Add end-to-end tests for proforma customer selection and persistence

- Implement tests to verify that the selected customer is retained when creating a proforma.
- Add functionality to change the customer and ensure the change is persisted.
- Include tests to confirm the customer change and refresh the recipient name.
- Test cancellation of customer change to ensure the previous customer remains selected.
This commit is contained in:
David Arranz 2026-09-15 11:37:50 +02:00
parent 0b086047ee
commit ba257fd489
31 changed files with 249 additions and 1772 deletions

View File

@ -98,7 +98,7 @@ export const CompanyDetailsSettingsPage = () => {
>
<RdxPanel
description={t("settings.companyDetails.sections.general.description")}
icon={<ScrollTextIcon className="size-5" />}
icon={<ScrollTextIcon className="size-6" />}
title={t("settings.companyDetails.sections.general.title")}
variant="plain"
>
@ -148,7 +148,7 @@ export const CompanyDetailsSettingsPage = () => {
<RdxPanel
description={t("settings.companyDetails.sections.contact.description")}
icon={<ContactIcon className="size-5" />}
icon={<ContactIcon className="size-6" />}
title={t("settings.companyDetails.sections.contact.title")}
variant="plain"
>
@ -198,7 +198,7 @@ export const CompanyDetailsSettingsPage = () => {
</RdxPanel>
<RdxPanel
description="Datos fiscales de la empresa"
icon={<ContactIcon className="size-5" />}
icon={<ContactIcon className="size-6" />}
title="Dirección fiscal"
variant="plain"
>
@ -238,7 +238,7 @@ export const CompanyDetailsSettingsPage = () => {
<RdxPanel
description={t("settings.companyDetails.sections.paymentTerm.description")}
icon={<LandmarkIcon className="size-5" />}
icon={<LandmarkIcon className="size-6" />}
title={t("settings.companyDetails.sections.paymentTerm.title")}
variant="plain"
>

View File

@ -157,7 +157,7 @@ test("update registra customer y recipient en una sola mutación Audit", async (
city: Maybe.some(unwrap(City.create("Madrid"))),
postalCode: Maybe.some(unwrap(PostalCode.create("28001"))),
province: Maybe.some(unwrap(Province.create("Madrid"))),
country: Maybe.some(unwrap(Country.create("ES"))),
countryCode: Maybe.some(unwrap(Country.create("ES"))),
},
}),
} as never,

View File

@ -213,7 +213,7 @@ export class ProformaUpdater implements IProformaUpdater {
city: customer.address.city,
postalCode: customer.address.postalCode,
province: customer.address.province,
country: customer.address.country,
country: customer.address.countryCode,
});
if (recipientResult.isFailure) {

View File

@ -13,6 +13,7 @@ import { ArrowRightIcon, CheckIcon, LockIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "../../../i18n";
import { ProformaStatusBadge } from "../../list/ui/components";
import { PROFORMA_STATUS, type ProformaStatus } from "../../shared";
import type { ChangeProformaStatusTarget } from "../entities";
import {
@ -49,7 +50,7 @@ const StatusBadge = ({ status }: { status: ProformaStatus }) => {
getProformaStatusColor(status)
)}
>
<Icon className="size-3.5" />
<Icon className="size-" />
{t(`catalog.proformas.status.${status}.label`)}
</span>
);
@ -149,7 +150,7 @@ export const ChangeProformaStatusDialog = ({
<span className="text-sm text-muted-foreground">
{t("proformas.change_proforma_status_dialog.current_status", "Estado actual")}
</span>
<StatusBadge status={currentTarget.status} />
<ProformaStatusBadge status={currentTarget.status} />
</div>
{orderedAvailableStatuses.length === 0 ? (

View File

@ -44,7 +44,7 @@ export const ProformaCreateInitialInfoCard = ({
return (
<RdxPanel
icon={<FileTextIcon className="size-5" />}
icon={<FileTextIcon className="size-6" />}
title={t("proformas.create.initial_info.title", "Información inicial")}
variant="card"
>

View File

@ -52,7 +52,7 @@ export const ProformaTotalsSummary = ({
className={className}
description={t("proformas.update.totals.description")}
disabled={disabled}
icon={<CurrencyIcon className="size-5" />}
icon={<CurrencyIcon className="size-6" />}
title={t("proformas.update.totals.title", "Totales")}
>
<FormSectionGrid className="gap-5 md:grid-cols-1">

View File

@ -172,12 +172,21 @@ export const ProformaUpdateCustomerChangeDialog = ({
</AlertDialogHeader>
<AlertDialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
<Button
disabled={isSubmitting}
id="proforma-customer-change-cancel"
onClick={() => onOpenChange(false)}
variant="outline"
>
{t("proformas.update.customer_change.dialog.cancel", "Cancelar")}
</Button>
{status === "compatible" ? (
<Button disabled={isSubmitting} onClick={onConfirm}>
<Button
disabled={isSubmitting}
id="proforma-customer-change-confirm"
onClick={onConfirm}
>
{isSubmitting ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />

View File

@ -28,7 +28,7 @@ export const ProformaUpdatePaymentEditor = ({
<RdxPanel
className={className}
description={t("form_groups.proformas.payment.description", "Forma de pago")}
icon={<CreditCardIcon className="size-5" />}
icon={<CreditCardIcon className="size-6" />}
title={t("form_groups.proformas.payment.title", "Condiciones de pago")}
variant="card"
>

View File

@ -22,7 +22,7 @@ export const ProformaUpdateSettingsEditor = ({
className={className}
description={t("form_groups.proformas.settings.description", "Configuración de la proforma")}
disabled={disabled}
icon={<Settings2Icon className="size-5" />}
icon={<Settings2Icon className="size-6" />}
title={t("form_groups.proformas.settings.title", "Configuración")}
>
<FormSectionGrid className="md:grid-cols-1">

View File

@ -31,7 +31,7 @@ export const ProformaUpdateHeaderEditor = ({
return (
<RdxPanel
className={className}
icon={<FileTextIcon className="size-5" />}
icon={<FileTextIcon className="size-6" />}
title={t("form_groups.proformas.basic_info.title")}
variant="card"
>

View File

@ -38,7 +38,7 @@ export const ProformaUpdateRecipientEditor = ({
"form_groups.proformas.customer.description",
"Cliente destinatario de la proforma"
)}
icon={<UserIcon className="size-5" />}
icon={<UserIcon className="size-6" />}
title={t("form_groups.proformas.customer.title", "Cliente")}
variant="card"
>
@ -60,6 +60,7 @@ export const ProformaUpdateRecipientEditor = ({
<Button
className="justify-center"
disabled={disabled}
id="proforma-change-customer"
onClick={onChangeCustomerClick}
type="button"
variant={selectedCustomer ? "outline" : "default"}

View File

@ -44,7 +44,7 @@ export const ProformaUpdateTaxEditor = ({
"form_groups.proformas.taxes.description",
"Configura los valores fiscales por defecto de la proforma. Los impuestos aplicados y los totales se calculan desde las líneas valoradas."
)}
icon={<LandmarkIcon className="size-5" />}
icon={<LandmarkIcon className="size-6" />}
title={t("form_groups.proformas.taxes.title", "Configuración fiscal")}
variant="card"
>

View File

@ -30,7 +30,7 @@ export const CompanyVerifactuConfigurationSection = ({
return (
<RdxPanel
description={t("settings.verifactu.configuration.description")}
icon={<ShieldCheckIcon className="size-5" />}
icon={<ShieldCheckIcon className="size-6" />}
title={t("settings.verifactu.configuration.title")}
variant="plain"
>

View File

@ -19,7 +19,7 @@ export const CompanyVerifactuDefaultsSection = ({
return (
<RdxPanel
description={t("settings.verifactu.defaults.description")}
icon={<LandmarkIcon className="size-5" />}
icon={<LandmarkIcon className="size-6" />}
title={t("settings.verifactu.defaults.title")}
variant="plain"
>

View File

@ -21,7 +21,7 @@ export const CompanyVerifactuStatusSection = () => {
return (
<RdxPanel
description={t("settings.verifactu.status.description")}
icon={<FileCogIcon className="size-5" />}
icon={<FileCogIcon className="size-6" />}
title={t("settings.verifactu.status.title")}
variant="plain"
>

View File

@ -18,7 +18,7 @@ export const CustomerAdditionalConfigEditor = ({
return (
<RdxPanel
description={t("form_groups.preferences.description")}
icon={<Settings2Icon className="size-5" />}
icon={<Settings2Icon className="size-6" />}
title={t("form_groups.preferences.title")}
variant="card"
>

View File

@ -18,7 +18,7 @@ export const CustomerAddressEditor = ({
return (
<RdxPanel
description={t("form_groups.address.description")}
icon={<MapPinIcon className="size-5" />}
icon={<MapPinIcon className="size-6" />}
title={t("form_groups.address.title")}
variant="card"
>

View File

@ -34,7 +34,7 @@ export const CustomerBasicInfoEditor = ({
return (
<RdxPanel
description={t("form_groups.basic_info.description")}
icon={<FileTextIcon className="size-5" />}
icon={<FileTextIcon className="size-6" />}
title={t("form_groups.basic_info.title")}
variant="card"
>

View File

@ -25,7 +25,7 @@ export const CustomerContactEditor = ({
return (
<RdxPanel
description={t("form_groups.contact_info.description")}
icon={<PhoneIcon className="size-5" />}
icon={<PhoneIcon className="size-6" />}
title={t("form_groups.contact_info.title")}
variant="card"
>

View File

@ -29,7 +29,7 @@ export const CustomerFiscalEditor = ({
return (
<RdxPanel
description={t("form_groups.fiscal_info.description")}
icon={<LandmarkIcon className="size-5" />}
icon={<LandmarkIcon className="size-6" />}
title={t("form_groups.fiscal_info.title")}
variant="card"
>

View File

@ -60,7 +60,7 @@ export const SupplierEditorForm = ({
<form className="flex min-w-0 flex-col gap-6" id={formId} noValidate onSubmit={onSubmit}>
<RdxPanel
description={t("form.sections.identity.description")}
icon={<FileTextIcon className="size-5" />}
icon={<FileTextIcon className="size-6" />}
title={t("form.sections.identity.title")}
>
<RdxFormGrid className="md:grid-cols-2">
@ -109,7 +109,7 @@ export const SupplierEditorForm = ({
<RdxPanel
description={t("form.sections.address.description")}
icon={<MapPinIcon className="size-5" />}
icon={<MapPinIcon className="size-6" />}
title={t("form.sections.address.title")}
>
<RdxFormGrid className="md:grid-cols-2">
@ -129,7 +129,7 @@ export const SupplierEditorForm = ({
<RdxPanel
description={t("form.sections.contact.description")}
icon={<PhoneIcon className="size-5" />}
icon={<PhoneIcon className="size-6" />}
title={t("form.sections.contact.title")}
>
<RdxFormGrid className="md:grid-cols-2">
@ -172,7 +172,7 @@ export const SupplierEditorForm = ({
<RdxPanel
description={t("form.sections.preferences.description")}
icon={<LanguagesIcon className="size-5" />}
icon={<LanguagesIcon className="size-6" />}
title={t("form.sections.preferences.title")}
>
<RdxFormGrid className="md:grid-cols-2">

View File

@ -19,7 +19,9 @@
"./components": "./src/components/index.ts",
"./components/*": "./src/components/*.tsx",
"./locales/*": "./src/locales/*",
"./hooks": ["./src/hooks/index.ts"]
"./hooks": [
"./src/hooks/index.ts"
]
},
"devDependencies": {
"@biomejs/biome": "^2.4.11",
@ -36,22 +38,22 @@
"esbuild-plugin-react18-css": "^0.0.4",
"rimraf": "^6.1.3",
"tailwindcss": "^4.2.2",
"tsx": "^4.21.0",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.2",
"typescript-plugin-css-modules": "^5.2.0",
"vite-tsconfig-paths": "^6.1.1"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@base-ui/react": "1.8.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@repo/i18next": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
@ -67,4 +69,4 @@
"sonner": "^2.0.7",
"zod": "^4.3.6"
}
}
}

View File

@ -54,12 +54,12 @@ export const RdxPanel = ({
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
{icon ? (
<div className="flex size-6 shrink-0 items-center justify-center text-primary">
<div className="flex size-8 shrink-0 items-center justify-center text-primary">
{icon}
</div>
) : null}
<div className="min-w-0 space-y-1">
<div className="min-w-0 space-y-0.5">
<h2
className="min-w-0 text-base font-semibold tracking-tight text-foreground"
id={titleId}

View File

@ -393,7 +393,7 @@ export function PDFViewer({
contentClassName="space-y-3 px-2 sm:space-y-4 sm:px-4 md:space-y-5 md:px-6"
description={description}
headerClassName="px-2 sm:px-4 md:px-6"
icon={<EyeIcon className="size-5" />}
icon={<EyeIcon className="size-6" />}
title={title}
>
{viewerContent}

View File

@ -7,5 +7,5 @@ export const RdxBadge = ({
className,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) => (
<Badge className={cn("tracking-wide px-4 h-6 rounded-md", className)} {...props} />
<Badge className={cn("tracking-wide px-3 h-6 rounded-lg", className)} {...props} />
);

View File

@ -5,7 +5,7 @@
"tsx": true,
"tailwind": {
"config": "",
"css": "@repo/shadcn-ui/globals.css",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
@ -21,4 +21,4 @@
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle"
}
}

View File

@ -39,11 +39,10 @@
"typescript": "^6.0.2"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@base-ui/react": "^1.8.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-toggle-group": "^1.1.11",
"@shadcn/react": "^0.3.1",
"add": "^2.0.6",
"class-variance-authority": "^0.7.1",
@ -57,7 +56,6 @@
"lucide-react": "^1.8.0",
"next-themes": "^0.4.6",
"pnpm": "^10.33.0",
"radix-ui": "latest",
"react-day-picker": "10.0.1",
"react-hook-form": "^7.72.1",
"react-resizable-panels": "^4.9.0",
@ -66,7 +64,6 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^4.3.6"
}
}
}

View File

@ -1,6 +1,6 @@
"use client";
import { Label } from "@repo/shadcn-ui/components/label";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import {
@ -82,39 +82,55 @@ const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivEl
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
const FormLabel = React.forwardRef<HTMLLabelElement, useRender.ComponentProps<"label">>(
({ className, render, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
ref={ref}
{...props}
/>
);
});
return useRender({
defaultTagName: "label",
props: {
className: cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
error && "text-destructive",
className
),
...props,
htmlFor: formItemId,
},
ref,
render,
state: {
slot: "label",
},
});
}
);
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
type FormControlProps = Omit<useRender.ComponentProps<"div">, "children" | "render"> & {
children: React.ReactElement;
};
return (
<Slot
aria-describedby={error ? `${formDescriptionId} ${formMessageId}` : `${formDescriptionId}`}
aria-invalid={!!error}
id={formItemId}
ref={ref}
{...props}
/>
);
});
const FormControl = React.forwardRef<HTMLElement, FormControlProps>(
({ children, ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return useRender({
defaultTagName: "div",
props: {
"aria-describedby": error ? `${formDescriptionId} ${formMessageId}` : formDescriptionId,
"aria-invalid": !!error,
id: formItemId,
...props,
},
ref,
render: children,
state: {
slot: "form-control",
},
});
}
);
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<

View File

@ -6,6 +6,7 @@
@import "@fontsource-variable/geist-mono";
@custom-variant dark (&:is(.dark *));
@source "../../../../apps/**/*.{ts,tsx}";
@source "../../../../modules/**/*.{ts,tsx}";
@source "../../../../packages/**/*.{ts,tsx}";
@ -66,7 +67,7 @@
--font-serif: "Geist Variable", serif;
--font-mono: "Geist Mono Variable", monospace;
--radius: 0.625rem;
--radius: 0.45rem;
--shadow-2xs: 0 1px 3px 0px rgb(0 0 0 / 0.05);
--shadow-xs: 0 1px 3px 0px rgb(0 0 0 / 0.05);
@ -185,10 +186,13 @@
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
@ -212,4 +216,8 @@
[role="button"]:not(:disabled) {
cursor: pointer;
}
html {
@apply font-sans;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,104 @@
import { randomUUID } from "node:crypto";
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { createCustomerViaUi } from "../../customers/support/create-customer-via-ui";
import { loginViaUi } from "../../support/login-via-ui";
import { createProformaViaUi } from "./support/create-proforma-via-ui";
const selectCandidate = async (page: Page, customer: { id: string; search: string }) => {
await page.locator("#proforma-change-customer").click();
const responsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "GET" &&
/\/customers\/?$/.test(url.pathname) &&
url.searchParams.get("q") === customer.search
);
});
await page.getByRole("dialog").locator('input[inputmode="search"]').fill(customer.search);
const response = await responsePromise;
expect(response.status()).toBe(200);
await page.getByTestId(`customer-selection-${customer.id}`).click();
await page.locator("#customer-selection-confirm").click();
};
const confirmCandidate = async (page: Page) => {
await expect(page.locator("#proforma-customer-change-confirm")).toBeVisible();
await page.locator("#proforma-customer-change-confirm").click();
};
const save = async (page: Page, id: string) => {
const response = page.waitForResponse(
(value) =>
value.request().method() === "PUT" &&
new URL(value.url()).pathname.endsWith(`/proformas/${id}`)
);
await page.getByTestId("proforma-update-submit").click();
const result = await response;
expect(result.status()).toBe(200);
};
const returnToProforma = async (page: Page, id: string) => {
await page.goto(`/proformas/${id}/edit?returnTo=/proformas`);
await expect(page.locator("#proforma-change-customer")).toBeVisible();
};
test("CUSTOMER-INVOICES-PROFORMA-CUSTOMER-001: conserva el cliente seleccionado al crear", async ({
page,
}, testInfo) => {
await loginViaUi(page);
const proforma = await createProformaViaUi(page, testInfo);
await page.reload();
await expect(page.locator("#proforma-recipient-name")).toHaveText(proforma.customer.name);
});
test("CUSTOMER-INVOICES-PROFORMA-CUSTOMER-002: cambia de cliente y lo persiste", async ({
page,
}, testInfo) => {
await loginViaUi(page);
const proforma = await createProformaViaUi(page, testInfo);
const search = randomUUID().replaceAll("-", "");
const customer = await createCustomerViaUi(page, testInfo, `E2E Customer B ${search}`);
await returnToProforma(page, proforma.id);
await selectCandidate(page, { id: customer.id, search });
await confirmCandidate(page);
await expect(page.locator("#proforma-recipient-name")).toHaveText(customer.name);
await save(page, proforma.id);
await page.reload();
await expect(page.locator("#proforma-recipient-name")).toHaveText(customer.name);
});
test("CUSTOMER-INVOICES-PROFORMA-CUSTOMER-003: confirma el cambio y refresca el recipient", async ({
page,
}, testInfo) => {
await loginViaUi(page);
const proforma = await createProformaViaUi(page, testInfo);
const search = randomUUID().replaceAll("-", "");
const customer = await createCustomerViaUi(page, testInfo, `E2E Customer Derived ${search}`);
await returnToProforma(page, proforma.id);
await selectCandidate(page, { id: customer.id, search });
await confirmCandidate(page);
await expect(page.locator("#proforma-recipient-name")).toHaveText(customer.name);
await save(page, proforma.id);
await page.reload();
await expect(page.locator("#proforma-recipient-name")).toHaveText(customer.name);
});
test("CUSTOMER-INVOICES-PROFORMA-CUSTOMER-004: cancelar conserva el cliente anterior", async ({
page,
}, testInfo) => {
await loginViaUi(page);
const proforma = await createProformaViaUi(page, testInfo);
const search = randomUUID().replaceAll("-", "");
const customer = await createCustomerViaUi(page, testInfo, `E2E Customer Cancel ${search}`);
await returnToProforma(page, proforma.id);
await selectCandidate(page, { id: customer.id, search });
await expect(page.locator("#proforma-customer-change-cancel")).toBeVisible();
await page.locator("#proforma-customer-change-cancel").click();
await expect(page.locator("#proforma-recipient-name")).toHaveText(proforma.customer.name);
await page.reload();
await expect(page.locator("#proforma-recipient-name")).toHaveText(proforma.customer.name);
});