From beb74c509c4b4ebc34b869bc1f7a9ee1bf981193 Mon Sep 17 00:00:00 2001 From: Felipe de Sousa Date: Wed, 23 Sep 2026 11:57:13 -0300 Subject: [PATCH] feat: replace fixed installment dropdown with free numeric input (1-120) --- components/dashboard/transaction-form.tsx | 36 +++++----- tests/dashboard/transaction-form.test.tsx | 85 ++++++++++++++++------- 2 files changed, 81 insertions(+), 40 deletions(-) diff --git a/components/dashboard/transaction-form.tsx b/components/dashboard/transaction-form.tsx index 6ee9c4b8..42b79d23 100644 --- a/components/dashboard/transaction-form.tsx +++ b/components/dashboard/transaction-form.tsx @@ -8,7 +8,6 @@ import { toast } from "sonner"; import { NewCategoryDialog } from "@/components/dashboard/new-category-dialog"; import { CompactInput } from "@/components/dashboard/form-inputs/compact-input"; -import { CompactSelect } from "@/components/dashboard/form-inputs/compact-select"; import { CompactTextarea } from "@/components/dashboard/form-inputs/compact-textarea"; import { CurrencyInput } from "@/components/dashboard/form-inputs/currency-input"; import { withSelectedMonth } from "@/components/dashboard/month-route"; @@ -335,17 +334,16 @@ function usePaymentMethodInstallmentEligibility({ return { canInstallment, handlePaymentMethodChange }; } -const INSTALLMENT_COUNTS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 24]; - const SECTION_LABEL_CLASS = "mb-2.5 block text-xs font-medium uppercase tracking-widest text-muted-foreground"; const CHIP_BASE_CLASS = "flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium transition-all duration-200 cursor-pointer max-w-full"; const CHIP_ADD_ACTION_CLASS = "border-dashed border-border/40 text-foreground/50 hover:border-border/60 hover:text-foreground/70"; -function getInstallmentOptions(t: (key: string) => string) { - return [ - { value: "1", label: t("transaction.installmentOption.full") }, - ...INSTALLMENT_COUNTS.map((count) => ({ value: String(count), label: `${count}x` })), - ]; +const MAX_INSTALLMENT_COUNT = 120; + +function clampInstallmentCount(rawValue: string) { + const parsed = Number.parseInt(rawValue, 10); + if (Number.isNaN(parsed)) return 1; + return Math.min(MAX_INSTALLMENT_COUNT, Math.max(1, parsed)); } function getDisplayedCategoryOptions({ @@ -586,7 +584,6 @@ function TransactionPaymentMethodChips({ type TransactionDateAndInstallmentsProps = TransactionFormFieldProps & { readonly error: string | undefined; readonly canInstallment: boolean; - readonly installmentOptions: ReturnType; readonly todayDateInputValue: string; readonly t: (key: string) => string; }; @@ -596,7 +593,6 @@ function TransactionDateAndInstallments({ setFormData, error, canInstallment, - installmentOptions, todayDateInputValue, t, }: TransactionDateAndInstallmentsProps) { @@ -615,12 +611,20 @@ function TransactionDateAndInstallments({ inputClassName="w-full max-w-full min-w-0 appearance-none overflow-hidden pr-3 text-left [&::-webkit-calendar-picker-indicator]:shrink-0 [&::-webkit-date-and-time-value]:min-w-0 [&::-webkit-date-and-time-value]:overflow-hidden [&::-webkit-date-and-time-value]:text-left" /> {canInstallment ? ( - setFormData({ ...formData, installmentCount: Number.parseInt(value, 10) })} - options={installmentOptions} + type="number" + inputMode="numeric" + min="1" + max={MAX_INSTALLMENT_COUNT.toString()} + value={formData.installmentCount} + onChange={(event) => + setFormData({ + ...formData, + installmentCount: clampInstallmentCount(event.target.value), + }) + } /> ) : null} @@ -813,7 +817,7 @@ function useTransactionFormHandlers({ t, }); - return { handleSubmit, canInstallment, handlePaymentMethodChange, installmentOptions: getInstallmentOptions(t) }; + return { handleSubmit, canInstallment, handlePaymentMethodChange }; } function useTransactionFormState({ diff --git a/tests/dashboard/transaction-form.test.tsx b/tests/dashboard/transaction-form.test.tsx index ab43a9e7..2d9b866d 100644 --- a/tests/dashboard/transaction-form.test.tsx +++ b/tests/dashboard/transaction-form.test.tsx @@ -1,5 +1,4 @@ -import { fireEvent, render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { fireEvent, render, screen } from "@testing-library/react"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -50,8 +49,7 @@ const translations: Record = { "transaction.paymentMethod": "Payment method", "transaction.addPaymentMethod": "Add payment method", "transaction.date": "Date", - "transaction.installmentFrequency": "Installments", - "transaction.installmentOption.full": "Full payment", + "transaction.installmentCount": "Number of installments", "transaction.notes": "Notes", "transaction.notesPlaceholder": "Add a note", "transaction.saving": "Saving...", @@ -344,48 +342,87 @@ describe("handlePaymentMethodChange defensive fallback", () => { }); describe("installment eligibility across payment method types", () => { - it("shows the installment select for credit/boleto/label-contains-boleto methods and hides it for debit, resetting the count on non-installment methods", async () => { - const user = userEvent.setup(); + const installmentInput = () => + screen.getByLabelText("Number of installments") as HTMLInputElement; + + it("shows the installment input for credit/boleto/label-contains-boleto methods and hides it for debit, resetting the count on non-installment methods", () => { render(); - // Default payment method is debit -> no installment select. - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + // Default payment method is debit -> no installment input. + expect(screen.queryByLabelText("Number of installments")).not.toBeInTheDocument(); - // Switch to credit -> select appears (type === "credit" branch). + // Switch to credit -> input appears (type === "credit" branch). fireEvent.click(screen.getByText("Credit Card")); - expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(installmentInput().value).toBe("1"); - // Bump installment count to 3x while on the credit method. - await user.click(screen.getByRole("combobox")); - const listbox = await screen.findByRole("listbox"); - await user.click(within(listbox).getByText("3x")); - expect(screen.getByRole("combobox")).toHaveTextContent("3x"); + // Bump installment count to 3 while on the credit method. + fireEvent.change(installmentInput(), { target: { value: "3" } }); + expect(installmentInput().value).toBe("3"); // Re-selecting an installment-capable method preserves the count. fireEvent.click(screen.getByText("Boleto")); - expect(screen.getByRole("combobox")).toHaveTextContent("3x"); + expect(installmentInput().value).toBe("3"); // Label-contains-"boleto" branch: type is "other" but label includes "boleto". fireEvent.click(screen.getByText("Store Boleto Plan")); - expect(screen.getByRole("combobox")).toHaveTextContent("3x"); + expect(installmentInput().value).toBe("3"); - // Switching to a non-installment method hides the select and resets the count. + // Switching to a non-installment method hides the input and resets the count. fireEvent.click(screen.getByText("Debit Card")); - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Number of installments")).not.toBeInTheDocument(); - // Switching back to credit confirms the count was reset to 1 ("Full payment"). + // Switching back to credit confirms the count was reset to 1 (lump sum). fireEvent.click(screen.getByText("Credit Card")); - expect(screen.getByRole("combobox")).toHaveTextContent("Full payment"); + expect(installmentInput().value).toBe("1"); }); - it("hides the installment select outside of expense type even with a credit payment method", () => { + it("hides the installment input outside of expense type even with a credit payment method", () => { render(); fireEvent.click(screen.getByText("Credit Card")); - expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByLabelText("Number of installments")).toBeInTheDocument(); fireEvent.click(screen.getByText("Saving")); - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Number of installments")).not.toBeInTheDocument(); + }); + + it("accepts a value above the old 24-installment ceiling, e.g. 36", () => { + render(); + fireEvent.click(screen.getByText("Credit Card")); + + fireEvent.change(installmentInput(), { target: { value: "36" } }); + expect(installmentInput().value).toBe("36"); + }); + + it("clamps a value above the 120-installment cap down to 120", () => { + render(); + fireEvent.click(screen.getByText("Credit Card")); + + fireEvent.change(installmentInput(), { target: { value: "121" } }); + expect(installmentInput().value).toBe("120"); + }); + + it("falls back to 1 for non-numeric or empty input instead of crashing or producing NaN", () => { + render(); + fireEvent.click(screen.getByText("Credit Card")); + + fireEvent.change(installmentInput(), { target: { value: "abc" } }); + expect(installmentInput().value).toBe("1"); + + fireEvent.change(installmentInput(), { target: { value: "3" } }); + fireEvent.change(installmentInput(), { target: { value: "" } }); + expect(installmentInput().value).toBe("1"); + }); + + it("clamps a zero or negative value up to 1", () => { + render(); + fireEvent.click(screen.getByText("Credit Card")); + + fireEvent.change(installmentInput(), { target: { value: "0" } }); + expect(installmentInput().value).toBe("1"); + + fireEvent.change(installmentInput(), { target: { value: "-5" } }); + expect(installmentInput().value).toBe("1"); }); });