Skip to content

Commit 142452d

Browse files
committed
fix: clean up organization selector
1 parent 54c6caf commit 142452d

File tree

2 files changed

+106
-43
lines changed

2 files changed

+106
-43
lines changed

apps/dashboard/components/layout/organization-selector.tsx

Lines changed: 94 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import {
44
CaretDownIcon,
55
CheckIcon,
66
PlusIcon,
7+
SpinnerGapIcon,
78
UserIcon,
89
UsersIcon,
910
} from '@phosphor-icons/react';
1011
import { useRouter } from 'next/navigation';
11-
import * as React from 'react';
12+
import { useCallback, useState } from 'react';
1213
import { CreateOrganizationDialog } from '@/components/organizations/create-organization-dialog';
1314
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
1415
import { Button } from '@/components/ui/button';
@@ -32,19 +33,43 @@ const getOrganizationInitials = (name: string) => {
3233
.slice(0, 2);
3334
};
3435

36+
function filterOrganizations<T extends { name: string; slug?: string | null }>(
37+
orgs: T[] | undefined,
38+
query: string
39+
): T[] {
40+
if (!orgs || orgs.length === 0) {
41+
return [];
42+
}
43+
if (!query) {
44+
return orgs;
45+
}
46+
const q = query.toLowerCase();
47+
const filtered: T[] = [];
48+
for (const org of orgs) {
49+
const nameMatch = org.name.toLowerCase().includes(q);
50+
const slugMatch = org.slug ? org.slug.toLowerCase().includes(q) : false;
51+
if (nameMatch || slugMatch) {
52+
filtered.push(org);
53+
}
54+
}
55+
return filtered;
56+
}
57+
3558
export function OrganizationSelector() {
3659
const {
3760
organizations,
3861
activeOrganization,
3962
isLoading,
4063
setActiveOrganization,
4164
isSettingActiveOrganization,
65+
hasError,
4266
} = useOrganizations();
4367
const router = useRouter();
44-
const [isOpen, setIsOpen] = React.useState(false);
45-
const [showCreateDialog, setShowCreateDialog] = React.useState(false);
68+
const [isOpen, setIsOpen] = useState(false);
69+
const [showCreateDialog, setShowCreateDialog] = useState(false);
70+
const [query, setQuery] = useState('');
4671

47-
const handleSelectOrganization = React.useCallback(
72+
const handleSelectOrganization = useCallback(
4873
(organizationId: string | null) => {
4974
if (organizationId === activeOrganization?.id) {
5075
return;
@@ -58,21 +83,23 @@ export function OrganizationSelector() {
5883
[activeOrganization, setActiveOrganization]
5984
);
6085

61-
const handleCreateOrganization = React.useCallback(() => {
86+
const handleCreateOrganization = useCallback(() => {
6287
setShowCreateDialog(true);
6388
setIsOpen(false);
6489
}, []);
6590

66-
const handleManageOrganizations = React.useCallback(() => {
91+
const handleManageOrganizations = useCallback(() => {
6792
router.push('/organizations');
6893
setIsOpen(false);
6994
}, [router]);
7095

96+
const filteredOrganizations = filterOrganizations(organizations, query);
97+
7198
if (isLoading) {
7299
return (
73100
<div className="rounded border border-border/50 bg-accent/30 px-2 py-2">
74101
<div className="flex items-center gap-3">
75-
<Skeleton className="h-8 w-8 rounded-full" />
102+
<Skeleton className="h-8 w-8 rounded" />
76103
<div className="space-y-1">
77104
<Skeleton className="h-4 w-24 rounded" />
78105
<Skeleton className="h-3 w-16 rounded" />
@@ -82,19 +109,40 @@ export function OrganizationSelector() {
82109
);
83110
}
84111

112+
if (hasError) {
113+
return (
114+
<div className="rounded border border-destructive/50 bg-destructive/10 px-3 py-2 text-destructive">
115+
<div className="flex items-center gap-2 text-sm">
116+
<span>Failed to load workspaces</span>
117+
</div>
118+
</div>
119+
);
120+
}
121+
85122
return (
86123
<>
87-
<DropdownMenu onOpenChange={setIsOpen} open={isOpen}>
124+
<DropdownMenu
125+
onOpenChange={(open) => {
126+
setIsOpen(open);
127+
if (!open) {
128+
setQuery('');
129+
}
130+
}}
131+
open={isOpen}
132+
>
88133
<DropdownMenuTrigger asChild>
89134
<Button
90-
className="h-auto w-full p-0 hover:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0"
135+
aria-expanded={isOpen}
136+
aria-haspopup="listbox"
137+
className="h-auto w-full p-0 hover:bg-transparent"
91138
disabled={isSettingActiveOrganization}
139+
type="button"
92140
variant="ghost"
93141
>
94142
<div
95143
className={cn(
96-
'w-full rounded border border-border/50 bg-accent/30 px-2 py-2 transition-all duration-200',
97-
'hover:border-border/70 hover:bg-accent/50',
144+
'w-full rounded border border-border/50 bg-accent/30 px-2 py-2 transition-colors',
145+
'hover:border-border/70 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
98146
isSettingActiveOrganization && 'cursor-not-allowed opacity-70',
99147
isOpen && 'border-border/70 bg-accent/50'
100148
)}
@@ -111,35 +159,41 @@ export function OrganizationSelector() {
111159
getOrganizationInitials(activeOrganization.name)
112160
) : (
113161
<UserIcon
114-
className="h-4 w-4"
115-
size={32}
162+
className="not-dark:text-primary"
116163
weight="duotone"
117164
/>
118165
)}
119166
</AvatarFallback>
120167
</Avatar>
121168
<div className="flex min-w-0 flex-col text-left">
122-
<span className="max-w-[140px] truncate font-medium text-sm">
169+
<span className="max-w-[140px] truncate font-medium text-sm sm:max-w-[180px]">
123170
{activeOrganization?.name || 'Personal'}
124171
</span>
125-
<span className="max-w-[140px] truncate text-muted-foreground text-xs">
172+
<span className="max-w-[140px] truncate text-muted-foreground text-xs sm:max-w-[180px]">
126173
{activeOrganization?.slug || 'Your workspace'}
127174
</span>
128175
</div>
129176
</div>
130-
<CaretDownIcon
131-
className={cn(
132-
'h-4 w-4 text-muted-foreground transition-transform duration-200',
133-
isOpen && 'rotate-180'
134-
)}
135-
size={32}
136-
weight="duotone"
137-
/>
177+
{isSettingActiveOrganization ? (
178+
<SpinnerGapIcon
179+
aria-label="Switching workspace"
180+
className="h-4 w-4 animate-spin text-muted-foreground"
181+
weight="duotone"
182+
/>
183+
) : (
184+
<CaretDownIcon
185+
className={cn(
186+
'h-4 w-4 text-muted-foreground transition-transform duration-200',
187+
isOpen && 'rotate-180'
188+
)}
189+
weight="fill"
190+
/>
191+
)}
138192
</div>
139193
</div>
140194
</Button>
141195
</DropdownMenuTrigger>
142-
<DropdownMenuContent align="start" className="w-64 p-1" sideOffset={4}>
196+
<DropdownMenuContent align="start" className="w-72 p-1" sideOffset={4}>
143197
{/* Personal Workspace */}
144198
<DropdownMenuItem
145199
className={cn(
@@ -151,7 +205,7 @@ export function OrganizationSelector() {
151205
>
152206
<Avatar className="h-6 w-6">
153207
<AvatarFallback className="bg-muted text-xs">
154-
<UserIcon className="h-4 w-4" size={32} weight="duotone" />
208+
<UserIcon className="not-dark:text-primary" weight="duotone" />
155209
</AvatarFallback>
156210
</Avatar>
157211
<div className="flex min-w-0 flex-1 flex-col">
@@ -162,17 +216,16 @@ export function OrganizationSelector() {
162216
</div>
163217
{!activeOrganization && (
164218
<CheckIcon
165-
className="h-4 w-4 text-primary"
166-
size={32}
219+
className="h-4 w-4 not-dark:text-primary"
167220
weight="duotone"
168221
/>
169222
)}
170223
</DropdownMenuItem>
171224

172-
{organizations && organizations.length > 0 && (
173-
<>
225+
{filteredOrganizations && filteredOrganizations.length > 0 && (
226+
<div className="flex flex-col gap-1">
174227
<DropdownMenuSeparator className="my-1" />
175-
{organizations.map((org) => (
228+
{filteredOrganizations.map((org) => (
176229
<DropdownMenuItem
177230
className={cn(
178231
'flex cursor-pointer items-center gap-3 rounded px-2 py-2 transition-colors',
@@ -200,35 +253,36 @@ export function OrganizationSelector() {
200253
{activeOrganization?.id === org.id && (
201254
<CheckIcon
202255
className="h-4 w-4 text-primary"
203-
size={32}
204256
weight="duotone"
205257
/>
206258
)}
207259
</DropdownMenuItem>
208260
))}
209-
</>
261+
</div>
262+
)}
263+
264+
{filteredOrganizations.length === 0 && (
265+
<div className="px-2 py-2 text-muted-foreground text-xs">
266+
No workspaces match “{query}”.
267+
</div>
210268
)}
211269

212270
<DropdownMenuSeparator className="my-1" />
213271
<DropdownMenuItem
214272
className="flex cursor-pointer items-center gap-3 rounded px-2 py-2 transition-colors focus:bg-accent focus:text-accent-foreground"
215273
onClick={handleCreateOrganization}
216274
>
217-
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted">
218-
<PlusIcon className="h-4 w-4 text-muted-foreground" size={32} />
275+
<div className="flex h-6 w-6 items-center justify-center rounded bg-muted">
276+
<PlusIcon className="not-dark:text-primary" />
219277
</div>
220278
<span className="font-medium text-sm">Create Organization</span>
221279
</DropdownMenuItem>
222280
<DropdownMenuItem
223281
className="flex cursor-pointer items-center gap-3 rounded px-2 py-2 transition-colors focus:bg-accent focus:text-accent-foreground"
224282
onClick={handleManageOrganizations}
225283
>
226-
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted">
227-
<UsersIcon
228-
className="h-4 w-4 text-muted-foreground"
229-
size={32}
230-
weight="duotone"
231-
/>
284+
<div className="flex h-6 w-6 items-center justify-center rounded bg-muted">
285+
<UsersIcon className="not-dark:text-primary" weight="duotone" />
232286
</div>
233287
<span className="font-medium text-sm">Manage Organizations</span>
234288
</DropdownMenuItem>

apps/dashboard/hooks/use-organizations.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { authClient } from '@databuddy/auth/client';
22
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
33
import { toast } from 'sonner';
44

5+
export type OrganizationRole = 'owner' | 'admin' | 'member';
6+
57
type CreateOrganizationData = {
68
name: string;
79
slug?: string;
@@ -18,19 +20,18 @@ type UpdateOrganizationData = {
1820

1921
type InviteMemberData = {
2022
email: string;
21-
role: 'owner' | 'admin' | 'member';
23+
role: OrganizationRole;
2224
organizationId?: string;
2325
resend?: boolean;
2426
};
2527

2628
export type UpdateMemberData = {
2729
memberId: string;
28-
role: 'owner' | 'admin' | 'member';
30+
role: OrganizationRole;
2931
organizationId?: string;
3032
};
3133

3234
const QUERY_KEYS = {
33-
organization: (slug: string) => ['organization', slug] as const,
3435
organizationMembers: (orgId: string) =>
3536
['organizations', orgId, 'members'] as const,
3637
organizationInvitations: (orgId: string) =>
@@ -228,13 +229,15 @@ export function useOrganizations() {
228229
hasError: !!organizationsError || !!activeOrganizationError,
229230

230231
createOrganization: createOrganizationMutation.mutate,
232+
createOrganizationAsync: createOrganizationMutation.mutateAsync,
231233
updateOrganization: updateOrganizationMutation.mutate,
232234
updateOrganizationAsync: updateOrganizationMutation.mutateAsync,
233235
deleteOrganization: deleteOrganizationMutation.mutate,
234236
deleteOrganizationAsync: deleteOrganizationMutation.mutateAsync,
235237
setActiveOrganization: setActiveOrganizationMutation.mutate,
236238
setActiveOrganizationAsync: setActiveOrganizationMutation.mutateAsync,
237239
uploadOrganizationLogo: uploadOrganizationLogoMutation.mutate,
240+
uploadOrganizationLogoAsync: uploadOrganizationLogoMutation.mutateAsync,
238241

239242
isCreatingOrganization: createOrganizationMutation.isPending,
240243
isUpdatingOrganization: updateOrganizationMutation.isPending,
@@ -346,8 +349,11 @@ export function useOrganizationMembers(organizationId: string) {
346349
refetch,
347350

348351
inviteMember: inviteMemberMutation.mutate,
352+
inviteMemberAsync: inviteMemberMutation.mutateAsync,
349353
updateMember: updateMemberMutation.mutate,
354+
updateMemberAsync: updateMemberMutation.mutateAsync,
350355
removeMember: removeMemberMutation.mutate,
356+
removeMemberAsync: removeMemberMutation.mutateAsync,
351357

352358
isInvitingMember: inviteMemberMutation.isPending,
353359
isUpdatingMember: updateMemberMutation.isPending,
@@ -407,6 +413,7 @@ export function useOrganizationInvitations(organizationId: string) {
407413
refetch,
408414

409415
cancelInvitation: cancelInvitationMutation.mutate,
416+
cancelInvitationAsync: cancelInvitationMutation.mutateAsync,
410417

411418
isCancellingInvitation: cancelInvitationMutation.isPending,
412419
};
@@ -480,7 +487,9 @@ export function useUserInvitations() {
480487
refetch,
481488

482489
acceptInvitation: acceptInvitationMutation.mutate,
490+
acceptInvitationAsync: acceptInvitationMutation.mutateAsync,
483491
rejectInvitation: rejectInvitationMutation.mutate,
492+
rejectInvitationAsync: rejectInvitationMutation.mutateAsync,
484493

485494
isAcceptingInvitation: acceptInvitationMutation.isPending,
486495
isRejectingInvitation: rejectInvitationMutation.isPending,

0 commit comments

Comments
 (0)