From 9f7d1b8ef847aed8533f0374075f7a15027224d7 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 12 Jun 2026 23:21:08 +0200 Subject: [PATCH] feat(i18n): localize category action messages --- src/actions/category.actions.ts | 50 +++++++++++++------ src/actions/category.messages.ts | 43 ++++++++++++++++ .../categories/[categoryId]/edit/page.tsx | 1 + .../categories/_components/category.copy.ts | 2 + .../_components/edit.category.form.tsx | 13 +++-- .../_components/new.category.form.tsx | 13 +++-- .../inventory/categories/new/page.tsx | 1 + src/schemas/category.schema.ts | 43 ++++++++++++---- tests/e2e/language-switcher.spec.ts | 5 +- tests/unit/actions/category.messages.test.ts | 45 +++++++++++++++++ tests/unit/schemas/category.schema.test.ts | 38 ++++++++++++++ 11 files changed, 222 insertions(+), 32 deletions(-) create mode 100644 src/actions/category.messages.ts create mode 100644 tests/unit/actions/category.messages.test.ts create mode 100644 tests/unit/schemas/category.schema.test.ts diff --git a/src/actions/category.actions.ts b/src/actions/category.actions.ts index 7f09b6a..afffa7a 100644 --- a/src/actions/category.actions.ts +++ b/src/actions/category.actions.ts @@ -2,11 +2,12 @@ import { revalidatePath } from "next/cache" +import { getI18n } from "@/i18n/server" import { + buildCreateCategorySchema, + buildUpdateCategorySchema, type CreateCategoryFormType, - createCategorySchema, type UpdateCategoryFormType, - updateCategorySchema, } from "@/schemas/category.schema" import { createCategoryUseCase, @@ -14,8 +15,14 @@ import { updateCategoryUseCase, } from "@/use-cases/category.use-cases" +import { localizeCategoryFieldErrors } from "./category.messages" + export async function createCategoryAction(formData: CreateCategoryFormType) { - const validatedFields = createCategorySchema.safeParse(formData) + const { dictionary } = await getI18n() + const copy = dictionary.inventory.categories + const validatedFields = buildCreateCategorySchema(copy.schema).safeParse( + formData, + ) if (!validatedFields.success) { return { @@ -28,29 +35,37 @@ export async function createCategoryAction(formData: CreateCategoryFormType) { const result = await createCategoryUseCase(validatedFields.data) if (!result.success) { - return result + return { + ...result, + errors: localizeCategoryFieldErrors(result.errors, copy.actions), + message: copy.actions.createFailure, + } } revalidatePath("/inventory/categories") return { success: true, - message: "Category created successfully", + message: copy.actions.createSuccess, } } catch (error) { console.error("Database error:", error) return { success: false, - message: "Failed to create category", + message: copy.actions.createFailure, errors: { - name: ["Category already exists"], + name: [copy.actions.duplicateName], }, } } } export async function updateCategoryAction(formData: UpdateCategoryFormType) { - const validatedFields = updateCategorySchema.safeParse(formData) + const { dictionary } = await getI18n() + const copy = dictionary.inventory.categories + const validatedFields = buildUpdateCategorySchema(copy.schema).safeParse( + formData, + ) if (!validatedFields.success) { return { @@ -63,25 +78,31 @@ export async function updateCategoryAction(formData: UpdateCategoryFormType) { const result = await updateCategoryUseCase(validatedFields.data) if (!result.success) { - return result + return { + ...result, + errors: localizeCategoryFieldErrors(result.errors, copy.actions), + message: copy.actions.updateFailure, + } } revalidatePath("/inventory/categories") return { success: true, - message: "Category updated successfully", + message: copy.actions.updateSuccess, } } catch (error) { console.error("Database error:", error) return { success: false, - message: "Failed to update category", + message: copy.actions.updateFailure, } } } export async function deleteCategoryAction(formData: FormData) { + const { dictionary } = await getI18n() + const copy = dictionary.inventory.categories const { id } = Object.fromEntries(formData) as { id: string } try { @@ -90,7 +111,8 @@ export async function deleteCategoryAction(formData: FormData) { if (!result.success) { return { ...result, - message: "Failed to delete category", + errors: localizeCategoryFieldErrors(result.errors, copy.actions), + message: copy.actions.deleteFailure, } } @@ -98,13 +120,13 @@ export async function deleteCategoryAction(formData: FormData) { return { success: true as const, - message: "Category deleted successfully", + message: copy.actions.deleteSuccess, } } catch (error) { console.error("Database error:", error) return { success: false as const, - message: "Failed to delete category", + message: copy.actions.deleteFailure, errors: {}, } } diff --git a/src/actions/category.messages.ts b/src/actions/category.messages.ts new file mode 100644 index 0000000..1484bd0 --- /dev/null +++ b/src/actions/category.messages.ts @@ -0,0 +1,43 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +type CategoryActionCopy = Dictionary["inventory"]["categories"]["actions"] + +type FieldErrors = Record + +const categoryErrorMessageKeys = { + "Category already exists": "duplicateName", + "Category name is the same as the old one": "unchangedName", + "Category name unchanged": "unchangedName", + "Category not found": "notFound", + "Category has items": "hasItems", + "Cannot delete category with items": "hasItems", +} as const satisfies Record + +function isCategoryErrorMessage( + message: string, +): message is keyof typeof categoryErrorMessageKeys { + return message in categoryErrorMessageKeys +} + +function localizeCategoryMessage( + message: string, + copy: CategoryActionCopy, +): string { + if (!isCategoryErrorMessage(message)) return message + + return copy[categoryErrorMessageKeys[message]] +} + +export function localizeCategoryFieldErrors( + errors: FieldErrors | undefined, + copy: CategoryActionCopy, +): FieldErrors | undefined { + if (!errors) return undefined + + return Object.fromEntries( + Object.entries(errors).map(([field, messages]) => [ + field, + messages.map((message) => localizeCategoryMessage(message, copy)), + ]), + ) +} diff --git a/src/app/(dashboard)/inventory/categories/[categoryId]/edit/page.tsx b/src/app/(dashboard)/inventory/categories/[categoryId]/edit/page.tsx index 4afde29..4d7ac82 100644 --- a/src/app/(dashboard)/inventory/categories/[categoryId]/edit/page.tsx +++ b/src/app/(dashboard)/inventory/categories/[categoryId]/edit/page.tsx @@ -27,6 +27,7 @@ export default async function EditCategoryPage({ diff --git a/src/app/(dashboard)/inventory/categories/_components/category.copy.ts b/src/app/(dashboard)/inventory/categories/_components/category.copy.ts index 72144ab..b67c8f2 100644 --- a/src/app/(dashboard)/inventory/categories/_components/category.copy.ts +++ b/src/app/(dashboard)/inventory/categories/_components/category.copy.ts @@ -1,4 +1,6 @@ import type { Dictionary } from "@/i18n/dictionaries" +import type { CategorySchemaCopy } from "@/schemas/category.schema" export type CategoryFormCopy = Dictionary["inventory"]["categories"]["form"] export type CategoryDeleteCopy = Dictionary["inventory"]["categories"]["delete"] +export type { CategorySchemaCopy } 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 08e3b03..3965e9f 100644 --- a/src/app/(dashboard)/inventory/categories/_components/edit.category.form.tsx +++ b/src/app/(dashboard)/inventory/categories/_components/edit.category.form.tsx @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useRouter } from "next/navigation" +import { useMemo } from "react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { updateCategoryAction } from "@/actions/category.actions" @@ -10,22 +11,28 @@ import { type SubmitButtonCopy, } from "@/components/forms/submitButton" import { + buildUpdateCategorySchema, type UpdateCategoryFormType, - updateCategorySchema, } from "@/schemas/category.schema" import type { CategorySummary } from "@/types" -import type { CategoryFormCopy } from "./category.copy" +import type { CategoryFormCopy, CategorySchemaCopy } from "./category.copy" export default function EditCategoryForm({ category, formCopy, + schemaCopy, submitButtonCopy, }: { category: CategorySummary formCopy: CategoryFormCopy + schemaCopy: CategorySchemaCopy submitButtonCopy: SubmitButtonCopy }) { const router = useRouter() + const schema = useMemo( + () => buildUpdateCategorySchema(schemaCopy), + [schemaCopy], + ) const { register, @@ -33,7 +40,7 @@ export default function EditCategoryForm({ setError, formState: { errors, isSubmitting, isSubmitSuccessful }, } = useForm({ - resolver: zodResolver(updateCategorySchema), + resolver: zodResolver(schema), defaultValues: { id: category.id, name: category.name, 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 a148892..c9ef9d4 100644 --- a/src/app/(dashboard)/inventory/categories/_components/new.category.form.tsx +++ b/src/app/(dashboard)/inventory/categories/_components/new.category.form.tsx @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useRouter } from "next/navigation" +import { useMemo } from "react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { createCategoryAction } from "@/actions/category.actions" @@ -10,19 +11,25 @@ import { type SubmitButtonCopy, } from "@/components/forms/submitButton" import { + buildCreateCategorySchema, type CreateCategoryFormType, - createCategorySchema, } from "@/schemas/category.schema" -import type { CategoryFormCopy } from "./category.copy" +import type { CategoryFormCopy, CategorySchemaCopy } from "./category.copy" export default function NewCategoryForm({ formCopy, + schemaCopy, submitButtonCopy, }: { formCopy: CategoryFormCopy + schemaCopy: CategorySchemaCopy submitButtonCopy: SubmitButtonCopy }) { const router = useRouter() + const schema = useMemo( + () => buildCreateCategorySchema(schemaCopy), + [schemaCopy], + ) const { register, @@ -30,7 +37,7 @@ export default function NewCategoryForm({ setError, formState: { errors, isSubmitting, isSubmitSuccessful }, } = useForm({ - resolver: zodResolver(createCategorySchema), + resolver: zodResolver(schema), }) const onSubmit = async (formData: CreateCategoryFormType) => { diff --git a/src/app/(dashboard)/inventory/categories/new/page.tsx b/src/app/(dashboard)/inventory/categories/new/page.tsx index 4e58d65..9195bcc 100644 --- a/src/app/(dashboard)/inventory/categories/new/page.tsx +++ b/src/app/(dashboard)/inventory/categories/new/page.tsx @@ -13,6 +13,7 @@ export default async function NewCategoryPage() { diff --git a/src/schemas/category.schema.ts b/src/schemas/category.schema.ts index 6914016..fcf98f8 100644 --- a/src/schemas/category.schema.ts +++ b/src/schemas/category.schema.ts @@ -1,18 +1,39 @@ import { z } from "zod" -export const createCategorySchema = z.object({ - name: z - .string() - .min(3, { - error: "Name is required and must be at least 3 characters long", - }) - .nonempty("Name is required and must be at least 3 characters long"), -}) +import type { Dictionary } from "@/i18n/dictionaries" + +export type CategorySchemaCopy = Dictionary["inventory"]["categories"]["schema"] + +const defaultCategorySchemaCopy: CategorySchemaCopy = { + nameRequired: "Name is required and must be at least 3 characters long", + idRequired: "ID is required", +} + +export function buildCreateCategorySchema(copy: CategorySchemaCopy) { + return z.object({ + name: z + .string() + .min(3, { + error: copy.nameRequired, + }) + .nonempty(copy.nameRequired), + }) +} + +export function buildUpdateCategorySchema(copy: CategorySchemaCopy) { + return buildCreateCategorySchema(copy).extend({ + id: z.string().nonempty(copy.idRequired), + }) +} + +export const createCategorySchema = buildCreateCategorySchema( + defaultCategorySchemaCopy, +) export type CreateCategoryFormType = z.infer -export const updateCategorySchema = createCategorySchema.extend({ - id: z.string().nonempty("ID is required"), -}) +export const updateCategorySchema = buildUpdateCategorySchema( + defaultCategorySchemaCopy, +) export type UpdateCategoryFormType = z.infer diff --git a/tests/e2e/language-switcher.spec.ts b/tests/e2e/language-switcher.spec.ts index 4a52641..c0be22c 100644 --- a/tests/e2e/language-switcher.spec.ts +++ b/tests/e2e/language-switcher.spec.ts @@ -93,8 +93,11 @@ test.describe("language switcher", () => { ).toBeVisible() await expect(page.getByLabel("Nombre")).toBeVisible() await expect(page.getByPlaceholder("Nombre de la categoría")).toBeVisible() + await page.getByRole("button", { name: "Crear categoría" }).click() await expect( - page.getByRole("button", { name: "Crear categoría" }), + page.getByText( + "El nombre es obligatorio y debe tener al menos 3 caracteres", + ), ).toBeVisible() }) diff --git a/tests/unit/actions/category.messages.test.ts b/tests/unit/actions/category.messages.test.ts new file mode 100644 index 0000000..285c7f9 --- /dev/null +++ b/tests/unit/actions/category.messages.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" + +import { localizeCategoryFieldErrors } from "@/actions/category.messages" + +const actionCopy = { + 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", +} + +describe("category action message localization", () => { + it("localizes known category field errors", () => { + expect( + localizeCategoryFieldErrors( + { + name: [ + "Category already exists", + "Category name is the same as the old one", + ], + id: ["Category not found", "Category has items"], + }, + actionCopy, + ), + ).toEqual({ + name: [actionCopy.duplicateName, actionCopy.unchangedName], + id: [actionCopy.notFound, actionCopy.hasItems], + }) + }) + + it("keeps unknown messages unchanged", () => { + expect( + localizeCategoryFieldErrors( + { name: ["Unexpected category issue"] }, + actionCopy, + ), + ).toEqual({ name: ["Unexpected category issue"] }) + }) +}) diff --git a/tests/unit/schemas/category.schema.test.ts b/tests/unit/schemas/category.schema.test.ts new file mode 100644 index 0000000..9140489 --- /dev/null +++ b/tests/unit/schemas/category.schema.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest" + +import { + buildCreateCategorySchema, + buildUpdateCategorySchema, +} from "@/schemas/category.schema" + +const schemaCopy = { + nameRequired: "El nombre es obligatorio y debe tener al menos 3 caracteres", + idRequired: "El ID es obligatorio", +} + +describe("category schema localization", () => { + it("uses localized create validation messages", () => { + const result = buildCreateCategorySchema(schemaCopy).safeParse({ name: "" }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.flatten().fieldErrors.name).toContain( + schemaCopy.nameRequired, + ) + } + }) + + it("uses localized update identifier validation messages", () => { + const result = buildUpdateCategorySchema(schemaCopy).safeParse({ + id: "", + name: "Hardware", + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.flatten().fieldErrors.id).toContain( + schemaCopy.idRequired, + ) + } + }) +})