feat(i18n): localize category action messages

This commit is contained in:
2026-06-12 23:21:08 +02:00
parent e9a07eb28e
commit 9f7d1b8ef8
11 changed files with 222 additions and 32 deletions
+36 -14
View File
@@ -2,11 +2,12 @@
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { getI18n } from "@/i18n/server"
import { import {
buildCreateCategorySchema,
buildUpdateCategorySchema,
type CreateCategoryFormType, type CreateCategoryFormType,
createCategorySchema,
type UpdateCategoryFormType, type UpdateCategoryFormType,
updateCategorySchema,
} from "@/schemas/category.schema" } from "@/schemas/category.schema"
import { import {
createCategoryUseCase, createCategoryUseCase,
@@ -14,8 +15,14 @@ import {
updateCategoryUseCase, updateCategoryUseCase,
} from "@/use-cases/category.use-cases" } from "@/use-cases/category.use-cases"
import { localizeCategoryFieldErrors } from "./category.messages"
export async function createCategoryAction(formData: CreateCategoryFormType) { 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) { if (!validatedFields.success) {
return { return {
@@ -28,29 +35,37 @@ export async function createCategoryAction(formData: CreateCategoryFormType) {
const result = await createCategoryUseCase(validatedFields.data) const result = await createCategoryUseCase(validatedFields.data)
if (!result.success) { if (!result.success) {
return result return {
...result,
errors: localizeCategoryFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
} }
revalidatePath("/inventory/categories") revalidatePath("/inventory/categories")
return { return {
success: true, success: true,
message: "Category created successfully", message: copy.actions.createSuccess,
} }
} catch (error) { } catch (error) {
console.error("Database error:", error) console.error("Database error:", error)
return { return {
success: false, success: false,
message: "Failed to create category", message: copy.actions.createFailure,
errors: { errors: {
name: ["Category already exists"], name: [copy.actions.duplicateName],
}, },
} }
} }
} }
export async function updateCategoryAction(formData: UpdateCategoryFormType) { 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) { if (!validatedFields.success) {
return { return {
@@ -63,25 +78,31 @@ export async function updateCategoryAction(formData: UpdateCategoryFormType) {
const result = await updateCategoryUseCase(validatedFields.data) const result = await updateCategoryUseCase(validatedFields.data)
if (!result.success) { if (!result.success) {
return result return {
...result,
errors: localizeCategoryFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
} }
revalidatePath("/inventory/categories") revalidatePath("/inventory/categories")
return { return {
success: true, success: true,
message: "Category updated successfully", message: copy.actions.updateSuccess,
} }
} catch (error) { } catch (error) {
console.error("Database error:", error) console.error("Database error:", error)
return { return {
success: false, success: false,
message: "Failed to update category", message: copy.actions.updateFailure,
} }
} }
} }
export async function deleteCategoryAction(formData: FormData) { export async function deleteCategoryAction(formData: FormData) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
const { id } = Object.fromEntries(formData) as { id: string } const { id } = Object.fromEntries(formData) as { id: string }
try { try {
@@ -90,7 +111,8 @@ export async function deleteCategoryAction(formData: FormData) {
if (!result.success) { if (!result.success) {
return { return {
...result, ...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 { return {
success: true as const, success: true as const,
message: "Category deleted successfully", message: copy.actions.deleteSuccess,
} }
} catch (error) { } catch (error) {
console.error("Database error:", error) console.error("Database error:", error)
return { return {
success: false as const, success: false as const,
message: "Failed to delete category", message: copy.actions.deleteFailure,
errors: {}, errors: {},
} }
} }
+43
View File
@@ -0,0 +1,43 @@
import type { Dictionary } from "@/i18n/dictionaries"
type CategoryActionCopy = Dictionary["inventory"]["categories"]["actions"]
type FieldErrors = Record<string, string[]>
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<string, keyof CategoryActionCopy>
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)),
]),
)
}
@@ -27,6 +27,7 @@ export default async function EditCategoryPage({
<EditCategoryForm <EditCategoryForm
category={category} category={category}
formCopy={copy.form} formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton} submitButtonCopy={dictionary.common.submitButton}
/> />
</div> </div>
@@ -1,4 +1,6 @@
import type { Dictionary } from "@/i18n/dictionaries" import type { Dictionary } from "@/i18n/dictionaries"
import type { CategorySchemaCopy } from "@/schemas/category.schema"
export type CategoryFormCopy = Dictionary["inventory"]["categories"]["form"] export type CategoryFormCopy = Dictionary["inventory"]["categories"]["form"]
export type CategoryDeleteCopy = Dictionary["inventory"]["categories"]["delete"] export type CategoryDeleteCopy = Dictionary["inventory"]["categories"]["delete"]
export type { CategorySchemaCopy }
@@ -2,6 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { updateCategoryAction } from "@/actions/category.actions" import { updateCategoryAction } from "@/actions/category.actions"
@@ -10,22 +11,28 @@ import {
type SubmitButtonCopy, type SubmitButtonCopy,
} from "@/components/forms/submitButton" } from "@/components/forms/submitButton"
import { import {
buildUpdateCategorySchema,
type UpdateCategoryFormType, type UpdateCategoryFormType,
updateCategorySchema,
} from "@/schemas/category.schema" } from "@/schemas/category.schema"
import type { CategorySummary } from "@/types" import type { CategorySummary } from "@/types"
import type { CategoryFormCopy } from "./category.copy" import type { CategoryFormCopy, CategorySchemaCopy } from "./category.copy"
export default function EditCategoryForm({ export default function EditCategoryForm({
category, category,
formCopy, formCopy,
schemaCopy,
submitButtonCopy, submitButtonCopy,
}: { }: {
category: CategorySummary category: CategorySummary
formCopy: CategoryFormCopy formCopy: CategoryFormCopy
schemaCopy: CategorySchemaCopy
submitButtonCopy: SubmitButtonCopy submitButtonCopy: SubmitButtonCopy
}) { }) {
const router = useRouter() const router = useRouter()
const schema = useMemo(
() => buildUpdateCategorySchema(schemaCopy),
[schemaCopy],
)
const { const {
register, register,
@@ -33,7 +40,7 @@ export default function EditCategoryForm({
setError, setError,
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateCategoryFormType>({ } = useForm<UpdateCategoryFormType>({
resolver: zodResolver(updateCategorySchema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
id: category.id, id: category.id,
name: category.name, name: category.name,
@@ -2,6 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { createCategoryAction } from "@/actions/category.actions" import { createCategoryAction } from "@/actions/category.actions"
@@ -10,19 +11,25 @@ import {
type SubmitButtonCopy, type SubmitButtonCopy,
} from "@/components/forms/submitButton" } from "@/components/forms/submitButton"
import { import {
buildCreateCategorySchema,
type CreateCategoryFormType, type CreateCategoryFormType,
createCategorySchema,
} from "@/schemas/category.schema" } from "@/schemas/category.schema"
import type { CategoryFormCopy } from "./category.copy" import type { CategoryFormCopy, CategorySchemaCopy } from "./category.copy"
export default function NewCategoryForm({ export default function NewCategoryForm({
formCopy, formCopy,
schemaCopy,
submitButtonCopy, submitButtonCopy,
}: { }: {
formCopy: CategoryFormCopy formCopy: CategoryFormCopy
schemaCopy: CategorySchemaCopy
submitButtonCopy: SubmitButtonCopy submitButtonCopy: SubmitButtonCopy
}) { }) {
const router = useRouter() const router = useRouter()
const schema = useMemo(
() => buildCreateCategorySchema(schemaCopy),
[schemaCopy],
)
const { const {
register, register,
@@ -30,7 +37,7 @@ export default function NewCategoryForm({
setError, setError,
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateCategoryFormType>({ } = useForm<CreateCategoryFormType>({
resolver: zodResolver(createCategorySchema), resolver: zodResolver(schema),
}) })
const onSubmit = async (formData: CreateCategoryFormType) => { const onSubmit = async (formData: CreateCategoryFormType) => {
@@ -13,6 +13,7 @@ export default async function NewCategoryPage() {
</div> </div>
<NewCategoryForm <NewCategoryForm
formCopy={copy.form} formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton} submitButtonCopy={dictionary.common.submitButton}
/> />
</div> </div>
+27 -6
View File
@@ -1,18 +1,39 @@
import { z } from "zod" import { z } from "zod"
export const createCategorySchema = z.object({ 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 name: z
.string() .string()
.min(3, { .min(3, {
error: "Name is required and must be at least 3 characters long", error: copy.nameRequired,
}) })
.nonempty("Name is required and must be at least 3 characters long"), .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<typeof createCategorySchema> export type CreateCategoryFormType = z.infer<typeof createCategorySchema>
export const updateCategorySchema = createCategorySchema.extend({ export const updateCategorySchema = buildUpdateCategorySchema(
id: z.string().nonempty("ID is required"), defaultCategorySchemaCopy,
}) )
export type UpdateCategoryFormType = z.infer<typeof updateCategorySchema> export type UpdateCategoryFormType = z.infer<typeof updateCategorySchema>
+4 -1
View File
@@ -93,8 +93,11 @@ test.describe("language switcher", () => {
).toBeVisible() ).toBeVisible()
await expect(page.getByLabel("Nombre")).toBeVisible() await expect(page.getByLabel("Nombre")).toBeVisible()
await expect(page.getByPlaceholder("Nombre de la categoría")).toBeVisible() await expect(page.getByPlaceholder("Nombre de la categoría")).toBeVisible()
await page.getByRole("button", { name: "Crear categoría" }).click()
await expect( await expect(
page.getByRole("button", { name: "Crear categoría" }), page.getByText(
"El nombre es obligatorio y debe tener al menos 3 caracteres",
),
).toBeVisible() ).toBeVisible()
}) })
@@ -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"] })
})
})
@@ -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,
)
}
})
})