From 1f5a849bf5a015a0f1aac5b247c4d334ded6240e Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Tue, 16 Jun 2026 21:48:59 +0200 Subject: [PATCH] feat: unify Person and User creation form with conditional password --- src/actions/person.actions.ts | 9 +- src/actions/user.messages.ts | 20 +-- .../admin/users/_components/new.user.form.tsx | 121 ++++++++++++++--- .../admin/users/_components/user.copy.ts | 17 +++ src/app/(dashboard)/admin/users/new/page.tsx | 4 +- src/app/(dashboard)/people/new/page.tsx | 25 +--- src/components/layout/addMenu.tsx | 2 +- src/i18n/dictionaries/en.ts | 8 ++ src/i18n/dictionaries/es.ts | 8 ++ src/use-cases/person.use-cases.ts | 14 +- .../use-cases/person-user.use-cases.test.ts | 2 +- .../use-cases/user.use-cases.test.ts | 4 +- .../unit/app/people/person-form-pages.test.ts | 60 +++------ .../app/people/person-form-wiring.test.ts | 18 --- .../unit/app/users/unified-form-pages.test.ts | 125 ++++++++++++++++++ tests/unit/app/users/user-form-pages.test.ts | 63 +++++---- tests/unit/app/users/user.copy.test.ts | 49 ++++++- .../unit/i18n/admin-users-dictionary.test.ts | 16 +++ .../unit/i18n/unified-form-dictionary.test.ts | 52 ++++++++ tests/unit/schemas/core-schemas.test.ts | 12 +- .../schemas/unified-create.schema.test.ts | 4 +- 21 files changed, 462 insertions(+), 171 deletions(-) create mode 100644 tests/unit/app/users/unified-form-pages.test.ts create mode 100644 tests/unit/i18n/unified-form-dictionary.test.ts diff --git a/src/actions/person.actions.ts b/src/actions/person.actions.ts index f3ca6ff..3fd499e 100644 --- a/src/actions/person.actions.ts +++ b/src/actions/person.actions.ts @@ -64,18 +64,15 @@ export async function createNewPerson(formData: CreatePersonFormType) { } } -export async function createPersonUserAction( - formData: UnifiedCreateFormType, -) { +export async function createPersonUserAction(formData: UnifiedCreateFormType) { const { dictionary } = await getI18n() const userCopy = dictionary.admin.users const schemaCopy = { ...userCopy.schema, ...dictionary.inventory.people.schema, } - const validatedFields = buildUnifiedCreateSchema(schemaCopy).safeParse( - formData, - ) + const validatedFields = + buildUnifiedCreateSchema(schemaCopy).safeParse(formData) if (!validatedFields.success) { return { diff --git a/src/actions/user.messages.ts b/src/actions/user.messages.ts index 5e3c52b..9d39261 100644 --- a/src/actions/user.messages.ts +++ b/src/actions/user.messages.ts @@ -72,27 +72,15 @@ export function localizeUnifiedCreateFieldErrors( field, messages.map((message) => { // Schema-level validation messages come from schemaCopy - if ( - field === "firstName" && - message === schemaCopy.firstNameRequired - ) + if (field === "firstName" && message === schemaCopy.firstNameRequired) return message - if ( - field === "lastName" && - message === schemaCopy.lastNameRequired - ) + if (field === "lastName" && message === schemaCopy.lastNameRequired) return message - if ( - field === "department" && - message === schemaCopy.departmentRequired - ) + if (field === "department" && message === schemaCopy.departmentRequired) return message if (field === "email" && message === schemaCopy.emailInvalid) return message - if ( - field === "password" && - message === schemaCopy.passwordMinLength - ) + if (field === "password" && message === schemaCopy.passwordMinLength) return message // Action-level messages (like "Email already exists") come from action copy diff --git a/src/app/(dashboard)/admin/users/_components/new.user.form.tsx b/src/app/(dashboard)/admin/users/_components/new.user.form.tsx index 4857970..251eef5 100644 --- a/src/app/(dashboard)/admin/users/_components/new.user.form.tsx +++ b/src/app/(dashboard)/admin/users/_components/new.user.form.tsx @@ -6,38 +6,53 @@ import { useMemo } from "react" import type { UseFormRegisterReturn } from "react-hook-form" import { useForm } from "react-hook-form" import { toast } from "sonner" -import { createUserAction } from "@/actions/user.actions" +import { createPersonUserAction } from "@/actions/person.actions" import { SubmitButton, type SubmitButtonCopy, } from "@/components/forms/submitButton" +import { PERSON_DEPARTMENTS } from "@/lib/constants" import { - buildCreateUserSchema, - type CreateUserFormType, - type UserSchemaCopy, + buildUnifiedCreateSchema, + type UnifiedCreateFormType, + type UnifiedSchemaCopy, } from "@/schemas/user.schema" -import type { UserFormCopy, UserRoleCopy } from "./user.copy" +import { + formatPersonDepartment, + type PersonDepartmentCopy, + type PersonFallbackCopy, + type UserFormCopy, + type UserRoleCopy, +} from "./user.copy" export default function NewUserForm({ formCopy, schemaCopy, roleLabels, + departmentCopy, + fallbackCopy, submitButtonCopy, }: { formCopy: UserFormCopy - schemaCopy: UserSchemaCopy + schemaCopy: UnifiedSchemaCopy roleLabels: UserRoleCopy + departmentCopy: PersonDepartmentCopy + fallbackCopy: PersonFallbackCopy submitButtonCopy: SubmitButtonCopy }) { const router = useRouter() - const schema = useMemo(() => buildCreateUserSchema(schemaCopy), [schemaCopy]) + const schema = useMemo( + () => buildUnifiedCreateSchema(schemaCopy), + [schemaCopy], + ) const { register, handleSubmit, + watch, setError, formState: { errors, isSubmitting, isSubmitSuccessful }, - } = useForm({ + } = useForm({ resolver: zodResolver(schema), defaultValues: { role: "STAFF", @@ -45,13 +60,16 @@ export default function NewUserForm({ }, }) - const onSubmit = async (formData: CreateUserFormType) => { - const response = await createUserAction(formData) + const selectedRole = watch("role") + const showPassword = selectedRole !== "NO_USER" + + const onSubmit = async (formData: UnifiedCreateFormType) => { + const response = await createPersonUserAction(formData) if (response?.errors) { Object.entries(response.errors).forEach(([fieldName, messages]) => { messages.forEach((message: string) => { - setError(fieldName as keyof CreateUserFormType, { + setError(fieldName as keyof UnifiedCreateFormType, { type: "server", message, }) @@ -70,11 +88,25 @@ export default function NewUserForm({ return (
+ + + {showPassword && ( + + )} {roleLabels.MANAGER} + ) } + +function DepartmentSelect({ + error, + formCopy, + departmentCopy, + fallbackCopy, + register, +}: { + error?: string + formCopy: UserFormCopy + departmentCopy: PersonDepartmentCopy + fallbackCopy: PersonFallbackCopy + register: UseFormRegisterReturn +}) { + return ( +
+ + + {error &&

{error}

} +
+ ) +} diff --git a/src/app/(dashboard)/admin/users/_components/user.copy.ts b/src/app/(dashboard)/admin/users/_components/user.copy.ts index 3b6fba5..b4a28c6 100644 --- a/src/app/(dashboard)/admin/users/_components/user.copy.ts +++ b/src/app/(dashboard)/admin/users/_components/user.copy.ts @@ -6,6 +6,9 @@ export type UserStatusCopy = Dictionary["admin"]["users"]["status"] export type UserFallbackCopy = Dictionary["admin"]["users"]["fallback"] export type UserResetPasswordCopy = Dictionary["admin"]["users"]["resetPassword"] +export type PersonDepartmentCopy = + Dictionary["inventory"]["people"]["departments"] +export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"] export function formatUserRole( role: string, @@ -16,3 +19,17 @@ export function formatUserRole( ? roleCopy[role as keyof UserRoleCopy] : fallbackCopy.unknownRole } + +export function formatPersonDepartment( + department: string | null | undefined, + departmentCopy: PersonDepartmentCopy, + fallbackCopy: PersonFallbackCopy, +): string { + if (!department) { + return fallbackCopy.unknownDepartment + } + + return department in departmentCopy + ? departmentCopy[department as keyof PersonDepartmentCopy] + : fallbackCopy.unknownDepartment +} diff --git a/src/app/(dashboard)/admin/users/new/page.tsx b/src/app/(dashboard)/admin/users/new/page.tsx index ea78dbe..20f77f0 100644 --- a/src/app/(dashboard)/admin/users/new/page.tsx +++ b/src/app/(dashboard)/admin/users/new/page.tsx @@ -13,8 +13,10 @@ export default async function NewUserPage() { diff --git a/src/app/(dashboard)/people/new/page.tsx b/src/app/(dashboard)/people/new/page.tsx index 5d4f786..a11cbc0 100644 --- a/src/app/(dashboard)/people/new/page.tsx +++ b/src/app/(dashboard)/people/new/page.tsx @@ -1,24 +1,5 @@ -import { getI18n } from "@/i18n/server" +import { redirect } from "next/navigation" -import PersonForm from "../_components/person.form" - -export default async function NewPersonPage() { - const { dictionary } = await getI18n() - const copy = dictionary.inventory.people - - return ( -
-
-

{copy.new.title}

-
- -
- ) +export default function NewPersonPage() { + redirect("/admin/users/new") } diff --git a/src/components/layout/addMenu.tsx b/src/components/layout/addMenu.tsx index 9891838..fda30e7 100644 --- a/src/components/layout/addMenu.tsx +++ b/src/components/layout/addMenu.tsx @@ -38,7 +38,7 @@ const items: { key: keyof AddMenuCopy; href: string }[] = [ }, { key: "person", - href: "/people/new", + href: "/admin/users/new", }, { key: "assignment", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index db6a883..c769219 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -453,8 +453,16 @@ export const en = { form: { nameLabel: "Name", namePlaceholder: "Full name", + firstNameLabel: "First Name", + firstNamePlaceholder: "First name", + lastNameLabel: "Last Name", + lastNamePlaceholder: "Last name", + departmentLabel: "Department", + departmentPlaceholder: "Select a department", emailLabel: "Email", emailPlaceholder: "user@example.com", + phoneLabel: "Phone", + phonePlaceholder: "Phone", passwordLabel: "Password", passwordPlaceholder: "Minimum 8 characters", roleLabel: "Role", diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts index 8ea6c26..53f899c 100644 --- a/src/i18n/dictionaries/es.ts +++ b/src/i18n/dictionaries/es.ts @@ -458,8 +458,16 @@ export const es = { form: { nameLabel: "Nombre", namePlaceholder: "Nombre completo", + firstNameLabel: "Nombre", + firstNamePlaceholder: "Nombre", + lastNameLabel: "Apellido", + lastNamePlaceholder: "Apellido", + departmentLabel: "Departamento", + departmentPlaceholder: "Selecciona un departamento", emailLabel: "Correo electrónico", emailPlaceholder: "usuario@ejemplo.com", + phoneLabel: "Teléfono", + phonePlaceholder: "Teléfono", passwordLabel: "Contraseña", passwordPlaceholder: "Mínimo 8 caracteres", roleLabel: "Rol", diff --git a/src/use-cases/person.use-cases.ts b/src/use-cases/person.use-cases.ts index db07684..d019608 100644 --- a/src/use-cases/person.use-cases.ts +++ b/src/use-cases/person.use-cases.ts @@ -6,8 +6,8 @@ import type { UpdatePersonFormType, } from "@/schemas/person.schema" import type { UnifiedCreateFormType } from "@/schemas/user.schema" -import { getUserByEmail } from "@/services/user.service" import { PersonService } from "@/services/person.service" +import { getUserByEmail } from "@/services/user.service" type FieldErrors = Record @@ -134,8 +134,16 @@ export async function updatePersonUseCase( export async function createPersonUserUseCase( input: UnifiedCreateFormType, ): Promise { - const { firstName, lastName, department, email, phone, role, password, isActive } = - input + const { + firstName, + lastName, + department, + email, + phone, + role, + password, + isActive, + } = input try { return await prisma.$transaction(async (tx) => { diff --git a/tests/integration/use-cases/person-user.use-cases.test.ts b/tests/integration/use-cases/person-user.use-cases.test.ts index c896aba..30412ee 100644 --- a/tests/integration/use-cases/person-user.use-cases.test.ts +++ b/tests/integration/use-cases/person-user.use-cases.test.ts @@ -258,4 +258,4 @@ describe("createPersonUserUseCase", () => { expect(result).toEqual({ success: true }) }) }) -}) \ No newline at end of file +}) diff --git a/tests/integration/use-cases/user.use-cases.test.ts b/tests/integration/use-cases/user.use-cases.test.ts index 883073c..a6a1f95 100644 --- a/tests/integration/use-cases/user.use-cases.test.ts +++ b/tests/integration/use-cases/user.use-cases.test.ts @@ -86,7 +86,9 @@ describe("user use-cases", () => { await expect(prisma.user.count()).resolves.toBe(1) await expect( - prisma.user.findUniqueOrThrow({ where: { email: "existing@example.test" } }), + prisma.user.findUniqueOrThrow({ + where: { email: "existing@example.test" }, + }), ).resolves.toMatchObject({ email: "existing@example.test" }) }) diff --git a/tests/unit/app/people/person-form-pages.test.ts b/tests/unit/app/people/person-form-pages.test.ts index 6b7322c..dffab9e 100644 --- a/tests/unit/app/people/person-form-pages.test.ts +++ b/tests/unit/app/people/person-form-pages.test.ts @@ -1,17 +1,12 @@ import { renderToStaticMarkup } from "react-dom/server" import { beforeEach, describe, expect, it, vi } from "vitest" -import { en } from "@/i18n/dictionaries/en" import { es } from "@/i18n/dictionaries/es" const mocks = vi.hoisted(() => ({ getI18n: vi.fn(), findById: vi.fn(), - createNewPerson: vi.fn(), - updatePerson: vi.fn(), - push: vi.fn(), - toastError: vi.fn(), - toastSuccess: vi.fn(), + redirect: vi.fn(), })) vi.mock("@/i18n/server", () => ({ @@ -24,52 +19,39 @@ vi.mock("@/services/person.service", () => ({ }, })) -vi.mock("@/actions/person.actions", () => ({ - createNewPerson: mocks.createNewPerson, - updatePerson: mocks.updatePerson, +vi.mock("next/navigation", () => ({ + redirect: mocks.redirect, + useRouter: () => ({ + push: vi.fn(), + }), })) -vi.mock("next/navigation", () => ({ - useRouter: () => ({ - push: mocks.push, - }), +vi.mock("@/actions/person.actions", () => ({ + createNewPerson: vi.fn(), + updatePerson: vi.fn(), })) vi.mock("sonner", () => ({ toast: { - error: mocks.toastError, - success: mocks.toastSuccess, + error: vi.fn(), + success: vi.fn(), }, })) -describe("person form pages", () => { +describe("person pages", () => { beforeEach(() => { vi.clearAllMocks() mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" }) }) - it("renders the new person page with Person form copy and no username field", async () => { + it("redirects /people/new to /admin/users/new", async () => { const { default: NewPersonPage } = await import( "@/app/(dashboard)/people/new/page" ) - const html = renderToStaticMarkup(await NewPersonPage()) + await NewPersonPage() - // Person form, not Recipient - expect(html).toContain("Agregar persona") - // No username label or placeholder - expect(html).not.toContain("Usuario") - expect(html).not.toContain('placeholder="Usuario"') - // Has expected person form fields - expect(html).toContain("Nombre") - expect(html).toContain("Apellido") - expect(html).toContain("Selecciona un departamento") - expect(html).toContain('option value="ENGINEERING"') - expect(html).toContain(">Ingeniería") - // Has department options from PERSON_DEPARTMENTS - expect(html).toContain("Correo electrónico") - expect(html).toContain("Teléfono") - expect(html).toContain("Crear persona") + expect(mocks.redirect).toHaveBeenCalledWith("/admin/users/new") }) it("renders the edit person page with Person heading and no username", async () => { @@ -112,16 +94,4 @@ describe("person form pages", () => { expect(html).toContain("Persona no encontrada") }) - - it("wires English Person form submit copy through the new page", async () => { - const { default: NewPersonPage } = await import( - "@/app/(dashboard)/people/new/page" - ) - - mocks.getI18n.mockResolvedValueOnce({ dictionary: en, locale: "en" }) - - const html = renderToStaticMarkup(await NewPersonPage()) - - expect(html).toContain("Create Person") - }) }) diff --git a/tests/unit/app/people/person-form-wiring.test.ts b/tests/unit/app/people/person-form-wiring.test.ts index 87825bf..1b75e36 100644 --- a/tests/unit/app/people/person-form-wiring.test.ts +++ b/tests/unit/app/people/person-form-wiring.test.ts @@ -3,7 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server" import { beforeEach, describe, expect, it, vi } from "vitest" import { en } from "@/i18n/dictionaries/en" -import { es } from "@/i18n/dictionaries/es" const mocks = vi.hoisted(() => ({ getI18n: vi.fn(), @@ -33,23 +32,6 @@ describe("person form schema wiring", () => { vi.clearAllMocks() }) - it("passes server-resolved Person schema copy into the new person form boundary", async () => { - mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" }) - - const { default: NewPersonPage } = await import( - "@/app/(dashboard)/people/new/page" - ) - - renderToStaticMarkup(await NewPersonPage()) - - expect(mocks.personForm).toHaveBeenCalledWith( - expect.objectContaining({ - mode: "create", - schemaCopy: es.inventory.people.schema, - }), - ) - }) - it("passes server-resolved Person schema copy into the edit person form boundary", async () => { mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" }) mocks.findById.mockResolvedValue({ diff --git a/tests/unit/app/users/unified-form-pages.test.ts b/tests/unit/app/users/unified-form-pages.test.ts new file mode 100644 index 0000000..ecc636b --- /dev/null +++ b/tests/unit/app/users/unified-form-pages.test.ts @@ -0,0 +1,125 @@ +import { renderToStaticMarkup } from "react-dom/server" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { en } from "@/i18n/dictionaries/en" +import { es } from "@/i18n/dictionaries/es" + +const mocks = vi.hoisted(() => ({ + createPersonUser: vi.fn(), + getI18n: vi.fn(), + push: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock("@/i18n/server", () => ({ + getI18n: mocks.getI18n, +})) + +vi.mock("@/actions/person.actions", () => ({ + createPersonUserAction: mocks.createPersonUser, +})) + +vi.mock("@/services/person.service", () => ({ + PersonService: { + findById: vi.fn(), + }, +})) + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: mocks.push, + }), +})) + +vi.mock("sonner", () => ({ + toast: { + error: mocks.toastError, + success: mocks.toastSuccess, + }, +})) + +describe("unified creation form page", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" }) + }) + + it("renders unified form with Person fields, email, password, role, and NO_USER option in Spanish", async () => { + const { default: NewUserPage } = await import( + "@/app/(dashboard)/admin/users/new/page" + ) + + const html = renderToStaticMarkup(await NewUserPage()) + + // Person fields + expect(html).toContain("Nombre") + expect(html).toContain("Apellido") + expect(html).toContain("Departamento") + expect(html).toContain("Teléfono") + + // User fields + expect(html).toContain("Correo electrónico") + expect(html).toContain("Contraseña") + expect(html).toContain("Rol") + + // NO_USER role option + expect(html).toContain("Sin cuenta de usuario") + expect(html).toContain('value="NO_USER"') + + // Other role options still present + expect(html).toContain('value="ADMIN"') + expect(html).toContain('value="MANAGER"') + expect(html).toContain('value="STAFF"') + expect(html).toContain('value="VIEWER"') + }) + + it("renders unified form with Person fields and NO_USER option in English", async () => { + mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" }) + + const { default: NewUserPage } = await import( + "@/app/(dashboard)/admin/users/new/page" + ) + + const html = renderToStaticMarkup(await NewUserPage()) + + // Person fields + expect(html).toContain("First Name") + expect(html).toContain("Last Name") + expect(html).toContain("Department") + expect(html).toContain("Phone") + + // User fields + expect(html).toContain("Password") + expect(html).toContain("Email") + + // NO_USER role option + expect(html).toContain("No user account") + expect(html).toContain('value="NO_USER"') + }) + + it("renders Person field placeholders from the unified form dictionary", async () => { + const { default: NewUserPage } = await import( + "@/app/(dashboard)/admin/users/new/page" + ) + + const html = renderToStaticMarkup(await NewUserPage()) + + // Person field placeholders + expect(html).toContain('placeholder="Nombre"') // firstNamePlaceholder (es) + expect(html).toContain('placeholder="Apellido"') // lastNamePlaceholder (es) + expect(html).toContain("Selecciona un departamento") // departmentPlaceholder + expect(html).toContain('placeholder="Teléfono"') // phonePlaceholder (es) + }) + + it("renders department select with all PERSON_DEPARTMENTS values", async () => { + const { default: NewUserPage } = await import( + "@/app/(dashboard)/admin/users/new/page" + ) + + const html = renderToStaticMarkup(await NewUserPage()) + + // Department values must use canonical enum values + expect(html).toContain('value="ADMINISTRATION"') + }) +}) diff --git a/tests/unit/app/users/user-form-pages.test.ts b/tests/unit/app/users/user-form-pages.test.ts index 103761a..9d98813 100644 --- a/tests/unit/app/users/user-form-pages.test.ts +++ b/tests/unit/app/users/user-form-pages.test.ts @@ -5,9 +5,7 @@ import { en } from "@/i18n/dictionaries/en" import { es } from "@/i18n/dictionaries/es" const mocks = vi.hoisted(() => ({ - createUserAction: vi.fn(), - updateUserAction: vi.fn(), - resetUserPasswordAction: vi.fn(), + createPersonUser: vi.fn(), getUserProfileById: vi.fn(), getI18n: vi.fn(), push: vi.fn(), @@ -19,10 +17,19 @@ vi.mock("@/i18n/server", () => ({ getI18n: mocks.getI18n, })) +vi.mock("@/actions/person.actions", () => ({ + createPersonUserAction: mocks.createPersonUser, +})) + vi.mock("@/actions/user.actions", () => ({ - createUserAction: mocks.createUserAction, - updateUserAction: mocks.updateUserAction, - resetUserPasswordAction: mocks.resetUserPasswordAction, + updateUserAction: vi.fn(), + resetUserPasswordAction: vi.fn(), +})) + +vi.mock("@/services/person.service", () => ({ + PersonService: { + findById: vi.fn(), + }, })) vi.mock("@/services/user.service", () => ({ @@ -51,18 +58,7 @@ describe("new user form localization", () => { mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" }) }) - it("passes server-resolved schema and role copy into the new user form boundary", async () => { - const { default: NewUserPage } = await import( - "@/app/(dashboard)/admin/users/new/page" - ) - - renderToStaticMarkup(await NewUserPage()) - - // The page must pass formCopy, schemaCopy, and roleLabels to the form - // We verify this by checking the form renders localized content - }) - - it("renders new user page with localized title and form labels in Spanish", async () => { + it("renders new user page with localized title and unified form labels in Spanish", async () => { const { default: NewUserPage } = await import( "@/app/(dashboard)/admin/users/new/page" ) @@ -72,27 +68,29 @@ describe("new user form localization", () => { // Title expect(html).toContain("Nuevo usuario") - // Form labels from dictionary + // Person field labels expect(html).toContain("Nombre") + expect(html).toContain("Apellido") + expect(html).toContain("Departamento") + expect(html).toContain("Teléfono") + + // User field labels expect(html).toContain("Correo electrónico") expect(html).toContain("Contraseña") expect(html).toContain("Rol") - // Placeholders from dictionary - expect(html).toContain("Nombre completo") - expect(html).toContain("Mínimo 8 caracteres") - // Role labels (display) with canonical values expect(html).toContain("Administrador") expect(html).toContain("Gerente") expect(html).toContain("Personal") expect(html).toContain("Visor") + expect(html).toContain("Sin cuenta de usuario") // Submit button text expect(html).toContain("Crear usuario") }) - it("renders new user page with English form labels in English locale", async () => { + it("renders new user page with English unified form labels in English locale", async () => { mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" }) const { default: NewUserPage } = await import( @@ -102,17 +100,27 @@ describe("new user form localization", () => { const html = renderToStaticMarkup(await NewUserPage()) expect(html).toContain("New User") - expect(html).toContain("Full name") + + // Person fields + expect(html).toContain("First Name") + expect(html).toContain("Last Name") + expect(html).toContain("Department") + expect(html).toContain("Phone") + + // User fields + expect(html).toContain("Email") expect(html).toContain("Password") - expect(html).toContain("Minimum 8 characters") + expect(html).toContain("Role") + expect(html).toContain("Create User") expect(html).toContain("Admin") expect(html).toContain("Manager") expect(html).toContain("Staff") expect(html).toContain("Viewer") + expect(html).toContain("No user account") }) - it("keeps canonical role values in option value attributes, not localized labels", async () => { + it("keeps canonical role values in option value attributes including NO_USER, not localized labels", async () => { const { default: NewUserPage } = await import( "@/app/(dashboard)/admin/users/new/page" ) @@ -124,6 +132,7 @@ describe("new user form localization", () => { expect(html).toContain('value="MANAGER"') expect(html).toContain('value="STAFF"') expect(html).toContain('value="VIEWER"') + expect(html).toContain('value="NO_USER"') }) }) diff --git a/tests/unit/app/users/user.copy.test.ts b/tests/unit/app/users/user.copy.test.ts index f491247..a241b86 100644 --- a/tests/unit/app/users/user.copy.test.ts +++ b/tests/unit/app/users/user.copy.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest" -import { formatUserRole } from "@/app/(dashboard)/admin/users/_components/user.copy" +import { + formatPersonDepartment, + formatUserRole, +} from "@/app/(dashboard)/admin/users/_components/user.copy" describe("user copy helpers", () => { const roleCopy = { @@ -34,3 +37,47 @@ describe("user copy helpers", () => { ).toBe("Rol desconocido") }) }) + +describe("formatPersonDepartment helper", () => { + const departmentCopy = { + IT: "IT", + ENGINEERING: "Ingeniería", + LOGISTICS: "Logística", + TRAFFIC: "Tráfico", + DRIVER: "Chofer", + ADMINISTRATION: "Administración", + SALES: "Ventas", + OTHER: "Otro", + } + + const fallbackCopy = { + unknownDepartment: "Departamento desconocido", + } + + it("formats known department values with localized labels", () => { + expect( + formatPersonDepartment("ENGINEERING", departmentCopy, fallbackCopy), + ).toBe("Ingeniería") + expect( + formatPersonDepartment("ADMINISTRATION", departmentCopy, fallbackCopy), + ).toBe("Administración") + }) + + it("falls back for unknown department values", () => { + expect( + formatPersonDepartment("UNKNOWN_DEPT", departmentCopy, fallbackCopy), + ).toBe("Departamento desconocido") + }) + + it("falls back for null department values", () => { + expect(formatPersonDepartment(null, departmentCopy, fallbackCopy)).toBe( + "Departamento desconocido", + ) + }) + + it("falls back for undefined department values", () => { + expect( + formatPersonDepartment(undefined, departmentCopy, fallbackCopy), + ).toBe("Departamento desconocido") + }) +}) diff --git a/tests/unit/i18n/admin-users-dictionary.test.ts b/tests/unit/i18n/admin-users-dictionary.test.ts index b0c227f..510baff 100644 --- a/tests/unit/i18n/admin-users-dictionary.test.ts +++ b/tests/unit/i18n/admin-users-dictionary.test.ts @@ -28,8 +28,16 @@ describe("admin users dictionary", () => { expect(users.form).toEqual({ nameLabel: "Name", namePlaceholder: "Full name", + firstNameLabel: "First Name", + firstNamePlaceholder: "First name", + lastNameLabel: "Last Name", + lastNamePlaceholder: "Last name", + departmentLabel: "Department", + departmentPlaceholder: "Select a department", emailLabel: "Email", emailPlaceholder: "user@example.com", + phoneLabel: "Phone", + phonePlaceholder: "Phone", passwordLabel: "Password", passwordPlaceholder: "Minimum 8 characters", roleLabel: "Role", @@ -112,8 +120,16 @@ describe("admin users dictionary", () => { expect(users.form).toEqual({ nameLabel: "Nombre", namePlaceholder: "Nombre completo", + firstNameLabel: "Nombre", + firstNamePlaceholder: "Nombre", + lastNameLabel: "Apellido", + lastNamePlaceholder: "Apellido", + departmentLabel: "Departamento", + departmentPlaceholder: "Selecciona un departamento", emailLabel: "Correo electrónico", emailPlaceholder: "usuario@ejemplo.com", + phoneLabel: "Teléfono", + phonePlaceholder: "Teléfono", passwordLabel: "Contraseña", passwordPlaceholder: "Mínimo 8 caracteres", roleLabel: "Rol", diff --git a/tests/unit/i18n/unified-form-dictionary.test.ts b/tests/unit/i18n/unified-form-dictionary.test.ts new file mode 100644 index 0000000..303e167 --- /dev/null +++ b/tests/unit/i18n/unified-form-dictionary.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest" + +import { dictionaries, getDictionary } from "@/i18n/dictionaries" + +describe("admin users unified form dictionary", () => { + it("provides unified form Person field keys in English admin.users.form", () => { + const form = getDictionary("en").admin.users.form + + expect(form.firstNameLabel).toBe("First Name") + expect(form.firstNamePlaceholder).toBe("First name") + expect(form.lastNameLabel).toBe("Last Name") + expect(form.lastNamePlaceholder).toBe("Last name") + expect(form.departmentLabel).toBe("Department") + expect(form.departmentPlaceholder).toBe("Select a department") + expect(form.phoneLabel).toBe("Phone") + expect(form.phonePlaceholder).toBe("Phone") + }) + + it("provides unified form Person field keys in Spanish admin.users.form", () => { + const form = getDictionary("es").admin.users.form + + expect(form.firstNameLabel).toBe("Nombre") + expect(form.firstNamePlaceholder).toBe("Nombre") + expect(form.lastNameLabel).toBe("Apellido") + expect(form.lastNamePlaceholder).toBe("Apellido") + expect(form.departmentLabel).toBe("Departamento") + expect(form.departmentPlaceholder).toBe("Selecciona un departamento") + expect(form.phoneLabel).toBe("Teléfono") + expect(form.phonePlaceholder).toBe("Teléfono") + }) + + it("maintains structural parity for admin.users.form between English and Spanish after Person keys", () => { + const enKeys = extractKeyPaths(dictionaries.en.admin.users.form) + const esKeys = extractKeyPaths(dictionaries.es.admin.users.form) + + expect(esKeys).toEqual(enKeys) + }) +}) + +function extractKeyPaths(value: unknown, prefix = ""): string[] { + if (!isPlainObject(value)) return [prefix] + + return Object.keys(value) + .sort() + .flatMap((key) => + extractKeyPaths(value[key], prefix ? `${prefix}.${key}` : key), + ) +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/tests/unit/schemas/core-schemas.test.ts b/tests/unit/schemas/core-schemas.test.ts index 542e0c7..b47db86 100644 --- a/tests/unit/schemas/core-schemas.test.ts +++ b/tests/unit/schemas/core-schemas.test.ts @@ -108,13 +108,15 @@ describe("core schemas", () => { ) expect( - signInSchema.safeParse({ email: "admin@test.com", password: "abc" }).success, + signInSchema.safeParse({ email: "admin@test.com", password: "abc" }) + .success, ).toBe(true) + expect(signInSchema.safeParse({ email: "", password: "abc" }).success).toBe( + false, + ) expect( - signInSchema.safeParse({ email: "", password: "abc" }).success, - ).toBe(false) - expect( - signInSchema.safeParse({ email: "admin@test.com", password: "ab" }).success, + signInSchema.safeParse({ email: "admin@test.com", password: "ab" }) + .success, ).toBe(false) }) diff --git a/tests/unit/schemas/unified-create.schema.test.ts b/tests/unit/schemas/unified-create.schema.test.ts index a9cfa47..ce99457 100644 --- a/tests/unit/schemas/unified-create.schema.test.ts +++ b/tests/unit/schemas/unified-create.schema.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest" import { buildUnifiedCreateSchema, - unifiedFormRoleSchema, type UnifiedSchemaCopy, + unifiedFormRoleSchema, } from "@/schemas/user.schema" const enCopy: UnifiedSchemaCopy = { @@ -260,4 +260,4 @@ describe("buildUnifiedCreateSchema", () => { } }) }) -}) \ No newline at end of file +})