diff --git a/src/actions/item.actions.ts b/src/actions/item.actions.ts index d0f10e0..66a0669 100644 --- a/src/actions/item.actions.ts +++ b/src/actions/item.actions.ts @@ -1,11 +1,13 @@ "use server" import { revalidatePath } from "next/cache" + +import { getI18n } from "@/i18n/server" import { + buildCreateItemSchema, + buildUpdateItemSchema, type CreateItemFormType, - createItemSchema, type UpdateItemFormType, - updateItemSchema, } from "@/schemas/item.schema" import { getAuthenticatedUserId } from "@/services/auth.service" import { @@ -14,8 +16,12 @@ import { updateItemUseCase, } from "@/use-cases/item.use-cases" +import { localizeItemFieldErrors } from "./item.messages" + 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) { return { @@ -32,7 +38,11 @@ export async function createItemAction(formData: CreateItemFormType) { }) if (!result.success) { - return result + return { + ...result, + errors: localizeItemFieldErrors(result.errors, copy.actions), + message: copy.actions.createFailure, + } } revalidatePath("/inventory/items") @@ -40,18 +50,20 @@ export async function createItemAction(formData: CreateItemFormType) { return { success: true, - message: "Item created successfully!", + message: copy.actions.createSuccess, } } catch (error) { console.error("Database error:", error) return { - error: "Error creating item", + error: copy.actions.createFailure, } } } 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) { return { @@ -68,7 +80,11 @@ export async function updateItemAction(formData: UpdateItemFormType) { }) if (!result.success) { - return result + return { + ...result, + errors: localizeItemFieldErrors(result.errors, copy.actions), + message: copy.actions.updateFailure, + } } revalidatePath("/inventory/items") @@ -76,17 +92,19 @@ export async function updateItemAction(formData: UpdateItemFormType) { return { success: true, - message: "Item updated successfully!", + message: copy.actions.updateSuccess, } } catch (error) { console.error("Database error:", error) return { - error: "Failed to update item", + error: copy.actions.updateFailure, } } } export async function deleteItemAction(formData: FormData) { + const { dictionary } = await getI18n() + const copy = dictionary.inventory.items const { id } = Object.fromEntries(formData) as { id: string } try { @@ -95,7 +113,8 @@ export async function deleteItemAction(formData: FormData) { if (!result.success) { return { ...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 { success: true as const, - message: "Item deleted successfully!", + message: copy.actions.deleteSuccess, } } catch (error) { console.error("Database error:", error) return { success: false as const, - message: "Failed to delete item", + message: copy.actions.deleteFailure, errors: {}, } } diff --git a/src/actions/item.messages.ts b/src/actions/item.messages.ts new file mode 100644 index 0000000..c4617ad --- /dev/null +++ b/src/actions/item.messages.ts @@ -0,0 +1,40 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +type ItemActionCopy = Dictionary["inventory"]["items"]["actions"] + +type FieldErrors = Record + +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 + +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)), + ]), + ) +} diff --git a/src/app/(dashboard)/inventory/items/[itemId]/edit/page.tsx b/src/app/(dashboard)/inventory/items/[itemId]/edit/page.tsx index a893e50..37e8dab 100644 --- a/src/app/(dashboard)/inventory/items/[itemId]/edit/page.tsx +++ b/src/app/(dashboard)/inventory/items/[itemId]/edit/page.tsx @@ -33,6 +33,7 @@ export default async function AddItem({ categories={categories} item={item} formCopy={copy.form} + schemaCopy={copy.schema} submitButtonCopy={dictionary.common.submitButton} /> diff --git a/src/app/(dashboard)/inventory/items/_components/item.copy.ts b/src/app/(dashboard)/inventory/items/_components/item.copy.ts index 0272dd3..1ab160b 100644 --- a/src/app/(dashboard)/inventory/items/_components/item.copy.ts +++ b/src/app/(dashboard)/inventory/items/_components/item.copy.ts @@ -1,6 +1,8 @@ import type { Dictionary } from "@/i18n/dictionaries" +import type { ItemSchemaCopy } from "@/schemas/item.schema" export type ItemListCopy = Dictionary["inventory"]["items"]["list"] export type ItemDetailCopy = Dictionary["inventory"]["items"]["detail"] export type ItemFormCopy = Dictionary["inventory"]["items"]["form"] export type ItemDeleteCopy = Dictionary["inventory"]["items"]["delete"] +export type { ItemSchemaCopy } diff --git a/src/app/(dashboard)/inventory/items/_components/new.item.form.tsx b/src/app/(dashboard)/inventory/items/_components/new.item.form.tsx index 8a8b55d..22c8a73 100644 --- a/src/app/(dashboard)/inventory/items/_components/new.item.form.tsx +++ b/src/app/(dashboard)/inventory/items/_components/new.item.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 { createItemAction } from "@/actions/item.actions" @@ -10,23 +11,26 @@ import { type SubmitButtonCopy, } from "@/components/forms/submitButton" import { + buildCreateItemSchema, type CreateItemFormType, - createItemSchema, } from "@/schemas/item.schema" import type { CategorySummary } from "@/types" -import type { ItemFormCopy } from "./item.copy" +import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy" export default function NewItemForm({ categories, formCopy, + schemaCopy, submitButtonCopy, }: { categories: CategorySummary[] formCopy: ItemFormCopy + schemaCopy: ItemSchemaCopy submitButtonCopy: SubmitButtonCopy }) { const router = useRouter() + const schema = useMemo(() => buildCreateItemSchema(schemaCopy), [schemaCopy]) const { register, @@ -34,7 +38,7 @@ export default function NewItemForm({ setError, formState: { errors, isSubmitting, isSubmitSuccessful }, } = useForm({ - resolver: zodResolver(createItemSchema), + resolver: zodResolver(schema), shouldFocusError: true, mode: "onSubmit", }) diff --git a/src/app/(dashboard)/inventory/items/_components/update.item.form.tsx b/src/app/(dashboard)/inventory/items/_components/update.item.form.tsx index 7933269..eac609b 100644 --- a/src/app/(dashboard)/inventory/items/_components/update.item.form.tsx +++ b/src/app/(dashboard)/inventory/items/_components/update.item.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 { updateItemAction } from "@/actions/item.actions" @@ -10,25 +11,28 @@ import { type SubmitButtonCopy, } from "@/components/forms/submitButton" import { + buildUpdateItemSchema, type UpdateItemFormType, - updateItemSchema, } from "@/schemas/item.schema" import type { CategorySummary, ItemWithAssetCount } from "@/types" -import type { ItemFormCopy } from "./item.copy" +import type { ItemFormCopy, ItemSchemaCopy } from "./item.copy" export default function UpdateItemForm({ categories, item, formCopy, + schemaCopy, submitButtonCopy, }: { categories: CategorySummary[] item: ItemWithAssetCount formCopy: ItemFormCopy + schemaCopy: ItemSchemaCopy submitButtonCopy: SubmitButtonCopy }) { const router = useRouter() + const schema = useMemo(() => buildUpdateItemSchema(schemaCopy), [schemaCopy]) const isDisabled = !!item?._count.assets && item?._count.assets > 0 @@ -38,7 +42,7 @@ export default function UpdateItemForm({ setError, formState: { errors, isSubmitting, isSubmitSuccessful }, } = useForm({ - resolver: zodResolver(updateItemSchema), + resolver: zodResolver(schema), defaultValues: { id: item?.id, name: item?.name, diff --git a/src/app/(dashboard)/inventory/items/new/page.tsx b/src/app/(dashboard)/inventory/items/new/page.tsx index 7b81bdb..f92c5ac 100644 --- a/src/app/(dashboard)/inventory/items/new/page.tsx +++ b/src/app/(dashboard)/inventory/items/new/page.tsx @@ -16,6 +16,7 @@ export default async function NewItemPage() { diff --git a/src/schemas/item.schema.ts b/src/schemas/item.schema.ts index 71a12a1..015442f 100644 --- a/src/schemas/item.schema.ts +++ b/src/schemas/item.schema.ts @@ -1,33 +1,60 @@ import { z } from "zod" -export const createItemSchema = z.object({ - name: z.string().min(1, { - error: "Name is required", - }), - categoryId: z.string().min(1, { - error: "Category is required", - }), - stock: z.coerce.number().int().nonnegative().min(0, { - error: "Stock is required", - }), -}) +import type { Dictionary } from "@/i18n/dictionaries" + +export type ItemSchemaCopy = Dictionary["inventory"]["items"]["schema"] + +const defaultItemSchemaCopy: ItemSchemaCopy = { + nameRequired: "Name is required", + categoryRequired: "Category is required", + stockRequired: "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 export type CreateItemData = z.output -export const updateItemSchema = createItemSchema.extend({ - id: z.string().min(1, { - error: "Item is required", - }), -}) +export function buildUpdateItemSchema(copy: ItemSchemaCopy) { + return buildCreateItemSchema(copy).extend({ + id: z.string().min(1, { + error: copy.itemRequired, + }), + }) +} + +export const updateItemSchema = buildUpdateItemSchema(defaultItemSchemaCopy) export type UpdateItemFormType = z.input export type UpdateItemData = z.output -export const getItemByIdSchema = z.object({ - id: z.string().min(1, { - error: "Item is required", - }), -}) +export function buildGetItemByIdSchema(copy: ItemSchemaCopy) { + return z.object({ + id: z.string().min(1, { + error: copy.itemRequired, + }), + }) +} + +export const getItemByIdSchema = buildGetItemByIdSchema(defaultItemSchemaCopy) export type GetItemByIdFormType = z.infer diff --git a/tests/unit/actions/item.messages.test.ts b/tests/unit/actions/item.messages.test.ts new file mode 100644 index 0000000..1777326 --- /dev/null +++ b/tests/unit/actions/item.messages.test.ts @@ -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"] }) + }) +}) diff --git a/tests/unit/schemas/item.schema.test.ts b/tests/unit/schemas/item.schema.test.ts new file mode 100644 index 0000000..62c2a02 --- /dev/null +++ b/tests/unit/schemas/item.schema.test.ts @@ -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, + ) + } + }) +})