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