feat(i18n): localize shell and common UI

This commit is contained in:
2026-06-11 17:54:56 +02:00
parent c3cf4182ad
commit 2fa6611719
15 changed files with 477 additions and 136 deletions
+6 -1
View File
@@ -3,6 +3,7 @@ import { Toaster } from "sonner"
import Navbar from "@/components/layout/navbar" import Navbar from "@/components/layout/navbar"
import AppSidebar from "@/components/layout/sidebar" import AppSidebar from "@/components/layout/sidebar"
import { SidebarProvider } from "@/components/ui/sidebar" import { SidebarProvider } from "@/components/ui/sidebar"
import { getI18n } from "@/i18n/server"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
export default async function LayoutDashboard({ export default async function LayoutDashboard({
@@ -11,10 +12,14 @@ export default async function LayoutDashboard({
children: React.ReactNode children: React.ReactNode
}) { }) {
const session = await auth() const session = await auth()
const { dictionary } = await getI18n()
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar userRole={session?.user.role} /> <AppSidebar
copy={dictionary.layout.sidebar}
userRole={session?.user.role}
/>
<main className="w-full"> <main className="w-full">
<Navbar /> <Navbar />
<div className="flex-1 p-6">{children}</div> <div className="flex-1 p-6">{children}</div>
+8 -4
View File
@@ -1,11 +1,15 @@
import Link from "next/link" import Link from "next/link"
export default function ForbiddenPage() { import { getI18n } from "@/i18n/server"
export default async function ForbiddenPage() {
const { dictionary } = await getI18n()
const copy = dictionary.common.forbidden
return ( return (
<main> <main>
<h1>Acceso denegado</h1> <h1>{copy.title}</h1>
<p>No tienes permisos para acceder a esta sección.</p> <p>{copy.description}</p>
<Link href="/">Volver al inicio</Link> <Link href="/">{copy.homeLink}</Link>
</main> </main>
) )
} }
+7 -2
View File
@@ -1,8 +1,13 @@
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import type { Dictionary } from "@/i18n/dictionaries"
import { signOut } from "@/lib/auth" import { signOut } from "@/lib/auth"
import { SIGN_IN_URL } from "@/lib/constants" import { SIGN_IN_URL } from "@/lib/constants"
export function SignOut() { type SignOutProps = {
copy: Dictionary["layout"]["logout"]
}
export function SignOut({ copy }: SignOutProps) {
return ( return (
<form <form
action={async () => { action={async () => {
@@ -11,7 +16,7 @@ export function SignOut() {
}} }}
> >
<Button type="submit" variant="destructive"> <Button type="submit" variant="destructive">
Sign Out {copy.label}
</Button> </Button>
</form> </form>
) )
+7 -2
View File
@@ -3,6 +3,7 @@ import Link from "next/link"
import Search from "@/components/common/search" import Search from "@/components/common/search"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
interface PageHeaderProps { interface PageHeaderProps {
title?: string title?: string
@@ -11,17 +12,21 @@ interface PageHeaderProps {
data: unknown[] data: unknown[]
} }
export default function PageHeader({ export default async function PageHeader({
title, title,
link, link,
search, search,
data, data,
}: PageHeaderProps) { }: PageHeaderProps) {
const { dictionary } = await getI18n()
return ( return (
<header className="mb-4 flex w-full flex-col gap-4 md:flex-row"> <header className="mb-4 flex w-full flex-col gap-4 md:flex-row">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">{title}</h1> <h1 className="text-2xl font-bold">{title}</h1>
<Search hidden={data.length === 0 && !search} /> <Search
copy={dictionary.common.search}
hidden={data.length === 0 && !search}
/>
</div> </div>
{link && ( {link && (
<div className="justify-end md:ml-auto md:flex"> <div className="justify-end md:ml-auto md:flex">
+101
View File
@@ -0,0 +1,101 @@
"use client"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import type { Dictionary } from "@/i18n/dictionaries"
type PaginationCopy = Dictionary["common"]["pagination"]
type PaginationClientProps = {
copy: PaginationCopy
totalPages: number
}
export function PaginationClient({ copy, totalPages }: PaginationClientProps) {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const currentPage = Number(searchParams.get("page")) || 1
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams)
params.set("page", pageNumber.toString())
router.push(`${pathname}?${params.toString()}`)
return `${pathname}?${params.toString()}`
}
const getPageNumbers = () => {
let start = Math.max(1, currentPage - 1)
let end = Math.min(totalPages, currentPage + 1)
// Always try to show 3 pages if possible
if (end - start < 2) {
if (start === 1) {
end = Math.min(totalPages, start + 2)
} else if (end === totalPages) {
start = Math.max(1, end - 2)
}
}
const pages = []
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
const pageNumbers = getPageNumbers()
return (
<div className="mt-4 flex items-center justify-between">
<div>
{copy.summaryPrefix} {currentPage} {copy.summarySeparator} {totalPages}
</div>
<div>
<Pagination>
<PaginationContent>
{currentPage > 1 && (
<PaginationItem>
<PaginationPrevious
onClick={() => createPageURL(currentPage - 1)}
className="cursor-pointer"
>
{copy.previous}
</PaginationPrevious>
</PaginationItem>
)}
{pageNumbers.map((page) => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => createPageURL(page)}
isActive={page === currentPage}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
{currentPage < totalPages && (
<PaginationItem>
<PaginationNext
onClick={() => createPageURL(currentPage + 1)}
className="cursor-pointer"
>
{copy.next}
</PaginationNext>
</PaginationItem>
)}
</PaginationContent>
</Pagination>
</div>
</div>
)
}
+8 -87
View File
@@ -1,97 +1,18 @@
"use client" import { getI18n } from "@/i18n/server"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { PaginationClient } from "./pagination.client"
import { export default async function PaginationButtons({
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
export default function PaginationButtons({
totalPages, totalPages,
}: { }: {
totalPages: number totalPages: number
}) { }) {
const pathname = usePathname() const { dictionary } = await getI18n()
const searchParams = useSearchParams()
const router = useRouter()
const currentPage = Number(searchParams.get("page")) || 1
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams)
params.set("page", pageNumber.toString())
router.push(`${pathname}?${params.toString()}`)
return `${pathname}?${params.toString()}`
}
const getPageNumbers = () => {
let start = Math.max(1, currentPage - 1)
let end = Math.min(totalPages, currentPage + 1)
// Always try to show 3 pages if possible
if (end - start < 2) {
if (start === 1) {
end = Math.min(totalPages, start + 2)
} else if (end === totalPages) {
start = Math.max(1, end - 2)
}
}
const pages = []
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
const pageNumbers = getPageNumbers()
return ( return (
<div className="mt-4 flex items-center justify-between"> <PaginationClient
<div> copy={dictionary.common.pagination}
Showing page {currentPage} of {totalPages} totalPages={totalPages}
</div> />
<div>
<Pagination>
<PaginationContent>
{currentPage > 1 && (
<PaginationItem>
<PaginationPrevious
onClick={() => createPageURL(currentPage - 1)}
className="cursor-pointer"
>
Previous
</PaginationPrevious>
</PaginationItem>
)}
{pageNumbers.map((page) => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => createPageURL(page)}
isActive={page === currentPage}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
{currentPage < totalPages && (
<PaginationItem>
<PaginationNext
onClick={() => createPageURL(currentPage + 1)}
className="cursor-pointer"
>
Next
</PaginationNext>
</PaginationItem>
)}
</PaginationContent>
</Pagination>
</div>
</div>
) )
} }
+9 -3
View File
@@ -5,17 +5,23 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useDebouncedCallback } from "use-debounce" import { useDebouncedCallback } from "use-debounce"
import type { Dictionary } from "@/i18n/dictionaries"
import { Input } from "../ui/input" import { Input } from "../ui/input"
type SearchCopy = Dictionary["common"]["search"]
interface SearchProps { interface SearchProps {
copy: SearchCopy
paramKey?: string paramKey?: string
placeholder?: string placeholder?: string
[x: string]: unknown [x: string]: unknown
} }
export default function Search({ export default function Search({
copy,
paramKey = "search", paramKey = "search",
placeholder = "Search...", placeholder = copy.placeholder,
...props ...props
}: SearchProps) { }: SearchProps) {
const pathname = usePathname() const pathname = usePathname()
@@ -73,7 +79,7 @@ export default function Search({
ref={inputRef} ref={inputRef}
type="text" type="text"
role="searchbox" role="searchbox"
aria-label="Buscar" aria-label={copy.label}
placeholder={placeholder} placeholder={placeholder}
value={search} value={search}
onChange={(e) => { onChange={(e) => {
@@ -86,7 +92,7 @@ export default function Search({
{search && ( {search && (
<button <button
type="button" type="button"
aria-label="Limpiar búsqueda" aria-label={copy.clearLabel}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2" className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
onClick={clearSearch} onClick={clearSearch}
> >
+23 -12
View File
@@ -1,6 +1,9 @@
"use client"
import { Plus } from "lucide-react" import { Plus } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import type { Dictionary } from "@/i18n/dictionaries"
import { ENVIRONMENT } from "@/lib/constants" import { ENVIRONMENT } from "@/lib/constants"
import { Button } from "../ui/button" import { Button } from "../ui/button"
@@ -13,35 +16,43 @@ import {
} from "../ui/dropdown-menu" } from "../ui/dropdown-menu"
import ResetButton from "./resetButton" import ResetButton from "./resetButton"
const items = [ type AddMenuCopy = Dictionary["layout"]["addMenu"]
type AddMenuProps = {
copy: AddMenuCopy
resetCopy: Dictionary["layout"]["resetDatabase"]
}
const items: { key: keyof AddMenuCopy; href: string }[] = [
{ {
name: "Category", key: "category",
href: "/inventory/categories/new", href: "/inventory/categories/new",
}, },
{ {
name: "Item", key: "item",
href: "/inventory/items/new", href: "/inventory/items/new",
}, },
{ {
name: "Asset", key: "asset",
href: "/inventory/assets/new", href: "/inventory/assets/new",
}, },
{ {
name: "Recipient", key: "recipient",
href: "/recipients/new", href: "/recipients/new",
}, },
{ {
name: "Assignment", key: "assignment",
href: "/assignments/new", href: "/assignments/new",
}, },
] ]
export default function AddMenu() { export default function AddMenu({ copy, resetCopy }: AddMenuProps) {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button type="button" className="btn btn-primary"> <Button type="button" aria-label={copy.add} className="btn btn-primary">
<Plus /> <Plus />
<span className="sr-only">{copy.add}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
@@ -51,18 +62,18 @@ export default function AddMenu() {
className="flex cursor-pointer items-center gap-2" className="flex cursor-pointer items-center gap-2"
passHref passHref
> >
<Plus /> Import <Plus /> {copy.import}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{items.map((item) => ( {items.map((item) => (
<DropdownMenuItem key={item.name} asChild> <DropdownMenuItem key={item.key} asChild>
<Link <Link
href={item.href} href={item.href}
className="flex cursor-pointer items-center gap-2" className="flex cursor-pointer items-center gap-2"
passHref passHref
> >
<Plus /> {item.name} <Plus /> {copy[item.key]}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
@@ -70,7 +81,7 @@ export default function AddMenu() {
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<ResetButton /> <ResetButton copy={resetCopy} />
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} )}
+8 -3
View File
@@ -28,7 +28,10 @@ export default async function Navbar() {
<nav className="flex items-center justify-between border-b p-4"> <nav className="flex items-center justify-between border-b p-4">
<SidebarTrigger /> <SidebarTrigger />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<AddMenu /> <AddMenu
copy={dictionary.layout.addMenu}
resetCopy={dictionary.layout.resetDatabase}
/>
<LanguageSwitcher <LanguageSwitcher
activeLocale={locale} activeLocale={locale}
copy={dictionary.common.languageSwitcher} copy={dictionary.common.languageSwitcher}
@@ -57,10 +60,12 @@ export default async function Navbar() {
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel> <DropdownMenuLabel>
{dictionary.layout.navbar.accountLabel}
</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"> <DropdownMenuItem variant="destructive">
<SignOut /> <SignOut copy={dictionary.layout.logout} />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
+9 -4
View File
@@ -5,8 +5,13 @@ import { useRouter } from "next/navigation"
import { signOut } from "next-auth/react" import { signOut } from "next-auth/react"
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import type { Dictionary } from "@/i18n/dictionaries"
export default function ResetButton() { type ResetButtonProps = {
copy: Dictionary["layout"]["resetDatabase"]
}
export default function ResetButton({ copy }: ResetButtonProps) {
const router = useRouter() const router = useRouter()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -20,11 +25,11 @@ export default function ResetButton() {
}) })
if (response.ok) { if (response.ok) {
toast.success("Database reseted successfully") toast.success(copy.successToast)
signOut() signOut()
router.push("/login") router.push("/login")
} else { } else {
toast.error("Error resetting database") toast.error(copy.errorToast)
} }
setLoading(false) setLoading(false)
} }
@@ -37,7 +42,7 @@ export default function ResetButton() {
disabled={loading} disabled={loading}
> >
{loading ? <Loader2 className="animate-spin" /> : <Trash />} {loading ? <Loader2 className="animate-spin" /> : <Trash />}
{loading ? "Resetting..." : "Reset Database"} {loading ? copy.loading : copy.idle}
</button> </button>
) )
} }
+44 -17
View File
@@ -22,60 +22,84 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import type { UserRole } from "@/generated/prisma/client" import type { UserRole } from "@/generated/prisma/client"
import type { Dictionary } from "@/i18n/dictionaries"
import { SidebarSection } from "./sidebar/sidebarSection" import { SidebarSection } from "./sidebar/sidebarSection"
const items = [ type SidebarCopy = Dictionary["layout"]["sidebar"]
type SidebarLabelKey = keyof SidebarCopy
type SidebarItem =
| {
type: "item"
labelKey: SidebarLabelKey
url: string
icon: React.ElementType
}
| {
type: "section"
labelKey: SidebarLabelKey
url: string
icon: React.ElementType
items: { labelKey: SidebarLabelKey; url: string }[]
}
const items: SidebarItem[] = [
{ {
type: "item", type: "item",
title: "Home", labelKey: "home",
url: "/", url: "/",
icon: Home, icon: Home,
}, },
{ {
type: "section", type: "section",
title: "Inventory", labelKey: "inventory",
url: "#", url: "#",
icon: Package, icon: Package,
items: [ items: [
{ {
title: "Items", labelKey: "items",
url: "/inventory/items", url: "/inventory/items",
}, },
{ {
title: "Categories", labelKey: "categories",
url: "/inventory/categories", url: "/inventory/categories",
}, },
{ {
title: "Assets", labelKey: "assets",
url: "/inventory/assets", url: "/inventory/assets",
}, },
], ],
}, },
{ {
type: "item", type: "item",
title: "Recipients", labelKey: "recipients",
url: "/recipients", url: "/recipients",
icon: User, icon: User,
}, },
{ {
type: "item", type: "item",
title: "Movements", labelKey: "movements",
url: "/movements", url: "/movements",
icon: BarChart, icon: BarChart,
}, },
{ {
type: "item", type: "item",
title: "Assignments", labelKey: "assignments",
url: "/assignments", url: "/assignments",
icon: Clipboard, icon: Clipboard,
}, },
] ]
export default function AppSidebar({ export default function AppSidebar({
copy,
userRole, userRole,
...props ...props
}: React.ComponentProps<typeof Sidebar> & { userRole?: UserRole }) { }: React.ComponentProps<typeof Sidebar> & {
copy: SidebarCopy
userRole?: UserRole
}) {
const pathname = usePathname() const pathname = usePathname()
const visibleItems = const visibleItems =
userRole === "ADMIN" userRole === "ADMIN"
@@ -83,10 +107,10 @@ export default function AppSidebar({
...items, ...items,
{ {
type: "item", type: "item",
title: "Users", labelKey: "users",
url: "/admin/users", url: "/admin/users",
icon: Shield, icon: Shield,
}, } satisfies SidebarItem,
] ]
: items : items
@@ -110,11 +134,11 @@ export default function AppSidebar({
: pathname.startsWith(item.url) : pathname.startsWith(item.url)
return ( return (
<SidebarMenuItem key={`item-${item.title}`}> <SidebarMenuItem key={`item-${item.labelKey}`}>
<SidebarMenuButton asChild isActive={isActive}> <SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}> <Link href={item.url}>
<item.icon className="mr-2 h-4 w-4" /> <item.icon className="mr-2 h-4 w-4" />
<span>{item.title}</span> <span>{copy[item.labelKey]}</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -123,10 +147,13 @@ export default function AppSidebar({
if (item.type === "section") { if (item.type === "section") {
return ( return (
<SidebarSection <SidebarSection
key={`section-${item.title}`} key={`section-${item.labelKey}`}
title={item.title} title={copy[item.labelKey]}
icon={item.icon} icon={item.icon}
items={item.items} items={item.items.map((subItem) => ({
title: copy[subItem.labelKey],
url: subItem.url,
}))}
/> />
) )
} }
+50
View File
@@ -7,6 +7,56 @@ export const en = {
es: "Spanish", es: "Spanish",
}, },
}, },
search: {
placeholder: "Search...",
label: "Search",
clearLabel: "Clear search",
},
pagination: {
summaryPrefix: "Showing page",
summarySeparator: "of",
previous: "Previous",
next: "Next",
},
forbidden: {
title: "Access denied",
description: "You do not have permission to access this section.",
homeLink: "Back to home",
},
},
layout: {
sidebar: {
home: "Home",
inventory: "Inventory",
items: "Items",
categories: "Categories",
assets: "Assets",
recipients: "Recipients",
movements: "Movements",
assignments: "Assignments",
users: "Users",
},
navbar: {
accountLabel: "My Account",
},
addMenu: {
add: "Add",
import: "Import",
category: "Category",
item: "Item",
asset: "Asset",
recipient: "Recipient",
assignment: "Assignment",
},
resetDatabase: {
idle: "Reset Database",
loading: "Resetting...",
successToast: "Database reset successfully",
errorToast: "Error resetting database",
},
logout: {
label: "Sign Out",
},
}, },
login: { login: {
title: "Sign In", title: "Sign In",
+50
View File
@@ -9,6 +9,56 @@ export const es = {
es: "Español", es: "Español",
}, },
}, },
search: {
placeholder: "Buscar...",
label: "Buscar",
clearLabel: "Limpiar búsqueda",
},
pagination: {
summaryPrefix: "Mostrando página",
summarySeparator: "de",
previous: "Anterior",
next: "Siguiente",
},
forbidden: {
title: "Acceso denegado",
description: "No tienes permisos para acceder a esta sección.",
homeLink: "Volver al inicio",
},
},
layout: {
sidebar: {
home: "Inicio",
inventory: "Inventario",
items: "Artículos",
categories: "Categorías",
assets: "Activos",
recipients: "Destinatarios",
movements: "Movimientos",
assignments: "Asignaciones",
users: "Usuarios",
},
navbar: {
accountLabel: "Mi cuenta",
},
addMenu: {
add: "Añadir",
import: "Importar",
category: "Categoría",
item: "Artículo",
asset: "Activo",
recipient: "Destinatario",
assignment: "Asignación",
},
resetDatabase: {
idle: "Reiniciar base de datos",
loading: "Reiniciando...",
successToast: "Base de datos reiniciada correctamente",
errorToast: "Error al reiniciar la base de datos",
},
logout: {
label: "Cerrar sesión",
},
}, },
login: { login: {
title: "Iniciar sesión", title: "Iniciar sesión",
+40 -1
View File
@@ -86,8 +86,47 @@ test.describe("language switcher", () => {
await expect( await expect(
page.getByRole("heading", { name: "Panel de control" }), page.getByRole("heading", { name: "Panel de control" }),
).toBeVisible() ).toBeVisible()
await expect(page.getByRole("link", { name: /Inicio/ })).toBeVisible()
await page.getByRole("button", { name: /Inventario/ }).click()
await expect(page.getByRole("link", { name: /Artículos/ })).toBeVisible()
await expect(
page.getByRole("link", { name: /Destinatarios/ }),
).toBeVisible()
await expect(page.getByRole("link", { name: /Usuarios/ })).toBeVisible()
await page.getByRole("button", { name: "Añadir" }).click()
await expect(page.getByRole("menuitem", { name: /Importar/ })).toBeVisible()
await expect(
page.getByRole("menuitem", { name: /Categoría/ }),
).toBeVisible()
await expect(
page.getByRole("menuitem", { name: /Asignación/ }),
).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByText("E2E Admin")).toBeVisible() await expect(page.getByText("E2E Admin")).toBeVisible()
await expect(page.getByText("admin@example.test")).toBeVisible() await page.getByText("E2E Admin").click()
await expect(page.getByText("Mi cuenta")).toBeVisible()
await expect(
page.getByRole("button", { name: "Cerrar sesión" }),
).toBeVisible()
await page.keyboard.press("Escape")
await page.goto("/admin/users")
await expect(page.getByPlaceholder("Buscar...")).toBeVisible()
await expect(page.getByRole("searchbox", { name: "Buscar" })).toBeVisible()
await page.goto("/forbidden")
await expect(
page.getByRole("heading", { name: "Acceso denegado" }),
).toBeVisible()
await expect(
page.getByText("No tienes permisos para acceder a esta sección."),
).toBeVisible()
await expect(
page.getByRole("link", { name: "Volver al inicio" }),
).toBeVisible()
await expectLocaleCookie(page, "es") await expectLocaleCookie(page, "es")
}) })
}) })
+107
View File
@@ -44,6 +44,113 @@ describe("i18n dictionaries", () => {
}) })
}) })
it("provides localized shell and common copy for English and Spanish", () => {
expect(getDictionary("en").layout).toEqual({
sidebar: {
home: "Home",
inventory: "Inventory",
items: "Items",
categories: "Categories",
assets: "Assets",
recipients: "Recipients",
movements: "Movements",
assignments: "Assignments",
users: "Users",
},
navbar: {
accountLabel: "My Account",
},
addMenu: {
add: "Add",
import: "Import",
category: "Category",
item: "Item",
asset: "Asset",
recipient: "Recipient",
assignment: "Assignment",
},
resetDatabase: {
idle: "Reset Database",
loading: "Resetting...",
successToast: "Database reset successfully",
errorToast: "Error resetting database",
},
logout: {
label: "Sign Out",
},
})
expect(getDictionary("es").layout).toEqual({
sidebar: {
home: "Inicio",
inventory: "Inventario",
items: "Artículos",
categories: "Categorías",
assets: "Activos",
recipients: "Destinatarios",
movements: "Movimientos",
assignments: "Asignaciones",
users: "Usuarios",
},
navbar: {
accountLabel: "Mi cuenta",
},
addMenu: {
add: "Añadir",
import: "Importar",
category: "Categoría",
item: "Artículo",
asset: "Activo",
recipient: "Destinatario",
assignment: "Asignación",
},
resetDatabase: {
idle: "Reiniciar base de datos",
loading: "Reiniciando...",
successToast: "Base de datos reiniciada correctamente",
errorToast: "Error al reiniciar la base de datos",
},
logout: {
label: "Cerrar sesión",
},
})
expect(getDictionary("en").common.search).toEqual({
placeholder: "Search...",
label: "Search",
clearLabel: "Clear search",
})
expect(getDictionary("es").common.search).toEqual({
placeholder: "Buscar...",
label: "Buscar",
clearLabel: "Limpiar búsqueda",
})
expect(getDictionary("en").common.pagination).toEqual({
summaryPrefix: "Showing page",
summarySeparator: "of",
previous: "Previous",
next: "Next",
})
expect(getDictionary("es").common.pagination).toEqual({
summaryPrefix: "Mostrando página",
summarySeparator: "de",
previous: "Anterior",
next: "Siguiente",
})
expect(getDictionary("en").common.forbidden).toEqual({
title: "Access denied",
description: "You do not have permission to access this section.",
homeLink: "Back to home",
})
expect(getDictionary("es").common.forbidden).toEqual({
title: "Acceso denegado",
description: "No tienes permisos para acceder a esta sección.",
homeLink: "Volver al inicio",
})
})
it("keeps dashboard home dictionary keys aligned across locales", () => { it("keeps dashboard home dictionary keys aligned across locales", () => {
expect(getDictionary("en").dashboardHome).toEqual({ expect(getDictionary("en").dashboardHome).toEqual({
heading: "Dashboard", heading: "Dashboard",