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 { 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: {},
}
}
+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
category={category}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
@@ -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 }
@@ -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<UpdateCategoryFormType>({
resolver: zodResolver(updateCategorySchema),
resolver: zodResolver(schema),
defaultValues: {
id: category.id,
name: category.name,
@@ -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<CreateCategoryFormType>({
resolver: zodResolver(createCategorySchema),
resolver: zodResolver(schema),
})
const onSubmit = async (formData: CreateCategoryFormType) => {
@@ -13,6 +13,7 @@ export default async function NewCategoryPage() {
</div>
<NewCategoryForm
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
+28 -7
View File
@@ -1,18 +1,39 @@
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
.string()
.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 const updateCategorySchema = createCategorySchema.extend({
id: z.string().nonempty("ID is required"),
})
export const updateCategorySchema = buildUpdateCategorySchema(
defaultCategorySchemaCopy,
)
export type UpdateCategoryFormType = z.infer<typeof updateCategorySchema>
+4 -1
View File
@@ -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()
})
@@ -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,
)
}
})
})