fix(ui): profile page directive + avatar dropdown links/fallbacks

This commit is contained in:
Gabriel Brown
2026-07-11 17:32:49 -04:00
parent d873c8eaf7
commit cc241c8bbf
3 changed files with 107 additions and 5 deletions
@@ -1,5 +1,3 @@
'use server';
import { import {
AvatarUpload, AvatarUpload,
ProfileHeader, ProfileHeader,
@@ -29,6 +29,10 @@ export const AvatarDropdown = () => {
user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip', user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
); );
const avatarUrl = remoteImageUrl ?? currentImageUrl; const avatarUrl = remoteImageUrl ?? currentImageUrl;
// Empty/whitespace names must fall through to the email, so intentionally
// treat a blank string as "no name" (nullish coalescing would keep it).
const trimmedName = user?.name?.trim();
const displayName = trimmedName?.length ? trimmedName : user?.email?.trim();
if (isLoading) { if (isLoading) {
return ( return (
@@ -61,16 +65,16 @@ export const AvatarDropdown = () => {
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align='end'> <DropdownMenuContent align='end'>
{(user?.name ?? user?.email) && ( {displayName && (
<> <>
<DropdownMenuLabel className='text-center font-bold'> <DropdownMenuLabel className='text-center font-bold'>
{user.name?.trim() ?? user.email?.trim()} {displayName}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
)} )}
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href='/profile' className='w-full cursor-pointer'> <Link href='/settings/profile' className='w-full cursor-pointer'>
Edit Profile Edit Profile
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
@@ -0,0 +1,100 @@
import type { ComponentProps, ReactNode } from 'react';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { AvatarDropdown } from '../../src/components/layout/header/controls/AvatarDropdown';
// Stable sentinels: the real Convex `api` proxy hands back a fresh reference on
// every access, so `===` comparisons in the mocks below would never match.
vi.mock('@spoon/backend/convex/_generated/api.js', () => ({
api: {
auth: {
getUser: 'auth:getUser',
},
files: {
getImageUrl: 'files:getImageUrl',
},
},
}));
const { mockUseConvexAuth, mockUseQuery } = vi.hoisted(() => ({
mockUseConvexAuth: vi.fn(),
mockUseQuery: vi.fn(),
}));
vi.mock('convex/react', () => ({
useConvexAuth: mockUseConvexAuth,
useQuery: mockUseQuery,
}));
vi.mock('@convex-dev/auth/react', () => ({
useAuthActions: () => ({ signOut: vi.fn() }),
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock('@/lib/avatar', () => ({
isRemoteImageUrl: () => false,
}));
// Render the dropdown primitives as pass-through wrappers so the menu content
// is deterministically present under jsdom (Radix pointer behavior is flaky
// here). We are exercising AvatarDropdown's logic, not Radix.
vi.mock('@spoon/ui', () => ({
BasedAvatar: () => <div>avatar</div>,
Button: (props: ComponentProps<'button'>) => <button {...props} />,
DropdownMenu: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => (
<>{children}</>
),
DropdownMenuContent: ({ children }: { children?: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuLabel: ({ children }: { children?: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <hr />,
}));
const seedUser = (user: { name: string; email: string }) => {
mockUseConvexAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
});
mockUseQuery.mockImplementation((reference: unknown) => {
if (reference === api.auth.getUser) return user;
return undefined;
});
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('AvatarDropdown', () => {
it('falls back to the email when the name is an empty string', () => {
seedUser({ name: '', email: 'me@x.com' });
render(<AvatarDropdown />);
expect(screen.getByText('me@x.com')).toBeInTheDocument();
});
it('shows the trimmed name when it is present', () => {
seedUser({ name: 'Gib', email: 'me@x.com' });
render(<AvatarDropdown />);
expect(screen.getByText('Gib')).toBeInTheDocument();
expect(screen.queryByText('me@x.com')).not.toBeInTheDocument();
});
it('links Edit Profile directly to the settings profile page', () => {
seedUser({ name: 'Gib', email: 'me@x.com' });
render(<AvatarDropdown />);
const editLink = screen.getByRole('link', { name: /edit profile/i });
expect(editLink).toHaveAttribute('href', '/settings/profile');
});
});