feat(i18n): localize category action messages
This commit is contained in:
@@ -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: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user