This commit is contained in:
David Arranz 2026-04-20 21:15:35 +02:00
parent bc554c180e
commit b5f4c4cdfc
13 changed files with 434 additions and 111 deletions

View File

@ -0,0 +1,151 @@
import { DomainError, ValidationErrorCollection, type ValidationErrorDetail } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
/**
* PatchCollector
*
* Utilidad de apoyo para la construcción de objetos de tipo PATCH en mappers de Application.
*
* Responsabilidad:
* - Acumular propiedades parciales válidas (`patch`)
* - Centralizar la validación de entrada (DTO Value Objects)
* - Registrar errores de validación de forma homogénea
* - Construir un `Result<T>` final consistente (éxito o `ValidationErrorCollection`)
*
* Contexto de uso:
* - Mappers de tipo `RequestDTO -> *PatchProps`
* - Casos de uso de actualización (semántica PATCH)
*
* Semántica aplicada (PATCH):
* - `undefined` campo omitido no se modifica
* - `null` eliminación explícita (solo en campos nullable)
* - valor definido validación + transformación a VO asignación
*
* Capacidades:
* - Helpers para distintos tipos de campo:
* - `setBoolean` campos primitivos no anulables
* - `setRequiredVo` campos obligatorios cuando se envían
* - `setNullableVo` campos opcionales/anulables (usa Maybe/null)
* - `setNested` composición de sub-objetos (address, contact, etc.)
*
* Reglas de diseño:
* - No contiene lógica de negocio
* - No accede a repositorios ni infraestructura
* - No construye agregados
* - No conoce DTOs concretos ni dominio específico
* - Es determinista y libre de efectos secundarios externos
*
* Manejo de errores:
* - Los errores se acumulan como `ValidationErrorDetail[]`
* - `build()` devuelve:
* - `Result.ok(patch)` si no hay errores
* - `Result.fail(ValidationErrorCollection)` si existen errores
*
* Objetivo:
* Reducir complejidad accidental en mappers, evitar duplicación de lógica
* y garantizar consistencia en validación y reporting de errores.
*/
export class PatchCollector {
private readonly errors: ValidationErrorDetail[] = [];
public addError(path: string, message: string): void {
this.errors.push({ path, message });
}
public hasErrors(): boolean {
return this.errors.length > 0;
}
public build<T>(value: T, message: string): Result<T, Error> {
if (this.errors.length > 0) {
return Result.fail(new ValidationErrorCollection(message, this.errors));
}
return Result.ok(value);
}
public failUnexpected(message: string, cause: unknown): Result<never, Error> {
return Result.fail(new DomainError(message, { cause }));
}
public setBoolean(
path: string,
input: boolean | undefined,
setter: (value: boolean) => void
): void {
if (input === undefined) {
return;
}
setter(input);
}
public setRequiredVo<T>(
path: string,
input: string | undefined,
factory: (value: string) => Result<T, Error>,
setter: (value: T) => void,
emptyMessage = "Value cannot be empty"
): void {
if (input === undefined) {
return;
}
if (input.trim() === "") {
this.addError(path, emptyMessage);
return;
}
const result = factory(input);
if (result.isFailure) {
this.addError(path, result.error.message);
return;
}
setter(result.data);
}
public setNullableVo<T>(
path: string,
input: string | null | undefined,
factory: (value: string) => Result<T, Error>,
setter: (value: ReturnType<typeof this.wrapNullable<T>>) => void
): void {
if (input === undefined) {
return;
}
if (input === null) {
setter(this.wrapNullable<T>(null));
return;
}
const result = factory(input);
if (result.isFailure) {
this.addError(path, result.error.message);
return;
}
setter(this.wrapNullable(result.data));
}
public setNested<TInput>(
_path: string,
input: TInput | undefined,
mapper: (value: TInput) => void
): void {
if (input === undefined) {
return;
}
mapper(input);
}
/**
* Helper adaptable al tipo Maybe/nullable real del proyecto.
* Aquí lo dejo abstracto para no acoplar el ejemplo a una implementación concreta.
*/
private wrapNullable<T>(value: T | null): T | null {
return value;
}
}

View File

