Skip to content

Commit 072da62

Browse files
committed
Refactor organization dropdown for self-hosted and Clerk
Split the organization dropdown into a Clerk-specific component and a self-hosted variant. Updated the main dropdown export to use the Clerk version by default, and adjusted the self-hosted organization switcher to export the correct component. Modified Vite config to alias the Clerk dropdown to the self-hosted switcher when in self-hosted mode.
1 parent 0398038 commit 072da62

File tree

4 files changed

+257
-281
lines changed

4 files changed

+257
-281
lines changed
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { useAuth, useClerk, useOrganization, useOrganizationList } from '@clerk/clerk-react';
2+
import type { OrganizationMembershipResource } from '@clerk/types';
3+
import { FeatureFlagsKeysEnum } from '@novu/shared';
4+
import { AnimatePresence, motion } from 'motion/react';
5+
import { useCallback, useRef, useState } from 'react';
6+
import { RiAddCircleLine, RiArrowDownSLine, RiArrowRightSLine, RiLoader4Line } from 'react-icons/ri';
7+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/primitives/avatar';
8+
import {
9+
DropdownMenu,
10+
DropdownMenuContent,
11+
DropdownMenuItem,
12+
DropdownMenuTrigger,
13+
} from '@/components/primitives/dropdown-menu';
14+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip';
15+
import { useRegion } from '@/context/region';
16+
import { useFeatureFlag } from '@/hooks/use-feature-flag';
17+
import { ROUTES } from '@/utils/routes';
18+
import { cn } from '@/utils/ui';
19+
20+
const SCROLL_THRESHOLD = 50;
21+
const PAGE_SIZE = 8;
22+
23+
function getOrganizationInitials(name: string) {
24+
return name
25+
.split(' ')
26+
.map((word) => word[0])
27+
.join('')
28+
.toUpperCase()
29+
.slice(0, 2);
30+
}
31+
32+
type OrganizationAvatarProps = {
33+
imageUrl: string;
34+
name: string;
35+
size?: 'sm' | 'md';
36+
showShimmer?: boolean;
37+
};
38+
39+
function OrganizationAvatar({ imageUrl, name, size = 'sm', showShimmer = false }: OrganizationAvatarProps) {
40+
const sizeClass = size === 'sm' ? 'size-6' : 'size-8';
41+
const textSizeClass = size === 'sm' ? 'text-xs' : 'text-sm';
42+
43+
return (
44+
<span className={cn('relative rounded-full', showShimmer && 'overflow-hidden', sizeClass)}>
45+
<Avatar className={cn('rounded-full', sizeClass)}>
46+
<AvatarImage src={imageUrl} alt={name} />
47+
<AvatarFallback className={cn('bg-primary-base text-static-white', textSizeClass)}>
48+
{getOrganizationInitials(name)}
49+
</AvatarFallback>
50+
</Avatar>
51+
{showShimmer && (
52+
<span className="absolute inset-0 -translate-x-full rotate-12 bg-gradient-to-r from-transparent via-white/30 to-transparent group-hover:animate-[shimmer_0.8s_ease-in-out] pointer-events-none" />
53+
)}
54+
</span>
55+
);
56+
}
57+
58+
type OrganizationListItemProps = {
59+
membership: OrganizationMembershipResource;
60+
onSwitch: (id: string) => void;
61+
isSwitching: boolean;
62+
switchingToId: string | null;
63+
};
64+
65+
function OrganizationListItem({ membership, onSwitch, isSwitching, switchingToId }: OrganizationListItemProps) {
66+
const isCurrentlySwitching = isSwitching && switchingToId === membership.organization.id;
67+
68+
return (
69+
<motion.div
70+
initial={{ opacity: 0, y: -4 }}
71+
animate={{ opacity: 1, y: 0 }}
72+
exit={{ opacity: 0, y: -4 }}
73+
transition={{ duration: 0.15 }}
74+
>
75+
<DropdownMenuItem
76+
className="group flex cursor-pointer items-center gap-2 rounded-sm border-0 px-2 py-1.5 text-sm focus:bg-accent"
77+
onClick={() => onSwitch(membership.organization.id)}
78+
disabled={isSwitching}
79+
>
80+
<OrganizationAvatar imageUrl={membership.organization.imageUrl} name={membership.organization.name} />
81+
<Tooltip>
82+
<TooltipTrigger asChild>
83+
<span className="flex-1 truncate text-foreground-950 max-w-[180px]">{membership.organization.name}</span>
84+
</TooltipTrigger>
85+
<TooltipContent>{membership.organization.name}</TooltipContent>
86+
</Tooltip>
87+
{isCurrentlySwitching ? (
88+
<RiLoader4Line className="size-4 animate-spin text-foreground-600" />
89+
) : (
90+
<RiArrowRightSLine className="size-4 opacity-0 transition-opacity group-hover:opacity-100" />
91+
)}
92+
</DropdownMenuItem>
93+
</motion.div>
94+
);
95+
}
96+
97+
export function OrganizationDropdown() {
98+
const { organization: currentOrganization } = useOrganization();
99+
const { orgId } = useAuth();
100+
const clerk = useClerk();
101+
const { selectedRegion } = useRegion();
102+
const isRegionSelectorEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_REGION_SELECTOR_ENABLED, false);
103+
104+
const [isOpen, setIsOpen] = useState(false);
105+
const [isSwitching, setIsSwitching] = useState(false);
106+
const [switchingToId, setSwitchingToId] = useState<string | null>(null);
107+
const [isScrolled, setIsScrolled] = useState(false);
108+
const scrollContainerRef = useRef<HTMLDivElement>(null);
109+
110+
const { userMemberships, isLoaded } = useOrganizationList({
111+
userMemberships: {
112+
infinite: true,
113+
pageSize: PAGE_SIZE,
114+
},
115+
});
116+
117+
const handleOrganizationSwitch = async (organizationId: string) => {
118+
if (organizationId === orgId || isSwitching) return;
119+
120+
setIsSwitching(true);
121+
setSwitchingToId(organizationId);
122+
try {
123+
await clerk.setActive({ organization: organizationId });
124+
setIsOpen(false);
125+
} catch (error) {
126+
console.error('Failed to switch organization:', error);
127+
} finally {
128+
setIsSwitching(false);
129+
setSwitchingToId(null);
130+
}
131+
};
132+
133+
const handleScroll = useCallback(() => {
134+
const container = scrollContainerRef.current;
135+
if (!container) return;
136+
137+
setIsScrolled(container.scrollTop > 0);
138+
139+
if (!userMemberships?.hasNextPage || userMemberships?.isFetching) return;
140+
141+
const { scrollTop, scrollHeight, clientHeight } = container;
142+
if (scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD) {
143+
userMemberships.fetchNext?.();
144+
}
145+
}, [userMemberships]);
146+
147+
const filterMemberships = useCallback(
148+
(membership: OrganizationMembershipResource) => {
149+
if (membership.organization.id === orgId) return false;
150+
151+
if (isRegionSelectorEnabled) {
152+
const orgRegion = membership.organization.publicMetadata?.region as string | undefined;
153+
154+
return !orgRegion || orgRegion === selectedRegion;
155+
}
156+
157+
return true;
158+
},
159+
[orgId, isRegionSelectorEnabled, selectedRegion]
160+
);
161+
162+
if (!isLoaded || !currentOrganization) {
163+
return (
164+
<div className="w-full px-1.5 py-1.5">
165+
<div className="flex items-center gap-2 rounded-lg bg-neutral-alpha-50 px-2 py-1.5">
166+
<div className="size-6 animate-pulse rounded-full bg-neutral-alpha-100" />
167+
<div className="h-4 w-32 animate-pulse rounded bg-neutral-alpha-100" />
168+
</div>
169+
</div>
170+
);
171+
}
172+
173+
const filteredMemberships = userMemberships?.data?.filter(filterMemberships) || [];
174+
175+
return (
176+
<>
177+
<style>
178+
{`
179+
@keyframes shimmer {
180+
from { transform: translateX(-100%); }
181+
to { transform: translateX(100%); }
182+
}
183+
`}
184+
</style>
185+
186+
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
187+
<DropdownMenuTrigger asChild>
188+
<button
189+
className={cn(
190+
'group relative flex w-full items-center gap-2 rounded-lg px-1.5 py-1.5 transition-all duration-300',
191+
'hover:bg-background hover:shadow-sm',
192+
'before:absolute before:bottom-0 before:left-0 before:h-0 before:w-full before:border-b before:border-neutral-alpha-100 before:transition-all before:duration-300 before:content-[""]',
193+
'hover:before:border-transparent',
194+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:shadow-sm focus-visible:before:border-transparent'
195+
)}
196+
>
197+
<OrganizationAvatar imageUrl={currentOrganization.imageUrl} name={currentOrganization.name} showShimmer />
198+
<span className="text-sm font-medium text-foreground-950">{currentOrganization.name}</span>
199+
<RiArrowDownSLine className="ml-auto size-4 opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus:opacity-100" />
200+
</button>
201+
</DropdownMenuTrigger>
202+
203+
<DropdownMenuContent className="w-64 p-0" align="start">
204+
<div
205+
ref={scrollContainerRef}
206+
className="max-h-[200px] overflow-y-auto"
207+
role="group"
208+
aria-label="List of all organization memberships"
209+
onScroll={handleScroll}
210+
>
211+
<AnimatePresence mode="popLayout">
212+
{filteredMemberships.map((membership) => (
213+
<OrganizationListItem
214+
key={membership.id}
215+
membership={membership}
216+
onSwitch={handleOrganizationSwitch}
217+
isSwitching={isSwitching}
218+
switchingToId={switchingToId}
219+
/>
220+
))}
221+
</AnimatePresence>
222+
223+
{userMemberships?.isFetching && (
224+
<div className="flex items-center justify-center py-2">
225+
<RiLoader4Line className="size-4 animate-spin text-foreground-600" />
226+
</div>
227+
)}
228+
</div>
229+
230+
<DropdownMenuItem
231+
className={cn(
232+
'flex cursor-pointer items-center gap-2 rounded-none border-t border-neutral-alpha-200 px-3 py-1.5 text-sm transition-shadow focus:bg-accent hover:bg-accent',
233+
isScrolled && 'shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)]'
234+
)}
235+
onSelect={() => {
236+
window.location.href = ROUTES.SIGNUP_ORGANIZATION_LIST;
237+
}}
238+
>
239+
<RiAddCircleLine className="size-4" />
240+
<span className="text-foreground-950">Create organization</span>
241+
</DropdownMenuItem>
242+
</DropdownMenuContent>
243+
</DropdownMenu>
244+
</>
245+
);
246+
}
247+

0 commit comments

Comments
 (0)