mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #1310 from MBombeck/fix/display-name-greeting
fix(ui): name the account by its display name
This commit is contained in:
7 files changed
+73
-17
No files matched your search
@@ -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>[] = [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user