OrganizationSelector.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import { LOCAL_STORAGE_KEYS } from 'common'
  2. import { Check, ChevronDown } from 'lucide-react'
  3. import { useMemo, useState, type ReactNode } from 'react'
  4. import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui'
  5. import {
  6. CreateOrganizationCard,
  7. OrganizationCard,
  8. } from '@/components/interfaces/Organization/OrganizationCard'
  9. import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
  10. import type { Organization } from '@/types'
  11. const VISIBLE_ORGANIZATIONS_LIMIT = 3
  12. const CONNECT_DISCLOSURE_TRIGGER_CLASSNAME =
  13. 'mx-auto flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 text-xs text-foreground-lighter transition-colors hover:bg-surface-200 hover:text-foreground'
  14. export const OrganizationSelector = ({
  15. organizations,
  16. selectedSlug,
  17. disabled = false,
  18. description,
  19. createLabel,
  20. createHrefParams,
  21. onSelect,
  22. getOrganizationDescription,
  23. }: {
  24. organizations: Organization[]
  25. selectedSlug?: string | null
  26. disabled?: boolean
  27. description?: ReactNode
  28. createLabel?: string
  29. createHrefParams?: { [key: string]: string }
  30. onSelect: (slug: string) => void
  31. getOrganizationDescription?: (organization: Organization) => ReactNode
  32. }) => {
  33. const [showMore, setShowMore] = useState(false)
  34. const [lastVisitedOrganization] = useLocalStorageQuery(
  35. LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
  36. ''
  37. )
  38. const { visibleOrganizations, overflowOrganizations } = useMemo(() => {
  39. const lastVisitedOrg = organizations.find(({ slug }) => slug === lastVisitedOrganization)
  40. const selectedIndex = organizations.findIndex(({ slug }) => slug === selectedSlug)
  41. const selectedInOverflow = selectedIndex >= VISIBLE_ORGANIZATIONS_LIMIT
  42. if (!!lastVisitedOrg) {
  43. const withoutLastVisited = organizations.filter(
  44. ({ slug }) => slug !== lastVisitedOrganization
  45. )
  46. return {
  47. visibleOrganizations: [
  48. lastVisitedOrg,
  49. ...withoutLastVisited.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1),
  50. ],
  51. overflowOrganizations: withoutLastVisited.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1),
  52. }
  53. }
  54. if (!selectedInOverflow || !selectedSlug) {
  55. return {
  56. visibleOrganizations: organizations.slice(0, VISIBLE_ORGANIZATIONS_LIMIT),
  57. overflowOrganizations: organizations.slice(VISIBLE_ORGANIZATIONS_LIMIT),
  58. }
  59. }
  60. const selected = organizations[selectedIndex]
  61. const withoutSelected = organizations.filter(({ slug }) => slug !== selectedSlug)
  62. return {
  63. visibleOrganizations: [
  64. ...withoutSelected.slice(0, VISIBLE_ORGANIZATIONS_LIMIT - 1),
  65. selected,
  66. ],
  67. overflowOrganizations: withoutSelected.slice(VISIBLE_ORGANIZATIONS_LIMIT - 1),
  68. }
  69. }, [lastVisitedOrganization, organizations, selectedSlug])
  70. const hasOverflow = overflowOrganizations.length > 0
  71. return (
  72. <section className="space-y-2" aria-label="Organizations">
  73. <div className="space-y-1">
  74. <p className="text-xs font-medium uppercase tracking-wider text-foreground-light">
  75. Organization
  76. </p>
  77. {description && <p className="text-xs text-foreground-lighter pr-4">{description}</p>}
  78. </div>
  79. <div className="space-y-2">
  80. {visibleOrganizations.map((organization) => (
  81. <ConnectOrganizationButton
  82. key={organization.slug}
  83. organization={organization}
  84. selected={selectedSlug === organization.slug}
  85. disabled={disabled}
  86. onClick={() => onSelect(organization.slug)}
  87. description={
  88. getOrganizationDescription?.(organization) ?? getPlanDescription(organization)
  89. }
  90. />
  91. ))}
  92. {!!createLabel && !!createHrefParams && (
  93. <CreateOrganizationCard params={createHrefParams} label={createLabel} />
  94. )}
  95. {hasOverflow && (
  96. <Collapsible open={showMore} onOpenChange={setShowMore}>
  97. <CollapsibleTrigger className={CONNECT_DISCLOSURE_TRIGGER_CLASSNAME}>
  98. <span>{showMore ? 'Show fewer' : `Show ${overflowOrganizations.length} more`}</span>
  99. <ChevronDown
  100. className={cn('size-3.5 transition-transform', showMore && 'rotate-180')}
  101. />
  102. </CollapsibleTrigger>
  103. <CollapsibleContent className="data-closed:animate-collapsible-up data-open:animate-collapsible-down overflow-hidden">
  104. <div className="space-y-2 pt-1">
  105. {overflowOrganizations.map((organization) => (
  106. <ConnectOrganizationButton
  107. key={organization.slug}
  108. organization={organization}
  109. selected={selectedSlug === organization.slug}
  110. disabled={disabled}
  111. onClick={() => onSelect(organization.slug)}
  112. description={
  113. getOrganizationDescription?.(organization) ?? getPlanDescription(organization)
  114. }
  115. />
  116. ))}
  117. </div>
  118. </CollapsibleContent>
  119. </Collapsible>
  120. )}
  121. </div>
  122. </section>
  123. )
  124. }
  125. const getPlanDescription = (organization: Organization) => `${organization.plan.name} Plan`
  126. const ConnectOrganizationButton = ({
  127. organization,
  128. selected,
  129. disabled,
  130. onClick,
  131. description,
  132. }: {
  133. organization: Organization
  134. selected?: boolean
  135. disabled?: boolean
  136. onClick?: () => void
  137. description?: ReactNode
  138. }) => (
  139. <button
  140. type="button"
  141. disabled={disabled}
  142. onClick={onClick}
  143. aria-pressed={selected}
  144. className={cn(
  145. 'group relative block w-full cursor-pointer text-left disabled:cursor-not-allowed disabled:opacity-50',
  146. disabled && 'pointer-events-none'
  147. )}
  148. >
  149. <OrganizationCard
  150. isLink={false}
  151. organization={organization}
  152. description={description}
  153. className={cn(
  154. 'pointer-events-none shadow-none transition-colors',
  155. !disabled && !selected && 'group-hover:border-default group-hover:bg-surface-200',
  156. selected &&
  157. 'border-brand bg-brand-200/20 dark:bg-brand-300 pr-10 group-hover:border-brand group-hover:bg-brand-200/20'
  158. )}
  159. />
  160. {selected && (
  161. <span className="pointer-events-none absolute right-3 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full bg-brand-500 dark:bg-brand-200 text-white dark:text-brand">
  162. <Check className="size-3.5" strokeWidth={2} />
  163. </span>
  164. )}
  165. </button>
  166. )