Skip to content

Commit af2ab81

Browse files
committed
feat(ui): enhance organization switcher with dropdown menu and sorting options
1 parent d2a4aaf commit af2ab81

File tree

5 files changed

+7803
-36942
lines changed

5 files changed

+7803
-36942
lines changed

apps/app/src/components/organization-switcher.tsx

Lines changed: 155 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import {
1313
CommandSeparator,
1414
} from '@comp/ui/command';
1515
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@comp/ui/dialog';
16+
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@comp/ui/dropdown-menu';
1617
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select';
1718
import { useSidebar } from '@comp/ui/sidebar';
1819
import type { Organization } from '@db';
1920
import { Check, ChevronsUpDown, Loader2, Plus, Search } from 'lucide-react';
20-
import { useAction } from 'next-safe-action/hooks';
21+
import { useAction, type HookActionStatus } from 'next-safe-action/hooks';
2122
import Image from 'next/image';
2223
import { useRouter } from 'next/navigation';
2324
import { useQueryState } from 'nuqs';
@@ -63,7 +64,6 @@ function OrganizationAvatar({
6364
}: OrganizationAvatarProps) {
6465
const sizeClass = size === 'sm' ? 'h-6 w-6' : 'h-8 w-8';
6566

66-
// If logo URL exists, show the image
6767
if (logoUrl) {
6868
return (
6969
<div className={cn('relative overflow-hidden rounded-sm border', sizeClass, className)}>
@@ -72,7 +72,6 @@ function OrganizationAvatar({
7272
);
7373
}
7474

75-
// Fallback to initials
7675
const initials = name?.slice(0, 2).toUpperCase() || '';
7776

7877
let colorIndex = 0;
@@ -98,6 +97,99 @@ function OrganizationAvatar({
9897
);
9998
}
10099

100+
function OrganizationSwitcherContent({
101+
sortedOrganizations,
102+
currentOrganization,
103+
logoUrls,
104+
sortOrder,
105+
setSortOrder,
106+
status,
107+
pendingOrgId,
108+
handleOrgChange,
109+
handleOpenChange,
110+
router,
111+
getDisplayName,
112+
}: {
113+
sortedOrganizations: Organization[];
114+
currentOrganization: Organization | null;
115+
logoUrls: Record<string, string>;
116+
sortOrder: string;
117+
setSortOrder: (value: string) => void;
118+
status: HookActionStatus;
119+
pendingOrgId: string | null;
120+
handleOrgChange: (org: Organization) => void;
121+
handleOpenChange: (open: boolean) => void;
122+
router: ReturnType<typeof useRouter>;
123+
getDisplayName: (org: Organization) => string;
124+
}) {
125+
return (
126+
<Command>
127+
<div className="flex items-center border-b px-3">
128+
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
129+
<CommandInput
130+
placeholder="Search organization..."
131+
className="placeholder:text-muted-foreground flex h-11 w-full rounded-md border-0 bg-transparent py-3 text-sm outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
132+
/>
133+
</div>
134+
<div className="p-2">
135+
<Select value={sortOrder} onValueChange={setSortOrder}>
136+
<SelectTrigger className="w-full">
137+
<SelectValue placeholder="Sort by..." />
138+
</SelectTrigger>
139+
<SelectContent>
140+
<SelectItem value="alphabetical">Alphabetical</SelectItem>
141+
<SelectItem value="recent">Recently Created</SelectItem>
142+
</SelectContent>
143+
</Select>
144+
</div>
145+
<CommandList>
146+
<CommandEmpty>No results found</CommandEmpty>
147+
<CommandGroup className="max-h-[300px] overflow-y-auto">
148+
{sortedOrganizations.map((org) => (
149+
<CommandItem
150+
key={org.id}
151+
value={`${org.id} ${org.name || ''}`}
152+
onSelect={() => {
153+
if (org.id !== currentOrganization?.id) {
154+
handleOrgChange(org);
155+
} else {
156+
handleOpenChange(false);
157+
}
158+
}}
159+
disabled={status === 'executing'}
160+
className="flex items-center gap-2"
161+
>
162+
{status === 'executing' && pendingOrgId === org.id ? (
163+
<Loader2 className="h-4 w-4 animate-spin" />
164+
) : currentOrganization?.id === org.id ? (
165+
<Check className="h-4 w-4" />
166+
) : (
167+
<div className="h-4 w-4" />
168+
)}
169+
<OrganizationAvatar name={org.name} logoUrl={logoUrls[org.id]} size="sm" />
170+
<span className="truncate">{getDisplayName(org)}</span>
171+
</CommandItem>
172+
))}
173+
</CommandGroup>
174+
<CommandSeparator />
175+
<CommandGroup>
176+
<CommandItem
177+
onSelect={() => {
178+
router.push('/setup?intent=create-additional');
179+
handleOpenChange(false);
180+
}}
181+
disabled={status === 'executing'}
182+
className="flex items-center gap-2"
183+
>
184+
<Plus className="h-4 w-4" />
185+
Create Organization
186+
</CommandItem>
187+
</CommandGroup>
188+
</CommandList>
189+
</Command>
190+
);
191+
}
192+
101193
export function OrganizationSwitcher({
102194
organizations,
103195
organization,
@@ -106,7 +198,7 @@ export function OrganizationSwitcher({
106198
const { state } = useSidebar();
107199
const isCollapsed = state === 'collapsed';
108200
const router = useRouter();
109-
const [isDialogOpen, setIsDialogOpen] = useState(false);
201+
const [isOpen, setIsOpen] = useState(false);
110202
const [pendingOrgId, setPendingOrgId] = useState<string | null>(null);
111203
const [sortOrder, setSortOrder] = useState('alphabetical');
112204

@@ -145,7 +237,8 @@ export function OrganizationSwitcher({
145237
if (orgId) {
146238
router.push(`/${orgId}/`);
147239
}
148-
setIsDialogOpen(false);
240+
setIsOpen(false);
241+
setShowOrganizationSwitcher(null);
149242
setPendingOrgId(null);
150243
},
151244
onExecute: (args) => {
@@ -181,105 +274,68 @@ export function OrganizationSwitcher({
181274
};
182275

183276
const handleOpenChange = (open: boolean) => {
184-
setShowOrganizationSwitcher(open);
185-
setIsDialogOpen(open);
277+
setShowOrganizationSwitcher(open ? true : null);
278+
setIsOpen(open);
186279
};
187280

188-
return (
189-
<div className="w-full">
190-
<Dialog open={showOrganizationSwitcher ?? isDialogOpen} onOpenChange={handleOpenChange}>
191-
<DialogTrigger asChild>
192-
<Button
193-
variant="ghost"
194-
size={isCollapsed ? 'icon' : 'default'}
195-
className={cn(
196-
isCollapsed ? 'h-10 w-10 justify-center p-0' : 'h-10 w-full justify-start p-1 pr-2',
197-
status === 'executing' && 'cursor-not-allowed opacity-50',
198-
)}
199-
disabled={status === 'executing'}
200-
>
201-
<OrganizationAvatar
202-
name={currentOrganization?.name}
203-
logoUrl={currentOrganization?.id ? logoUrls[currentOrganization.id] : undefined}
204-
className="shrink-0"
205-
/>
206-
{!isCollapsed && (
207-
<>
208-
<span className="ml-2 flex-1 truncate text-left">{currentOrganization?.name}</span>
209-
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
210-
</>
211-
)}
212-
</Button>
213-
</DialogTrigger>
281+
const isOpenState = showOrganizationSwitcher ?? isOpen;
282+
283+
const triggerButton = (
284+
<Button
285+
variant="ghost"
286+
size={isCollapsed ? 'icon' : 'default'}
287+
className={cn(
288+
isCollapsed ? 'size-10 p-0' : 'h-10 w-full justify-start p-1 pr-2',
289+
status === 'executing' && 'cursor-not-allowed opacity-50',
290+
)}
291+
disabled={status === 'executing'}
292+
>
293+
<OrganizationAvatar
294+
name={currentOrganization?.name}
295+
logoUrl={currentOrganization?.id ? logoUrls[currentOrganization.id] : undefined}
296+
className="shrink-0"
297+
/>
298+
{!isCollapsed && (
299+
<>
300+
<span className="ml-2 flex-1 truncate text-left">{currentOrganization?.name}</span>
301+
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
302+
</>
303+
)}
304+
</Button>
305+
);
306+
307+
const contentProps = {
308+
sortedOrganizations,
309+
currentOrganization,
310+
logoUrls,
311+
sortOrder,
312+
setSortOrder,
313+
status,
314+
pendingOrgId,
315+
handleOrgChange,
316+
handleOpenChange,
317+
router,
318+
getDisplayName,
319+
};
320+
321+
if (isCollapsed) {
322+
return (
323+
<Dialog open={isOpenState} onOpenChange={handleOpenChange}>
324+
<DialogTrigger asChild>{triggerButton}</DialogTrigger>
214325
<DialogContent className="p-0 sm:max-w-[400px]">
215326
<DialogTitle className="sr-only">Select Organization</DialogTitle>
216-
<Command>
217-
<div className="flex items-center border-b px-3">
218-
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
219-
<CommandInput
220-
placeholder="Search organization..."
221-
className="placeholder:text-muted-foreground flex h-11 w-full rounded-md border-0 bg-transparent py-3 text-sm outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
222-
/>
223-
</div>
224-
<div className="p-2">
225-
<Select value={sortOrder} onValueChange={setSortOrder}>
226-
<SelectTrigger className="w-full">
227-
<SelectValue placeholder="Sort by..." />
228-
</SelectTrigger>
229-
<SelectContent>
230-
<SelectItem value="alphabetical">Alphabetical</SelectItem>
231-
<SelectItem value="recent">Recently Created</SelectItem>
232-
</SelectContent>
233-
</Select>
234-
</div>
235-
<CommandList>
236-
<CommandEmpty>No results found</CommandEmpty>
237-
<CommandGroup className="max-h-[300px] overflow-y-auto">
238-
{sortedOrganizations.map((org) => (
239-
<CommandItem
240-
key={org.id}
241-
// Search by id and name
242-
value={`${org.id} ${org.name || ''}`}
243-
onSelect={() => {
244-
if (org.id !== currentOrganization?.id) {
245-
handleOrgChange(org);
246-
} else {
247-
handleOpenChange(false);
248-
}
249-
}}
250-
disabled={status === 'executing'}
251-
className="flex items-center gap-2"
252-
>
253-
{status === 'executing' && pendingOrgId === org.id ? (
254-
<Loader2 className="h-4 w-4 animate-spin" />
255-
) : currentOrganization?.id === org.id ? (
256-
<Check className="h-4 w-4" />
257-
) : (
258-
<div className="h-4 w-4" />
259-
)}
260-
<OrganizationAvatar name={org.name} logoUrl={logoUrls[org.id]} size="sm" />
261-
<span className="truncate">{getDisplayName(org)}</span>
262-
</CommandItem>
263-
))}
264-
</CommandGroup>
265-
<CommandSeparator />
266-
<CommandGroup>
267-
<CommandItem
268-
onSelect={() => {
269-
router.push('/setup?intent=create-additional');
270-
setIsDialogOpen(false);
271-
}}
272-
disabled={status === 'executing'}
273-
className="flex items-center gap-2"
274-
>
275-
<Plus className="h-4 w-4" />
276-
Create Organization
277-
</CommandItem>
278-
</CommandGroup>
279-
</CommandList>
280-
</Command>
327+
<OrganizationSwitcherContent {...contentProps} />
281328
</DialogContent>
282329
</Dialog>
283-
</div>
330+
);
331+
}
332+
333+
return (
334+
<DropdownMenu open={isOpenState} onOpenChange={handleOpenChange}>
335+
<DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>
336+
<DropdownMenuContent align="start" className="w-[300px] p-0">
337+
<OrganizationSwitcherContent {...contentProps} />
338+
</DropdownMenuContent>
339+
</DropdownMenu>
284340
);
285341
}

0 commit comments

Comments
 (0)