Merge pull request #1310 from MBombeck/fix/display-name-greeting

fix(ui): name the account by its display name
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-11-01 01:14:41 +01:00
commit 9a6d9b6793
7 files changed
+73 -17

No files matched your search

+3 -3
View File
@@ -7,6 +7,7 @@ import Link from "next/link";
import { SlidersHorizontal, TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { accountName } from "@/lib/account-name";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
// Bare number — its own React- and label-map-free module, so importing it here
@@ -419,10 +420,9 @@ export default function InsightsPageClient() {
// boundary hydrates, and a greeting that personalises during the
// hydration render disagrees with the name-less SSR text — React
// #418. See `useMounted`.
const shownName = user ? accountName(user).trim() : "";
const heroGreetingName =
mounted && user?.username?.trim() && user.username.trim().length > 0
? user.username.split(/\s+/)[0]
: null;
mounted && shownName.length > 0 ? shownName.split(/\s+/)[0] : null;
// The advisor read only fires while the briefing is available, so with it
// unavailable these stay null: the hero keeps its fallback subtitle and the
// trends row its built-in captions.
@@ -32,6 +32,7 @@ import {
import { PageHeader } from "@/components/ui/page-header";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { accountName } from "@/lib/account-name";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { hourInTz } from "@/lib/tz/format";
@@ -143,7 +144,7 @@ export function DashboardHeader({
const greeting = greetingLine({
greeting: timeGreeting,
known: mounted && user != null,
name: user?.username?.trim() ? user.username : null,
name: user ? accountName(user).trim() || null : null,
createdAt: user?.createdAt,
});
const welcomeText = t(greeting.key, greeting.values);
@@ -18,6 +18,7 @@ const mockUserRef = {
value: {
id: "u1",
username: "testuser",
displayName: null as string | null,
email: "user@example.com",
role: "USER" as "USER" | "ADMIN",
avatarUrl: null,
@@ -119,6 +120,25 @@ const GATED_HREFS = [
"/achievements",
] as const;
describe("<SidebarNav> account identity", () => {
afterEach(() => {
mockUserRef.value = { ...mockUserRef.value, displayName: null };
});
it("names the account by its display name when one is set", () => {
mockUserRef.value = { ...mockUserRef.value, displayName: "Jane Doe" };
const html = render();
expect(html).toContain(">Jane Doe</p>");
expect(html).toContain(">JD</span>");
expect(html).not.toContain(">testuser</p>");
});
it("falls back to the username without a display name", () => {
const html = render();
expect(html).toContain(">testuser</p>");
});
});
describe("<SidebarNav> first-paint fail-closed module gate (v1.25.12)", () => {
it("renders core destinations on first paint regardless of the module map", () => {
const maps: Record<string, boolean>[] = [
+10 -10
View File
@@ -40,6 +40,7 @@ import {
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { accountName } from "@/lib/account-name";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useNavModules } from "@/hooks/use-nav-modules";
import { useMounted } from "@/hooks/use-mounted";
@@ -153,6 +154,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
const sharedRecord = user?.accountAccess?.active != null;
if (!user) return null;
const name = accountName(user);
const themeIcon =
theme === "system" ? (
@@ -166,13 +168,13 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
const identityBlock = (
<>
<Avatar className="h-9 w-9 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
{getInitials(name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{user.username}</p>
<p className="truncate text-sm font-semibold">{name}</p>
{user.email && (
<p className="text-muted-foreground truncate text-xs">{user.email}</p>
)}
@@ -282,11 +284,9 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<DropdownMenuTrigger className="hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]">
<span className="sr-only">{t("nav.userMenu")}</span>
<Avatar className="h-8 w-8" aria-hidden="true">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={user.username} />
)}
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
{getInitials(name)}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
@@ -311,13 +311,13 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
label={t("nav.accountSettings")}
>
<Avatar className="h-8 w-8 shrink-0" aria-hidden="true">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
{getInitials(name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.username}</p>
<p className="truncate text-sm font-medium">{name}</p>
{user.email && (
<p className="text-muted-foreground truncate text-xs">
{user.email}
+4 -3
View File
@@ -26,6 +26,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import Link from "next/link";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { accountName } from "@/lib/account-name";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTheme } from "@/components/providers";
import { useTranslations } from "@/lib/i18n/context";
@@ -143,13 +144,13 @@ export function TopBar() {
<span className="sr-only">{t("nav.userMenu")}</span>
<Avatar className="size-7" aria-hidden="true">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={user.username} />
<AvatarImage src={avatarUrl} alt={accountName(user)} />
)}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
{getInitials(accountName(user))}
</AvatarFallback>
</Avatar>
<span className="hidden sm:inline">{user.username}</span>
<span className="hidden sm:inline">{accountName(user)}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { accountName } from "../account-name";
describe("accountName", () => {
it("names the account by its display name when one is set", () => {
expect(accountName({ username: "jdoe", displayName: "Jane" })).toBe("Jane");
});
it("trims the display name it shows", () => {
expect(accountName({ username: "jdoe", displayName: " Jane " })).toBe(
"Jane",
);
});
it("falls back to the username when the display name is empty or absent", () => {
expect(accountName({ username: "jdoe", displayName: " " })).toBe("jdoe");
expect(accountName({ username: "jdoe", displayName: null })).toBe("jdoe");
expect(accountName({ username: "jdoe" })).toBe("jdoe");
});
});
+13
View File
@@ -0,0 +1,13 @@
/**
* The name the app shows and addresses an account by: the display name when
* one is set, the username otherwise. Greetings, the top bar and the sidebar
* all read it here, so the display name edited on the profile card reaches
* every surface that names the account.
*/
export function accountName(user: {
username: string;
displayName?: string | null;
}): string {
const displayName = user.displayName?.trim();
return displayName ? displayName : user.username;
}