From e9a07eb28e543dbb442761e63ff801993b0ac779 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 12 Jun 2026 23:01:33 +0200 Subject: [PATCH] feat(i18n): localize inventory categories UI --- .../categories/[categoryId]/edit/page.tsx | 4 +- .../categories/_components/category.copy.ts | 4 + .../_components/delete.category.button.tsx | 7 +- .../_components/edit.category.form.tsx | 9 +- .../_components/new.category.form.tsx | 9 +- .../inventory/categories/new/page.tsx | 8 +- .../(dashboard)/inventory/categories/page.tsx | 20 +++- src/components/common/pageheader.tsx | 6 +- src/components/common/pageheader.utils.ts | 11 ++ src/i18n/dictionaries/en.ts | 51 +++++++++ src/i18n/dictionaries/es.ts | 52 +++++++++ tests/e2e/language-switcher.spec.ts | 30 +++++ .../unit/components/common/pageheader.test.ts | 18 +++ tests/unit/i18n/dictionaries.test.ts | 103 ++++++++++++++++++ 14 files changed, 315 insertions(+), 17 deletions(-) create mode 100644 src/app/(dashboard)/inventory/categories/_components/category.copy.ts create mode 100644 src/components/common/pageheader.utils.ts create mode 100644 tests/unit/components/common/pageheader.test.ts diff --git a/src/app/(dashboard)/inventory/categories/[categoryId]/edit/page.tsx b/src/app/(dashboard)/inventory/categories/[categoryId]/edit/page.tsx index ea2812e..4afde29 100644 --- a/src/app/(dashboard)/inventory/categories/[categoryId]/edit/page.tsx +++ b/src/app/(dashboard)/inventory/categories/[categoryId]/edit/page.tsx @@ -13,6 +13,7 @@ export default async function EditCategoryPage({ const { categoryId } = await params const category = await CategoryService.findById(categoryId) const { dictionary } = await getI18n() + const copy = dictionary.inventory.categories if (!category) { notFound() @@ -21,10 +22,11 @@ export default async function EditCategoryPage({ return (
-

Edit Category

+

{copy.edit.title}

diff --git a/src/app/(dashboard)/inventory/categories/_components/category.copy.ts b/src/app/(dashboard)/inventory/categories/_components/category.copy.ts new file mode 100644 index 0000000..72144ab --- /dev/null +++ b/src/app/(dashboard)/inventory/categories/_components/category.copy.ts @@ -0,0 +1,4 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +export type CategoryFormCopy = Dictionary["inventory"]["categories"]["form"] +export type CategoryDeleteCopy = Dictionary["inventory"]["categories"]["delete"] diff --git a/src/app/(dashboard)/inventory/categories/_components/delete.category.button.tsx b/src/app/(dashboard)/inventory/categories/_components/delete.category.button.tsx index 9c2dfcd..9d62794 100644 --- a/src/app/(dashboard)/inventory/categories/_components/delete.category.button.tsx +++ b/src/app/(dashboard)/inventory/categories/_components/delete.category.button.tsx @@ -7,10 +7,14 @@ import { toast } from "sonner" import { deleteCategoryAction } from "@/actions/category.actions" import { Button } from "@/components/ui/button" +import type { CategoryDeleteCopy } from "./category.copy" + export default function DeleteCategoryButton({ categoryId, + copy, }: { categoryId: string + copy: CategoryDeleteCopy }) { const router = useRouter() const [isPending, startTransition] = useTransition() @@ -28,7 +32,7 @@ export default function DeleteCategoryButton({ toast.success(response.message) router.refresh() } else { - toast.error(response.message ?? "Unknown error") + toast.error(response.message ?? copy.unknownError) } }) } @@ -42,6 +46,7 @@ export default function DeleteCategoryButton({ size="icon" variant="outline" disabled={isPending} + aria-label={isPending ? copy.pending : copy.label} > diff --git a/src/app/(dashboard)/inventory/categories/_components/edit.category.form.tsx b/src/app/(dashboard)/inventory/categories/_components/edit.category.form.tsx index 14970f0..08e3b03 100644 --- a/src/app/(dashboard)/inventory/categories/_components/edit.category.form.tsx +++ b/src/app/(dashboard)/inventory/categories/_components/edit.category.form.tsx @@ -14,12 +14,15 @@ import { updateCategorySchema, } from "@/schemas/category.schema" import type { CategorySummary } from "@/types" +import type { CategoryFormCopy } from "./category.copy" export default function EditCategoryForm({ category, + formCopy, submitButtonCopy, }: { category: CategorySummary + formCopy: CategoryFormCopy submitButtonCopy: SubmitButtonCopy }) { const router = useRouter() @@ -64,12 +67,12 @@ export default function EditCategoryForm({
- Update Category + {formCopy.updateSubmit} ) diff --git a/src/app/(dashboard)/inventory/categories/_components/new.category.form.tsx b/src/app/(dashboard)/inventory/categories/_components/new.category.form.tsx index 372d684..a148892 100644 --- a/src/app/(dashboard)/inventory/categories/_components/new.category.form.tsx +++ b/src/app/(dashboard)/inventory/categories/_components/new.category.form.tsx @@ -13,10 +13,13 @@ import { type CreateCategoryFormType, createCategorySchema, } from "@/schemas/category.schema" +import type { CategoryFormCopy } from "./category.copy" export default function NewCategoryForm({ + formCopy, submitButtonCopy, }: { + formCopy: CategoryFormCopy submitButtonCopy: SubmitButtonCopy }) { const router = useRouter() @@ -56,12 +59,12 @@ export default function NewCategoryForm({
- Create Category + {formCopy.createSubmit} ) diff --git a/src/app/(dashboard)/inventory/categories/new/page.tsx b/src/app/(dashboard)/inventory/categories/new/page.tsx index fe02f44..4e58d65 100644 --- a/src/app/(dashboard)/inventory/categories/new/page.tsx +++ b/src/app/(dashboard)/inventory/categories/new/page.tsx @@ -4,13 +4,17 @@ import NewCategoryForm from "../_components/new.category.form" export default async function NewCategoryPage() { const { dictionary } = await getI18n() + const copy = dictionary.inventory.categories return (
-

New Category

+

{copy.new.title}

- +
) } diff --git a/src/app/(dashboard)/inventory/categories/page.tsx b/src/app/(dashboard)/inventory/categories/page.tsx index 17fb49d..c39ea99 100644 --- a/src/app/(dashboard)/inventory/categories/page.tsx +++ b/src/app/(dashboard)/inventory/categories/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link" import PageHeader from "@/components/common/pageheader" import PaginationButtons from "@/components/common/pagination" import { Button } from "@/components/ui/button" +import { getI18n } from "@/i18n/server" import { CategoryService } from "@/services/category.service" import DeleteCategoryButton from "./_components/delete.category.button" @@ -23,18 +24,21 @@ export default async function Items(props: { pageSize: 10, search, }) + const { dictionary } = await getI18n() + const copy = dictionary.inventory.categories return (
{categories.length === 0 && currentPage === 1 && (
- No Categories found. + {copy.list.empty}
)} @@ -44,13 +48,13 @@ export default async function Items(props: { - Name + {copy.list.columns.name} - Items + {copy.list.columns.items} - Actions + {copy.list.columns.actions} @@ -68,12 +72,16 @@ export default async function Items(props: { className="btn btn-primary" variant="outline" size="icon" + aria-label={copy.list.actions.edit} > {category._count.items === 0 && ( - + )} diff --git a/src/components/common/pageheader.tsx b/src/components/common/pageheader.tsx index c3b3c06..3157792 100644 --- a/src/components/common/pageheader.tsx +++ b/src/components/common/pageheader.tsx @@ -5,9 +5,12 @@ import Search from "@/components/common/search" import { Button } from "@/components/ui/button" import { getI18n } from "@/i18n/server" +import { getPageHeaderAddLabel } from "./pageheader.utils" + interface PageHeaderProps { title?: string link?: string + addLabel?: string search?: string data: unknown[] } @@ -15,6 +18,7 @@ interface PageHeaderProps { export default async function PageHeader({ title, link, + addLabel, search, data, }: PageHeaderProps) { @@ -32,7 +36,7 @@ export default async function PageHeader({
diff --git a/src/components/common/pageheader.utils.ts b/src/components/common/pageheader.utils.ts new file mode 100644 index 0000000..7d11e6b --- /dev/null +++ b/src/components/common/pageheader.utils.ts @@ -0,0 +1,11 @@ +type PageHeaderAddLabelInput = { + addLabel?: string + title?: string +} + +export function getPageHeaderAddLabel({ + addLabel, + title, +}: PageHeaderAddLabelInput) { + return addLabel ?? `Add ${title}` +} diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 171170f..db8e711 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -63,6 +63,57 @@ export const en = { label: "Sign Out", }, }, + inventory: { + categories: { + list: { + title: "Categories", + addLabel: "Add Category", + empty: "No categories found.", + columns: { + name: "Name", + items: "Items", + actions: "Actions", + }, + actions: { + edit: "Edit category", + delete: "Delete category", + }, + }, + new: { + title: "New Category", + }, + edit: { + title: "Edit Category", + }, + form: { + nameLabel: "Name", + namePlaceholder: "Category name", + createSubmit: "Create Category", + updateSubmit: "Update Category", + }, + delete: { + label: "Delete category", + pending: "Deleting...", + unknownError: "Unknown error", + }, + actions: { + createSuccess: "Category created successfully", + createFailure: "Failed to create category", + updateSuccess: "Category updated successfully", + updateFailure: "Failed to update category", + deleteSuccess: "Category deleted successfully", + deleteFailure: "Failed to delete category", + duplicateName: "Category already exists", + unchangedName: "Category name unchanged", + notFound: "Category not found", + hasItems: "Cannot delete category with items", + }, + schema: { + nameRequired: "Name is required and must be at least 3 characters long", + idRequired: "ID is required", + }, + }, + }, login: { title: "Sign In", usernameLabel: "Username", diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts index 113cf36..a73ccd2 100644 --- a/src/i18n/dictionaries/es.ts +++ b/src/i18n/dictionaries/es.ts @@ -65,6 +65,58 @@ export const es = { label: "Cerrar sesión", }, }, + inventory: { + categories: { + list: { + title: "Categorías", + addLabel: "Agregar categoría", + empty: "No se encontraron categorías.", + columns: { + name: "Nombre", + items: "Artículos", + actions: "Acciones", + }, + actions: { + edit: "Editar categoría", + delete: "Eliminar categoría", + }, + }, + new: { + title: "Nueva categoría", + }, + edit: { + title: "Editar categoría", + }, + form: { + nameLabel: "Nombre", + namePlaceholder: "Nombre de la categoría", + createSubmit: "Crear categoría", + updateSubmit: "Actualizar categoría", + }, + delete: { + label: "Eliminar categoría", + pending: "Eliminando...", + unknownError: "Error desconocido", + }, + actions: { + createSuccess: "Categoría creada correctamente", + createFailure: "Error al crear la categoría", + updateSuccess: "Categoría actualizada correctamente", + updateFailure: "Error al actualizar la categoría", + deleteSuccess: "Categoría eliminada correctamente", + deleteFailure: "Error al eliminar la categoría", + duplicateName: "La categoría ya existe", + unchangedName: "El nombre de la categoría no cambió", + notFound: "Categoría no encontrada", + hasItems: "No se puede eliminar una categoría con artículos", + }, + schema: { + nameRequired: + "El nombre es obligatorio y debe tener al menos 3 caracteres", + idRequired: "El ID es obligatorio", + }, + }, + }, login: { title: "Iniciar sesión", usernameLabel: "Usuario", diff --git a/tests/e2e/language-switcher.spec.ts b/tests/e2e/language-switcher.spec.ts index f2f4996..4a52641 100644 --- a/tests/e2e/language-switcher.spec.ts +++ b/tests/e2e/language-switcher.spec.ts @@ -68,6 +68,36 @@ test.describe("language switcher", () => { await expectLocaleCookie(page, "es") }) + test("renders inventory categories copy in the active locale", async ({ + baseURL, + page, + }) => { + await signInAsAdmin(page, baseURL) + await setLocaleCookie(page, "es", baseURL) + + await page.goto("/inventory/categories") + + await expect(page.locator("html")).toHaveAttribute("lang", "es") + await expect( + page.getByRole("heading", { name: "Categorías" }), + ).toBeVisible() + await expect( + page.getByRole("link", { name: /Agregar categoría/ }), + ).toBeVisible() + await expect(page.getByText("No se encontraron categorías.")).toBeVisible() + + await page.goto("/inventory/categories/new") + + await expect( + page.getByRole("heading", { name: "Nueva categoría" }), + ).toBeVisible() + await expect(page.getByLabel("Nombre")).toBeVisible() + await expect(page.getByPlaceholder("Nombre de la categoría")).toBeVisible() + await expect( + page.getByRole("button", { name: "Crear categoría" }), + ).toBeVisible() + }) + test("switches the authenticated dashboard language from the navbar", async ({ baseURL, page, diff --git a/tests/unit/components/common/pageheader.test.ts b/tests/unit/components/common/pageheader.test.ts new file mode 100644 index 0000000..b9ec501 --- /dev/null +++ b/tests/unit/components/common/pageheader.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest" + +import { getPageHeaderAddLabel } from "@/components/common/pageheader.utils" + +describe("PageHeader", () => { + it("uses the explicit add label when provided", () => { + expect( + getPageHeaderAddLabel({ + addLabel: "Agregar categoría", + title: "Categorías", + }), + ).toBe("Agregar categoría") + }) + + it("keeps the legacy fallback when no explicit add label is provided", () => { + expect(getPageHeaderAddLabel({ title: "Items" })).toBe("Add Items") + }) +}) diff --git a/tests/unit/i18n/dictionaries.test.ts b/tests/unit/i18n/dictionaries.test.ts index 06027fb..24289b3 100644 --- a/tests/unit/i18n/dictionaries.test.ts +++ b/tests/unit/i18n/dictionaries.test.ts @@ -163,6 +163,109 @@ describe("i18n dictionaries", () => { }) }) + it("provides localized inventory category copy for English and Spanish", () => { + expect(getDictionary("en").inventory.categories).toEqual({ + list: { + title: "Categories", + addLabel: "Add Category", + empty: "No categories found.", + columns: { + name: "Name", + items: "Items", + actions: "Actions", + }, + actions: { + edit: "Edit category", + delete: "Delete category", + }, + }, + new: { + title: "New Category", + }, + edit: { + title: "Edit Category", + }, + form: { + nameLabel: "Name", + namePlaceholder: "Category name", + createSubmit: "Create Category", + updateSubmit: "Update Category", + }, + delete: { + label: "Delete category", + pending: "Deleting...", + unknownError: "Unknown error", + }, + actions: { + createSuccess: "Category created successfully", + createFailure: "Failed to create category", + updateSuccess: "Category updated successfully", + updateFailure: "Failed to update category", + deleteSuccess: "Category deleted successfully", + deleteFailure: "Failed to delete category", + duplicateName: "Category already exists", + unchangedName: "Category name unchanged", + notFound: "Category not found", + hasItems: "Cannot delete category with items", + }, + schema: { + nameRequired: "Name is required and must be at least 3 characters long", + idRequired: "ID is required", + }, + }) + + expect(getDictionary("es").inventory.categories).toEqual({ + list: { + title: "Categorías", + addLabel: "Agregar categoría", + empty: "No se encontraron categorías.", + columns: { + name: "Nombre", + items: "Artículos", + actions: "Acciones", + }, + actions: { + edit: "Editar categoría", + delete: "Eliminar categoría", + }, + }, + new: { + title: "Nueva categoría", + }, + edit: { + title: "Editar categoría", + }, + form: { + nameLabel: "Nombre", + namePlaceholder: "Nombre de la categoría", + createSubmit: "Crear categoría", + updateSubmit: "Actualizar categoría", + }, + delete: { + label: "Eliminar categoría", + pending: "Eliminando...", + unknownError: "Error desconocido", + }, + actions: { + createSuccess: "Categoría creada correctamente", + createFailure: "Error al crear la categoría", + updateSuccess: "Categoría actualizada correctamente", + updateFailure: "Error al actualizar la categoría", + deleteSuccess: "Categoría eliminada correctamente", + deleteFailure: "Error al eliminar la categoría", + duplicateName: "La categoría ya existe", + unchangedName: "El nombre de la categoría no cambió", + notFound: "Categoría no encontrada", + hasItems: "No se puede eliminar una categoría con artículos", + }, + schema: { + nameRequired: + "El nombre es obligatorio y debe tener al menos 3 caracteres", + idRequired: "El ID es obligatorio", + }, + }) + }) + it("keeps dashboard home dictionary keys aligned across locales", () => { expect(getDictionary("en").dashboardHome).toEqual({ heading: "Dashboard",