feat: enhance testing and admin page functionality

- Mocked `useRouter` from `next/navigation` in tests to improve routing behavior during testing.
- Updated admin page tests to reflect changes in title display and removed unnecessary back link check.
- Refactored admin page header to improve accessibility and layout, displaying month and year more clearly.
- Removed unused imports and components to streamline code and enhance maintainability.
This commit is contained in:
2026-03-06 11:03:46 +03:00
parent a3152a4545
commit 53a899ea26
5 changed files with 51 additions and 34 deletions

View File

@@ -93,13 +93,12 @@ describe("AdminPage", () => {
}); });
}); });
it("shows admin title and back link when allowed and data loaded", async () => { it("shows admin title (month/year) when allowed and data loaded", async () => {
mockFetchForAdmin(); mockFetchForAdmin();
render(<AdminPage />); render(<AdminPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole("heading", { name: /admin|админка/i })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: /admin|админка/i })).toBeInTheDocument();
}); });
expect(screen.getByRole("link", { name: /back to calendar|назад к календарю/i })).toBeInTheDocument();
}); });
it("shows access denied when fetchAdminMe returns is_admin false", async () => { it("shows access denied when fetchAdminMe returns is_admin false", async () => {

View File

@@ -6,10 +6,8 @@
"use client"; "use client";
import Link from "next/link";
import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin"; import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin";
import { useTranslation } from "@/i18n/use-translation"; import { useTranslation } from "@/i18n/use-translation";
import { Button } from "@/components/ui/button";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen"; import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import { LoadingState } from "@/components/states/LoadingState"; import { LoadingState } from "@/components/states/LoadingState";
import { ErrorState } from "@/components/states/ErrorState"; import { ErrorState } from "@/components/states/ErrorState";
@@ -47,24 +45,28 @@ export default function AdminPage() {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{admin.adminAccessDeniedDetail ?? t("admin.access_denied")} {admin.adminAccessDeniedDetail ?? t("admin.access_denied")}
</p> </p>
<Button asChild variant="outline">
<Link href="/">{t("admin.back_to_calendar")}</Link>
</Button>
</div> </div>
</div> </div>
); );
} }
const month = admin.currentMonth.getMonth();
const year = admin.currentMonth.getFullYear();
return ( return (
<div className={PAGE_WRAPPER_CLASS}> <div className={PAGE_WRAPPER_CLASS}>
<header className="sticky top-0 z-10 flex items-center justify-between border-b bg-[var(--header-bg)] py-3"> <header className="sticky top-0 z-10 flex flex-col items-center border-b bg-[var(--header-bg)] py-3">
<h1 className="text-lg font-semibold"> <h1
{t("admin.title")} {monthName(admin.currentMonth.getMonth())}{" "} className="m-0 flex flex-col items-center justify-center gap-0 leading-none"
{admin.currentMonth.getFullYear()} aria-label={`${t("admin.title")}, ${monthName(month)} ${year}`}
>
<span className="text-xs font-normal leading-none text-muted">
{year}
</span>
<span className="text-[1.1rem] font-semibold leading-tight sm:text-[1.25rem]">
{monthName(month)}
</span>
</h1> </h1>
<Button asChild variant="ghost" size="sm">
<Link href="/">{t("admin.back_to_calendar")}</Link>
</Button>
</header> </header>
{admin.successMessage && ( {admin.successMessage && (

View File

@@ -10,6 +10,10 @@ import { resetAppStore } from "@/test/test-utils";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { useTelegramAuth } from "@/hooks/use-telegram-auth"; import { useTelegramAuth } from "@/hooks/use-telegram-auth";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
}));
vi.mock("@/hooks/use-telegram-auth", () => ({ vi.mock("@/hooks/use-telegram-auth", () => ({
useTelegramAuth: vi.fn(), useTelegramAuth: vi.fn(),
})); }));

View File

@@ -6,14 +6,14 @@
"use client"; "use client";
import { useRef, useState, useEffect, useCallback } from "react"; import { useRef, useState, useEffect, useCallback } from "react";
import Link from "next/link"; import { useRouter } from "next/navigation";
import { settingsButton } from "@telegram-apps/sdk-react";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useMonthData } from "@/hooks/use-month-data"; import { useMonthData } from "@/hooks/use-month-data";
import { useSwipe } from "@/hooks/use-swipe"; import { useSwipe } from "@/hooks/use-swipe";
import { useStickyScroll } from "@/hooks/use-sticky-scroll"; import { useStickyScroll } from "@/hooks/use-sticky-scroll";
import { useAutoRefresh } from "@/hooks/use-auto-refresh"; import { useAutoRefresh } from "@/hooks/use-auto-refresh";
import { useTranslation } from "@/i18n/use-translation";
import { CalendarHeader } from "@/components/calendar/CalendarHeader"; import { CalendarHeader } from "@/components/calendar/CalendarHeader";
import { CalendarGrid } from "@/components/calendar/CalendarGrid"; import { CalendarGrid } from "@/components/calendar/CalendarGrid";
import { DutyList } from "@/components/duty/DutyList"; import { DutyList } from "@/components/duty/DutyList";
@@ -80,7 +80,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
})) }))
); );
const { t } = useTranslation(); const router = useRouter();
const { retry } = useMonthData({ const { retry } = useMonthData({
initDataRaw, initDataRaw,
@@ -138,6 +138,35 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
} }
}, [loading, accessDenied, setAppContentReady]); }, [loading, accessDenied, setAppContentReady]);
// Show native Settings button in Telegram context menu for admins; click navigates to /admin.
useEffect(() => {
if (!isAdmin) return;
let offClick: (() => void) | undefined;
try {
if (settingsButton.mount.isAvailable()) {
settingsButton.mount();
}
if (settingsButton.show.isAvailable()) {
settingsButton.show();
}
if (settingsButton.onClick.isAvailable()) {
offClick = settingsButton.onClick(() => router.push("/admin"));
}
} catch {
// Non-Telegram environment; SettingsButton not available.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (settingsButton.hide.isAvailable()) {
settingsButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [isAdmin, router]);
return ( return (
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6"> <div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
<div <div
@@ -149,16 +178,6 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
disabled={navDisabled} disabled={navDisabled}
onPrevMonth={handlePrevMonth} onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth} onNextMonth={handleNextMonth}
trailingContent={
isAdmin ? (
<Link
href="/admin"
className="text-sm text-accent hover:underline focus-visible:outline-accent rounded"
>
{t("admin.link")}
</Link>
) : undefined
}
/> />
<CalendarGrid <CalendarGrid
currentMonth={currentMonth} currentMonth={currentMonth}

View File

@@ -5,7 +5,6 @@
"use client"; "use client";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTranslation } from "@/i18n/use-translation"; import { useTranslation } from "@/i18n/use-translation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -21,8 +20,6 @@ export interface CalendarHeaderProps {
disabled?: boolean; disabled?: boolean;
onPrevMonth: () => void; onPrevMonth: () => void;
onNextMonth: () => void; onNextMonth: () => void;
/** Optional content shown above the nav row (e.g. Admin link). */
trailingContent?: ReactNode;
className?: string; className?: string;
} }
@@ -31,7 +28,6 @@ export function CalendarHeader({
disabled = false, disabled = false,
onPrevMonth, onPrevMonth,
onNextMonth, onNextMonth,
trailingContent,
className, className,
}: CalendarHeaderProps) { }: CalendarHeaderProps) {
const { t, monthName, weekdayLabels } = useTranslation(); const { t, monthName, weekdayLabels } = useTranslation();
@@ -41,9 +37,6 @@ export function CalendarHeader({
return ( return (
<header className={cn("flex flex-col", className)}> <header className={cn("flex flex-col", className)}>
{trailingContent != null && (
<div className="flex justify-end mb-1 min-h-[1.5rem]">{trailingContent}</div>
)}
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<Button <Button
type="button" type="button"