@ -0,0 +1,90 @@
// packages/rdx-ui-or-core/src/forms/apply-validation-error-collection.ts
import type { ValidationErrorCollection } from "@repo/rdx-ddd";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
export interface ApplyValidationErrorCollectionOptions<TFormValues extends FieldValues> {
/**
* Permite transformar paths del backend al path real del formulario.
* Ej:
* - "email_primary" -> "emailPrimary"
* - "recipient.name" -> "recipient.name"
*/
mapPath?: (backendPath: string) => Path<TFormValues> | undefined;
/**
* Nombre del campo raíz donde dejar errores globales/no mapeables.
* Opcional. Si no existe, simplemente no se asignan esos errores al form.
*/
rootFieldName?: Path<TFormValues>;
/**
* Si true, evita sobrescribir el mismo campo varias veces.
* Útil si backend manda varios errores sobre el mismo path.
*/
keepFirstErrorPerField?: boolean;
}
const normalizeBackendPath = (path: string): string => {
return path
.trim()
.replace(/\[(\d+)\]/g, ".$1") // lines[0].name -> lines.0.name
.replace(/^\.+|\.+$/g, "") // quita puntos al principio/final
.replace(/\.{2,}/g, "."); // colapsa puntos dobles
};
const defaultMapPath = <TFormValues extends FieldValues>(
backendPath: string
): Path<TFormValues> | undefined => {
const normalized = normalizeBackendPath(backendPath);
return normalized as Path<TFormValues>;
};
export const applyValidationErrorCollection = <TFormValues extends FieldValues>(
form: UseFormReturn<TFormValues>,
error: ValidationErrorCollection,
options?: ApplyValidationErrorCollectionOptions<TFormValues>
) => {
const mapPath = options?.mapPath ?? defaultMapPath<TFormValues>;
const rootFieldName = options?.rootFieldName;
const keepFirstErrorPerField = options?.keepFirstErrorPerField ?? true;
const assignedFields = new Set<string>();
const globalMessages: string[] = [];
for (const detail of error.details) {
const message = detail.message?.trim();
const rawPath = detail.path?.trim();
if (!message) continue;
if (!rawPath) {
globalMessages.push(message);
continue;
}
const fieldPath = mapPath(rawPath);
if (!fieldPath) {
globalMessages.push(message);
continue;
}
if (keepFirstErrorPerField && assignedFields.has(fieldPath)) {
continue;
}
form.setError(fieldPath, {
type: "server",
message,
});
assignedFields.add(fieldPath);
}
if (globalMessages.length > 0 && rootFieldName) {
form.setError(rootFieldName, {
type: "server",
message: globalMessages.join("\n"),
});
}
};

View File

@ -1,3 +1,4 @@
export * from "./apply-validation-error-collection";
export * from "./date-helper";
export * from "./dto-compare-helper";
export * from "./money-dto-helper";

View File

