feat(i18n): localize item validation messages

This commit is contained in:
2026-06-13 11:28:28 +02:00
parent 964b1648ca
commit c67e86c91b
10 changed files with 271 additions and 40 deletions
+32 -13
View File
@@ -1,11 +1,13 @@
"use server" "use server"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { getI18n } from "@/i18n/server"
import { import {
buildCreateItemSchema,
buildUpdateItemSchema,
type CreateItemFormType, type CreateItemFormType,
createItemSchema,
type UpdateItemFormType, type UpdateItemFormType,
updateItemSchema,
} from "@/schemas/item.schema" } from "@/schemas/item.schema"
import { getAuthenticatedUserId } from "@/services/auth.service" import { getAuthenticatedUserId } from "@/services/auth.service"
import { import {
@@ -14,8 +16,12 @@ import {
updateItemUseCase, updateItemUseCase,
} from "@/use-cases/item.use-cases" } from "@/use-cases/item.use-cases"
import { localizeItemFieldErrors } from "./item.messages"
export async function createItemAction(formData: CreateItemFormType) { export async function createItemAction(formData: CreateItemFormType) {
const validatedFields = createItemSchema.safeParse(formData) const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
const validatedFields = buildCreateItemSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) { if (!validatedFields.success) {
return { return {
@@ -32,7 +38,11 @@ export async function createItemAction(formData: CreateItemFormType) {
}) })
if (!result.success) { if (!result.success) {
return result return {
...result,
errors: localizeItemFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
} }
revalidatePath("/inventory/items") revalidatePath("/inventory/items")
@@ -40,18 +50,20 @@ export async function createItemAction(formData: CreateItemFormType) {
return { return {
success: true, success: true,
message: "Item created successfully!", message: copy.actions.createSuccess,
} }
} catch (error) { } catch (error) {
console.error("Database error:", error) console.error("Database error:", error)
return { return {
error: "Error creating item", error: copy.actions.createFailure,
} }
} }
} }
export async function updateItemAction(formData: UpdateItemFormType) { export async function updateItemAction(formData: UpdateItemFormType) {
const validatedFields = updateItemSchema.safeParse(formData) const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
const validatedFields = buildUpdateItemSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) { if (!validatedFields.success) {
return { return {
@@ -68,7 +80,11 @@ export async function updateItemAction(formData: UpdateItemFormType) {
}) })
if (!result.success) { if (!result.success) {
return result return {
...result,
errors: localizeItemFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
} }
revalidatePath("/inventory/items") revalidatePath("/inventory/items")
@@ -76,17 +92,19 @@ export async function updateItemAction(formData: UpdateItemFormType) {
return { return {
success: true, success: true,
message: "Item updated successfully!", message: copy.actions.updateSuccess,
} }
} catch (error) { } catch (error) {
console.error("Database error:", error) console.error("Database error:", error)
return { return {
error: "Failed to update item", error: copy.actions.updateFailure,
} }
} }
} }
export async function deleteItemAction(formData: FormData) { export async function deleteItemAction(formData: FormData) {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
const { id } = Object.fromEntries(formData) as { id: string } const { id } = Object.fromEntries(formData) as { id: string }
try { try {
@@ -95,7 +113,8 @@ export async function deleteItemAction(formData: FormData) {
if (!result.success) { if (!result.success) {
return { return {
...result, ...result,
message: "Failed to delete item", errors: localizeItemFieldErrors(result.errors, copy.actions),
message: copy.actions.deleteFailure,
} }
} }
@@ -103,13 +122,13 @@ export async function deleteItemAction(formData: FormData) {
return { return {
success: true as const, success: true as const,
message: "Item 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 item", message: copy.actions.deleteFailure,
errors: {}, errors: {},
} }
} }
+40
View File
@@ -0,0 +1,40 @@
import type { Dictionary } from "@/i18n/dictionaries"
type ItemActionCopy = Dictionary["inventory"]["items"]["actions"]
type FieldErrors = Record<string, string[]>
const itemErrorMessageKeys = {
"An item with this name already exists": "duplicateName",
"Item not found": "notFound",
"Item has assets, you cannot delete it": "hasAssets",
"Item has stock, you cannot delete it": "hasStock",
"Invalid stock": "invalidStock",
"Stock cannot be negative": "negativeStock",
} as const satisfies Record<string, keyof ItemActionCopy>
function isItemErrorMessage(
message: string,
): message is keyof typeof itemErrorMessageKeys {
return message in itemErrorMessageKeys
}
function localizeItemMessage(message: string, copy: ItemActionCopy): string {
if (!isItemErrorMessage(message)) return message
return copy[itemErrorMessageKeys[message]]
}
export function localizeItemFieldErrors(
errors: FieldErrors | undefined,
copy: ItemActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizeItemMessage(message, copy)),
]),
)
}
@@ -33,6 +33,7 @@ export default async function AddItem({
categories={categories} categories={categories}
item={item} item={item}
formCopy={copy.form} formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton} submitButtonCopy={dictionary.common.submitButton}
/> />
</div> </div>
@@ -1,6 +1,8 @@
import type { Dictionary } from "@/i18n/dictionaries" import type { Dictionary } from "@/i18n/dictionaries"
import type { ItemSchemaCopy } from "@/schemas/item.schema"
export type ItemListCopy = Dictionary["inventory"]["items"]["list"] export type ItemListCopy = Dictionary["inventory"]["items"]["list"]
export type ItemDetailCopy = Dictionary["inventory"]["items"]["detail"] export type ItemDetailCopy = Dictionary["inventory"]["items"]["detail"]
export type ItemFormCopy = Dictionary["inventory"]["items"]["form"] export type ItemFormCopy = Dictionary["inventory"]["items"]["form"]
export type ItemDeleteCopy = Dictionary["inventory"]["items"]["delete"] export type ItemDeleteCopy = Dictionary["inventory"]["items"]["delete"]
export type { ItemSchemaCopy }
@@ -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 { createItemAction } from "@/actions/item.actions" import { createItemAction } from "@/actions/item.actions"
@@ -10,23 +11,26 @@ import {
type SubmitButtonCopy, type SubmitButtonCopy,
} from "@/components/forms/submitButton" } from "@/components/forms/submitButton"
import { import {
buildCreateItemSchema,
type CreateItemFormType, type CreateItemFormType,
createItemSchema,
} from "@/schemas/item.schema" } from "@/schemas/item.schema"
import type { CategorySummary } from "@/types" import type { CategorySummary } from "@/types"
import type { ItemFormCopy } from "./item.copy" import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy"
export default function NewItemForm({ export default function NewItemForm({
categories, categories,
formCopy, formCopy,
schemaCopy,
submitButtonCopy, submitButtonCopy,
}: { }: {
categories: CategorySummary[] categories: CategorySummary[]
formCopy: ItemFormCopy formCopy: ItemFormCopy
schemaCopy: ItemSchemaCopy
submitButtonCopy: SubmitButtonCopy submitButtonCopy: SubmitButtonCopy
}) { }) {
const router = useRouter() const router = useRouter()
const schema = useMemo(() => buildCreateItemSchema(schemaCopy), [schemaCopy])
const { const {
register, register,
@@ -34,7 +38,7 @@ export default function NewItemForm({
setError, setError,
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateItemFormType>({ } = useForm<CreateItemFormType>({
resolver: zodResolver(createItemSchema), resolver: zodResolver(schema),
shouldFocusError: true, shouldFocusError: true,
mode: "onSubmit", mode: "onSubmit",
}) })
@@ -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 { updateItemAction } from "@/actions/item.actions" import { updateItemAction } from "@/actions/item.actions"
@@ -10,25 +11,28 @@ import {
type SubmitButtonCopy, type SubmitButtonCopy,
} from "@/components/forms/submitButton" } from "@/components/forms/submitButton"
import { import {
buildUpdateItemSchema,
type UpdateItemFormType, type UpdateItemFormType,
updateItemSchema,
} from "@/schemas/item.schema" } from "@/schemas/item.schema"
import type { CategorySummary, ItemWithAssetCount } from "@/types" import type { CategorySummary, ItemWithAssetCount } from "@/types"
import type { ItemFormCopy } from "./item.copy" import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy"
export default function UpdateItemForm({ export default function UpdateItemForm({
categories, categories,
item, item,
formCopy, formCopy,
schemaCopy,
submitButtonCopy, submitButtonCopy,
}: { }: {
categories: CategorySummary[] categories: CategorySummary[]
item: ItemWithAssetCount item: ItemWithAssetCount
formCopy: ItemFormCopy formCopy: ItemFormCopy
schemaCopy: ItemSchemaCopy
submitButtonCopy: SubmitButtonCopy submitButtonCopy: SubmitButtonCopy
}) { }) {
const router = useRouter() const router = useRouter()
const schema = useMemo(() => buildUpdateItemSchema(schemaCopy), [schemaCopy])
const isDisabled = !!item?._count.assets && item?._count.assets > 0 const isDisabled = !!item?._count.assets && item?._count.assets > 0
@@ -38,7 +42,7 @@ export default function UpdateItemForm({
setError, setError,
formState: { errors, isSubmitting, isSubmitSuccessful }, formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateItemFormType>({ } = useForm<UpdateItemFormType>({
resolver: zodResolver(updateItemSchema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
id: item?.id, id: item?.id,
name: item?.name, name: item?.name,
@@ -16,6 +16,7 @@ export default async function NewItemPage() {
<NewItemForm <NewItemForm
categories={categories} categories={categories}
formCopy={copy.form} formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton} submitButtonCopy={dictionary.common.submitButton}
/> />
</div> </div>
+48 -21
View File
@@ -1,33 +1,60 @@
import { z } from "zod" import { z } from "zod"
export const createItemSchema = z.object({ import type { Dictionary } from "@/i18n/dictionaries"
name: z.string().min(1, {
error: "Name is required", export type ItemSchemaCopy = Dictionary["inventory"]["items"]["schema"]
}),
categoryId: z.string().min(1, { const defaultItemSchemaCopy: ItemSchemaCopy = {
error: "Category is required", nameRequired: "Name is required",
}), categoryRequired: "Category is required",
stock: z.coerce.number().int().nonnegative().min(0, { stockRequired: "Stock is required",
error: "Stock is required", itemRequired: "Item is required",
}), }
})
export function buildCreateItemSchema(copy: ItemSchemaCopy) {
return z.object({
name: z.string().min(1, {
error: copy.nameRequired,
}),
categoryId: z.string().min(1, {
error: copy.categoryRequired,
}),
stock: z.coerce
.number({ error: copy.stockRequired })
.int({ error: copy.stockRequired })
.nonnegative({ error: copy.stockRequired })
.min(0, {
error: copy.stockRequired,
}),
})
}
export const createItemSchema = buildCreateItemSchema(defaultItemSchemaCopy)
export type CreateItemFormType = z.input<typeof createItemSchema> export type CreateItemFormType = z.input<typeof createItemSchema>
export type CreateItemData = z.output<typeof createItemSchema> export type CreateItemData = z.output<typeof createItemSchema>
export const updateItemSchema = createItemSchema.extend({ export function buildUpdateItemSchema(copy: ItemSchemaCopy) {
id: z.string().min(1, { return buildCreateItemSchema(copy).extend({
error: "Item is required", id: z.string().min(1, {
}), error: copy.itemRequired,
}) }),
})
}
export const updateItemSchema = buildUpdateItemSchema(defaultItemSchemaCopy)
export type UpdateItemFormType = z.input<typeof updateItemSchema> export type UpdateItemFormType = z.input<typeof updateItemSchema>
export type UpdateItemData = z.output<typeof updateItemSchema> export type UpdateItemData = z.output<typeof updateItemSchema>
export const getItemByIdSchema = z.object({ export function buildGetItemByIdSchema(copy: ItemSchemaCopy) {
id: z.string().min(1, { return z.object({
error: "Item is required", id: z.string().min(1, {
}), error: copy.itemRequired,
}) }),
})
}
export const getItemByIdSchema = buildGetItemByIdSchema(defaultItemSchemaCopy)
export type GetItemByIdFormType = z.infer<typeof getItemByIdSchema> export type GetItemByIdFormType = z.infer<typeof getItemByIdSchema>
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest"
import { localizeItemFieldErrors } from "@/actions/item.messages"
const actionCopy = {
createSuccess: "Artículo creado correctamente",
createFailure: "Error al crear el artículo",
updateSuccess: "Artículo actualizado correctamente",
updateFailure: "Error al actualizar el artículo",
deleteSuccess: "Artículo eliminado correctamente",
deleteFailure: "Error al eliminar el artículo",
duplicateName: "El artículo ya existe",
notFound: "Artículo no encontrado",
hasAssets: "No se puede eliminar un artículo con activos",
hasStock: "No se puede eliminar un artículo con stock",
invalidStock: "Stock inválido",
negativeStock: "El stock no puede ser negativo",
}
describe("item action message localization", () => {
it("localizes known item field errors", () => {
expect(
localizeItemFieldErrors(
{
name: ["An item with this name already exists"],
id: [
"Item not found",
"Item has assets, you cannot delete it",
"Item has stock, you cannot delete it",
],
stock: ["Stock cannot be negative", "Invalid stock"],
},
actionCopy,
),
).toEqual({
name: [actionCopy.duplicateName],
id: [actionCopy.notFound, actionCopy.hasAssets, actionCopy.hasStock],
stock: [actionCopy.negativeStock, actionCopy.invalidStock],
})
})
it("keeps unknown messages unchanged", () => {
expect(
localizeItemFieldErrors({ name: ["Unexpected item issue"] }, actionCopy),
).toEqual({ name: ["Unexpected item issue"] })
})
})
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest"
import {
buildCreateItemSchema,
buildGetItemByIdSchema,
buildUpdateItemSchema,
} from "@/schemas/item.schema"
const schemaCopy = {
nameRequired: "El nombre es obligatorio",
categoryRequired: "La categoría es obligatoria",
stockRequired: "El stock es obligatorio",
itemRequired: "El artículo es obligatorio",
}
describe("item schema localization", () => {
it("uses localized create validation messages", () => {
const result = buildCreateItemSchema(schemaCopy).safeParse({
name: "",
categoryId: "",
stock: -1,
})
expect(result.success).toBe(false)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
expect(errors.name).toContain(schemaCopy.nameRequired)
expect(errors.categoryId).toContain(schemaCopy.categoryRequired)
expect(errors.stock).toContain(schemaCopy.stockRequired)
}
})
it("uses localized update identifier validation messages", () => {
const result = buildUpdateItemSchema(schemaCopy).safeParse({
id: "",
name: "Laptop",
categoryId: "category-1",
stock: 1,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.id).toContain(
schemaCopy.itemRequired,
)
}
})
it("uses localized get-by-id validation messages", () => {
const result = buildGetItemByIdSchema(schemaCopy).safeParse({ id: "" })
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.id).toContain(
schemaCopy.itemRequired,
)
}
})
it("preserves stock coercion and integer validation semantics", () => {
const validResult = buildCreateItemSchema(schemaCopy).safeParse({
name: "Laptop",
categoryId: "category-1",
stock: "2",
})
expect(validResult.success).toBe(true)
if (validResult.success) {
expect(validResult.data.stock).toBe(2)
}
const invalidResult = buildCreateItemSchema(schemaCopy).safeParse({
name: "Laptop",
categoryId: "category-1",
stock: 1.5,
})
expect(invalidResult.success).toBe(false)
if (!invalidResult.success) {
expect(invalidResult.error.flatten().fieldErrors.stock).toContain(
schemaCopy.stockRequired,
)
}
})
})