@ -1,12 +1,12 @@
export const COUNTRY_OPTIONS = [
{ value: "es", label: "España" },
{ value: "fr", label: "Francia" },
{ value: "de", label: "Alemania" },
{ value: "it", label: "Italia" },
{ value: "pt", label: "Portugal" },
{ value: "us", label: "Estados Unidos" },
{ value: "mx", label: "México" },
{ value: "ar", label: "Argentina" },
{ value: "ES", label: "España" },
{ value: "FR", label: "Francia" },
{ value: "DE", label: "Alemania" },
{ value: "IT", label: "Italia" },
{ value: "PT", label: "Portugal" },
{ value: "US", label: "Estados Unidos" },
{ value: "MX", label: "México" },
{ value: "AR", label: "Argentina" },
] as const;
export const LANGUAGE_OPTIONS = [

View File

@ -33,7 +33,13 @@ export const useCustomerUpdateMutation = () => {
const result = schema.safeParse(data);
if (!result.success) {
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
console.log("Error de validación al actualizar cliente:", toValidationErrors(result.error));
const errorCollection = new ValidationErrorCollection(
"Validation failed",
toValidationErrors(result.error)
);
console.log("Errores de validación convertidos:", errorCollection);
throw errorCollection;
}
const dto: UpdateCustomerByIdResult = await updateCustomerById(dataSource, params);

View File

@ -1,3 +1,4 @@
import { applyValidationErrorCollection } from "@erp/core";
import { useHookForm } from "@erp/core/hooks";
import { type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
@ -17,8 +18,11 @@ import {
CustomerUpdateFormSchema,
defaultCustomerUpdateForm,
} from "../entities";
import { buildCustomerUpdatePatch, buildUpdateCustomerByIdParams } from "../utils";
import { focusFirstCustomerUpdateError } from "../utils/focus-first-customer-update-error";
import {
buildCustomerUpdatePatch,
buildUpdateCustomerByIdParams,
focusFirstCustomerUpdateFormError,
} from "../utils";
export interface UseCustomerUpdateControllerOptions {
onUpdated?(updated: Customer): void;
@ -28,6 +32,18 @@ export interface UseCustomerUpdateControllerOptions {
errorToasts?: boolean; // mostrar o no toast automáticamente
}
const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => {
if (isValidationErrorCollection(error)) {
return error;
}
if (error instanceof Error) {
return error;
}
return new Error("Unknown error");
};
export const useCustomerUpdateController = (
customerId?: string,
options?: UseCustomerUpdateControllerOptions
@ -91,9 +107,20 @@ export const useCustomerUpdateController = (
return;
}
const previousData = customerData;
//const previousData = customerData;
const patchData = buildCustomerUpdatePatch(formData, form.formState.dirtyFields);
if (Object.keys(patchData).length === 0) {
showWarningToast(
t("pages.update.no_changes.title", "Sin cambios"),
t("pages.update.no_changes.message", "No has realizado ningún cambio.")
);
return;
}
console.log("Parche de actualización construido:", patchData);
const params = buildUpdateCustomerByIdParams(customerId, patchData);
console.log("Enviando actualización con params:", params);
@ -117,25 +144,49 @@ export const useCustomerUpdateController = (
options?.onUpdated?.(updated);
} catch (error: unknown) {
const normalizedError = isValidationErrorCollection(error)
? (error as ValidationErrorCollection)
: (error as Error);
const normalizedError = normalizeSubmitError(error);
// Revertir form a datos anteriores (si los hay)
form.reset(
// No revierto el form para que no se pierdan los cambios que
// ha hecho el usuario y no han sido guardados.
/*form.reset(
previousData ? mapCustomerToCustomerUpdateForm(previousData) : defaultCustomerUpdateForm,
{ keepDirty: false }
);
);*/
if (isValidationErrorCollection(normalizedError)) {
applyValidationErrorCollection(form, normalizedError);
console.log("Errores de validación aplicados al form:", form.formState.errors);
focusFirstCustomerUpdateFormError(form);
if (options?.errorToasts !== false) {
showWarningToast(
t("pages.update.validation_error.title", "Revisa los campos"),
t(
"pages.update.validation_error.message",
"Hay errores de validación. Corrige los campos indicados."
)
);
}
options?.onError?.(normalizedError, params);
return;
}
if (options?.errorToasts !== false) {
showErrorToast(t("pages.update.error.title"), normalizedError.message);
showErrorToast(
t("pages.update.error.title", "No se pudo modificar el cliente"),
normalizedError.message ||
t("common.errors.unexpected", "Ha ocurrido un error inesperado.")
);
}
options?.onError?.(normalizedError, params);
}
},
(errors: FieldErrors<CustomerUpdateForm>) => {
focusFirstCustomerUpdateError(errors);
focusFirstCustomerUpdateFormError(form);
showWarningToast(
t("forms.validation.title", "Revisa los campos"),

View File

@ -1,3 +1,13 @@
import {
CountryCodeSchema,
CurrencyCodeSchema,
LandPhoneSchema,
LanguageCodeSchema,
MobilePhoneSchema,
PostalCodeSchema,
TinSchema,
URLSchema,
} from "@erp/core";
import { z } from "zod/v4";
/**
@ -18,9 +28,10 @@ import { z } from "zod/v4";
export const CustomerUpdateFormSchema = z.object({
reference: z.string(),
isCompany: z.boolean(),
name: z.string().min(1, "El nombre es obligatorio"),
tradeName: z.string(),
tin: z.string(),
tin: TinSchema,
defaultTaxes: z.array(z.string()),
@ -28,22 +39,22 @@ export const CustomerUpdateFormSchema = z.object({
street2: z.string(),
city: z.string(),
province: z.string(),
postalCode: z.string(),
country: z.string().min(1, "El país es obligatorio"),
postalCode: PostalCodeSchema.or(z.literal("")),
country: CountryCodeSchema.or(z.literal("")),
primaryEmail: z.email("Email inválido").or(z.literal("")),
secondaryEmail: z.email("Email inválido").or(z.literal("")),
primaryPhone: z.string(),
secondaryPhone: z.string(),
primaryMobile: z.string(),
secondaryMobile: z.string(),
primaryPhone: LandPhoneSchema.or(z.literal("")),
secondaryPhone: LandPhoneSchema.or(z.literal("")),
primaryMobile: MobilePhoneSchema.or(z.literal("")),
secondaryMobile: MobilePhoneSchema.or(z.literal("")),
fax: z.string(),
website: z.url("URL inválida").or(z.literal("")),
fax: LandPhoneSchema.or(z.literal("")).or(z.literal("")),
website: URLSchema.or(z.literal("")).or(z.literal("")),
legalRecord: z.string(),
legalRecord: z.string().or(z.literal("")),
languageCode: z.string().min(1, "El idioma es obligatorio"),
currencyCode: z.string().min(1, "La moneda es obligatoria"),
languageCode: LanguageCodeSchema,
currencyCode: CurrencyCodeSchema,
});

View File

@ -33,13 +33,10 @@ export const CustomerContactEditor = ({
description={t("form_fields.email_primary.description")}
disabled={disabled}
label={t("form_fields.email_primary.label")}
leftIcon={
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
leftIcon={<AtSignIcon className="size-4" strokeWidth={1.5} />}
name="primaryEmail"
placeholder={t("form_fields.email_primary.placeholder")}
readOnly={readOnly}
required
typePreset="email"
/>
@ -48,12 +45,7 @@ export const CustomerContactEditor = ({
description={t("form_fields.mobile_primary.description")}
disabled={disabled}
label={t("form_fields.mobile_primary.label")}
leftIcon={
<SmartphoneIcon
className="h-[18px] w-[18px] text-muted-foreground"
strokeWidth={1.5}
/>
}
leftIcon={<SmartphoneIcon className="size-4" strokeWidth={1.5} />}
name="primaryMobile"
placeholder={t("form_fields.mobile_primary.placeholder")}
readOnly={readOnly}
@ -65,9 +57,7 @@ export const CustomerContactEditor = ({
description={t("form_fields.phone_primary.description")}
disabled={disabled}
label={t("form_fields.phone_primary.label")}
leftIcon={
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
leftIcon={<PhoneIcon className="size-4" strokeWidth={1.5} />}
name="primaryPhone"
placeholder={t("form_fields.phone_primary.placeholder")}
readOnly={readOnly}
@ -81,9 +71,7 @@ export const CustomerContactEditor = ({
description={t("form_fields.email_secondary.description")}
disabled={disabled}
label={t("form_fields.email_secondary.label")}
leftIcon={
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
leftIcon={<AtSignIcon className="size-4" strokeWidth={1.5} />}
name="secondaryEmail"
placeholder={t("form_fields.email_secondary.placeholder")}
readOnly={readOnly}
@ -95,12 +83,7 @@ export const CustomerContactEditor = ({
description={t("form_fields.mobile_secondary.description")}
disabled={disabled}
label={t("form_fields.mobile_secondary.label")}
leftIcon={
<SmartphoneIcon
className="h-[18px] w-[18px] text-muted-foreground"
strokeWidth={1.5}
/>
}
leftIcon={<SmartphoneIcon className="size-4" strokeWidth={1.5} />}
name="secondaryMobile"
placeholder={t("form_fields.mobile_secondary.placeholder")}
readOnly={readOnly}
@ -111,9 +94,7 @@ export const CustomerContactEditor = ({
description={t("form_fields.phone_secondary.description")}
disabled={disabled}
label={t("form_fields.phone_secondary.label")}
leftIcon={
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
leftIcon={<PhoneIcon className="size-4" strokeWidth={1.5} />}
name="secondaryPhone"
placeholder={t("form_fields.phone_secondary.placeholder")}
readOnly={readOnly}
@ -138,12 +119,7 @@ export const CustomerContactEditor = ({
description={t("form_fields.website.description")}
disabled={disabled}
label={t("form_fields.website.label")}
leftIcon={
<GlobeIcon
className="h-[18px] w-[18px] text-muted-foreground"
strokeWidth={1.5}
/>
}
leftIcon={<GlobeIcon className="size-4" strokeWidth={1.5} />}
name="website"
placeholder={t("form_fields.website.placeholder")}
readOnly={readOnly}

View File

@ -25,9 +25,6 @@ export const buildCustomerUpdatePatch = (
if (!formHasAnyDirty(dirtyFields)) {
return {};
}
console.log("Campos sucios detectados:", dirtyFields);
const patch: CustomerUpdatePatch = {};
if (dirtyFields.reference) {

View File

@ -23,38 +23,77 @@ export const buildUpdateCustomerByIdParams = (
id: string,
patchData: CustomerUpdatePatch
): UpdateCustomerByIdParams => {
const data: Record<string, unknown> = {};
// --- escalares ---
if (patchData.reference !== undefined) {
data.reference = patchData.reference;
}
if (patchData.isCompany !== undefined) {
data.is_company = patchData.isCompany;
}
if (patchData.name !== undefined) {
data.name = patchData.name;
}
if (patchData.tradeName !== undefined) {
data.trade_name = patchData.tradeName;
}
if (patchData.tin !== undefined) {
data.tin = patchData.tin;
}
if (patchData.defaultTaxes !== undefined) {
data.default_taxes = patchData.defaultTaxes?.toString();
}
if (patchData.legalRecord !== undefined) {
data.legal_record = patchData.legalRecord;
}
if (patchData.languageCode !== undefined) {
data.language_code = patchData.languageCode;
}
if (patchData.currencyCode !== undefined) {
data.currency_code = patchData.currencyCode;
}
// --- address ---
const address: Record<string, unknown> = {};
if (patchData.street !== undefined) address.street = patchData.street;
if (patchData.street2 !== undefined) address.street2 = patchData.street2;
if (patchData.city !== undefined) address.city = patchData.city;
if (patchData.province !== undefined) address.province = patchData.province;
if (patchData.postalCode !== undefined) address.postal_code = patchData.postalCode;
if (patchData.country !== undefined) address.country = patchData.country;
if (Object.keys(address).length > 0) {
data.address = address;
}
// --- contact ---
const contact: Record<string, unknown> = {};
if (patchData.primaryEmail !== undefined) contact.email_primary = patchData.primaryEmail;
if (patchData.secondaryEmail !== undefined) contact.email_secondary = patchData.secondaryEmail;
if (patchData.primaryPhone !== undefined) contact.phone_primary = patchData.primaryPhone;
if (patchData.secondaryPhone !== undefined) contact.phone_secondary = patchData.secondaryPhone;
if (patchData.primaryMobile !== undefined) contact.mobile_primary = patchData.primaryMobile;
if (patchData.secondaryMobile !== undefined) contact.mobile_secondary = patchData.secondaryMobile;
if (patchData.fax !== undefined) contact.fax = patchData.fax;
if (patchData.website !== undefined) contact.website = patchData.website;
if (Object.keys(contact).length > 0) {
data.contact = contact;
}
return {
id,
data: {
reference: patchData.reference,
is_company: patchData.isCompany,
name: patchData.name,
trade_name: patchData.tradeName,
tin: patchData.tin,
default_taxes: patchData.defaultTaxes?.toString(),
address: {
street: patchData.street,
street2: patchData.street2,
city: patchData.city,
province: patchData.province,
postal_code: patchData.postalCode,
country: patchData.country,
},
contact: {
email_primary: patchData.primaryEmail,
email_secondary: patchData.secondaryEmail,
phone_primary: patchData.primaryPhone,
phone_secondary: patchData.secondaryPhone,
mobile_primary: patchData.primaryMobile,
mobile_secondary: patchData.secondaryMobile,
fax: patchData.fax,
website: patchData.website,
},
legal_record: patchData.legalRecord,
language_code: patchData.languageCode,
currency_code: patchData.currencyCode,
},
data,
} satisfies UpdateCustomerByIdParams;
};

View File

@ -1,13 +0,0 @@
import type { FieldErrors } from "react-hook-form";
import type { CustomerUpdateForm } from "../entities";
export const focusFirstCustomerUpdateError = (errors: FieldErrors<CustomerUpdateForm>) => {
const firstKey = Object.keys(errors)[0] as keyof CustomerUpdateForm | undefined;
if (!firstKey) {
return;
}
document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`)?.focus();
};

View File

@ -0,0 +1,14 @@
import type { UseFormReturn } from "react-hook-form";
import type { CustomerUpdateForm } from "../entities";
export const focusFirstCustomerUpdateFormError = (form: UseFormReturn<CustomerUpdateForm>) => {
const errors = form.formState.errors;
const firstKey = Object.keys(errors)[0] as keyof CustomerUpdateForm | undefined;
if (firstKey) {
form.setFocus(firstKey);
}
return;
};

View File

@ -1,3 +1,3 @@
export * from "./build-customer.update-patch";
export * from "./build-update-customer-by-id-params";
export * from "./focus-first-customer-update-error";
export * from "./focus-first-customer-update-form-error";