Skip to content

Commit 3481344

Browse files
authored
Merge pull request #5639 from HHS/OPS-currency-input-decimal-fix
2 parents f828a33 + a63add5 commit 3481344

4 files changed

Lines changed: 176 additions & 5 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,5 @@ docs/openapi_reports/yamllint_*
264264

265265
*storybook.log
266266
storybook-static
267+
268+
.worktrees/

frontend/cypress/e2e/createAgreement.cy.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,39 @@ it("should handle cancelling out of workflow on step 2", () => {
366366
});
367367

368368
// TODO: Add test for cancelling out of workflow on step 3
369+
370+
it("allows entering a decimal budget line amount", () => {
371+
// Step One - Select a Project
372+
cy.get("#project-combobox-input").type("Human Services Interoperability Support{enter}");
373+
cy.get("#continue").click();
374+
375+
// Step Two - Create an Agreement
376+
cy.get("dt").should("contain", "Project");
377+
cy.get("#agreement-type-filter").select("CONTRACT");
378+
cy.get("#name").type("E2E Decimal Amount Test");
379+
cy.get("#contract-type").select("FIRM_FIXED_PRICE");
380+
cy.get("#service_requirement_type").select("SEVERABLE");
381+
cy.get("#description").type("Test Agreement Description");
382+
cy.get("#product_service_code_id").select("Other Scientific and Technical Consulting Services");
383+
cy.get("#agreement_reason").select("NEW_REQ");
384+
cy.get("#project-officer-combobox-input").type("Chris Fortunato{enter}");
385+
cy.get("[data-cy='continue-btn']").click();
386+
387+
// Step Three - Add Services Component and Budget Line
388+
cy.get("#servicesComponentSelect").select("1");
389+
cy.get("#pop-start-date").type("01/01/2024");
390+
cy.get("#pop-end-date").type("01/01/2025");
391+
cy.get("#description").type("This is a description.");
392+
cy.get("[data-cy='add-services-component-btn']").click();
393+
cy.get("h2").should("contain", "Base Period 1");
394+
395+
// Add a budget line item with a decimal amount
396+
cy.get("#allServicesComponentSelect").select("Base Period 1");
397+
cy.get("#need-by-date").type("01/01/2030");
398+
cy.get("#can-combobox-input").type("G99MVT3{enter}");
399+
cy.get("#enteredAmount").clear().type("500.75");
400+
401+
// The decimal value should be accepted and retained by the input.
402+
// No cleanup needed — the test stops before submitting, so no agreement is created.
403+
cy.get("#enteredAmount").should("have.value", "500.75");
404+
});

frontend/src/components/UI/Form/CurrencyInput/CurrencyInput.jsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import cx from "clsx";
2+
import { useState, useEffect, useRef } from "react";
23
import CurrencyFormat from "react-currency-format";
34

45
/**
@@ -28,6 +29,25 @@ const CurrencyInput = ({
2829
placeholder = "$",
2930
...rest
3031
}) => {
32+
// displayValue holds the raw formatted string (e.g. "5.") so a trailing
33+
// decimal isn't stripped before the user finishes typing the cents.
34+
const [displayValue, setDisplayValue] = useState(value ?? "");
35+
// Set to true on each user keystroke so the useEffect skips the parent's
36+
// round-trip echo and doesn't overwrite the in-progress display string.
37+
const skipNextSyncRef = useRef(false);
38+
39+
useEffect(() => {
40+
if (skipNextSyncRef.current) {
41+
skipNextSyncRef.current = false;
42+
return;
43+
}
44+
setDisplayValue(value ?? "");
45+
}, [value]);
46+
47+
function handleChange(e) {
48+
onChange(name, e.target.value);
49+
}
50+
3151
return (
3252
<div className={cx("usa-form-group", pending && "pending", className)}>
3353
<label
@@ -47,14 +67,15 @@ const CurrencyInput = ({
4767
<CurrencyFormat
4868
id={name}
4969
name={name}
50-
value={value}
70+
value={displayValue}
5171
className={`usa-input ${messages.length ? "usa-input--error" : ""} `}
5272
thousandSeparator={true}
5373
decimalScale={2}
5474
placeholder={placeholder}
5575
onValueChange={(values) => {
76+
skipNextSyncRef.current = true;
77+
setDisplayValue(values.value);
5678
const { floatValue } = values;
57-
// Explicitly check if floatValue is a number (including 0)
5879
if (setEnteredAmount) {
5980
setEnteredAmount(typeof floatValue === "number" ? floatValue : null);
6081
}
@@ -64,9 +85,6 @@ const CurrencyInput = ({
6485
/>
6586
</div>
6687
);
67-
function handleChange(e) {
68-
onChange(name, e.target.value);
69-
}
7088
};
7189

7290
export default CurrencyInput;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { vi } from "vitest";
4+
import "@testing-library/jest-dom";
5+
import CurrencyInput from "./CurrencyInput";
6+
7+
describe("CurrencyInput", () => {
8+
it("allows typing a decimal point mid-entry", async () => {
9+
const setEnteredAmount = vi.fn();
10+
const onChange = vi.fn();
11+
12+
render(
13+
<CurrencyInput
14+
name="amount"
15+
value=""
16+
setEnteredAmount={setEnteredAmount}
17+
onChange={onChange}
18+
/>
19+
);
20+
21+
const input = screen.getByRole("textbox");
22+
await userEvent.type(input, "5.");
23+
24+
// The input should visually show the decimal (not strip it)
25+
expect(input).toHaveDisplayValue(/5\./);
26+
});
27+
28+
it("calls setEnteredAmount with the float value on change", async () => {
29+
const setEnteredAmount = vi.fn();
30+
const onChange = vi.fn();
31+
32+
render(
33+
<CurrencyInput
34+
name="amount"
35+
value=""
36+
setEnteredAmount={setEnteredAmount}
37+
onChange={onChange}
38+
/>
39+
);
40+
41+
const input = screen.getByRole("textbox");
42+
await userEvent.type(input, "5.50");
43+
44+
expect(setEnteredAmount).toHaveBeenLastCalledWith(5.5);
45+
});
46+
47+
it("does not strip a trailing decimal when the parent re-renders with the same float", async () => {
48+
const setEnteredAmount = vi.fn();
49+
const onChange = vi.fn();
50+
51+
const { rerender } = render(
52+
<CurrencyInput
53+
name="amount"
54+
value=""
55+
setEnteredAmount={setEnteredAmount}
56+
onChange={onChange}
57+
/>
58+
);
59+
60+
const input = screen.getByRole("textbox");
61+
await userEvent.type(input, "5.");
62+
63+
// userEvent.type flushes all effects before returning, so skipNextSyncRef.current
64+
// is true when rerender delivers value={5}. The effect fires, sees the flag, and
65+
// skips the sync — preserving the trailing decimal.
66+
rerender(
67+
<CurrencyInput
68+
name="amount"
69+
value={5}
70+
setEnteredAmount={setEnteredAmount}
71+
onChange={onChange}
72+
/>
73+
);
74+
75+
expect(input).toHaveDisplayValue(/5\./);
76+
77+
// Simulate the parent then pushing a genuinely new value (no user typing).
78+
// The guard should allow this through and update the display.
79+
rerender(
80+
<CurrencyInput
81+
name="amount"
82+
value={7}
83+
setEnteredAmount={setEnteredAmount}
84+
onChange={onChange}
85+
/>
86+
);
87+
88+
expect(input).toHaveDisplayValue("7");
89+
});
90+
91+
it("clears the input when parent resets value to empty", () => {
92+
const setEnteredAmount = vi.fn();
93+
const onChange = vi.fn();
94+
95+
const { rerender } = render(
96+
<CurrencyInput
97+
name="amount"
98+
value={500}
99+
setEnteredAmount={setEnteredAmount}
100+
onChange={onChange}
101+
/>
102+
);
103+
104+
rerender(
105+
<CurrencyInput
106+
name="amount"
107+
value=""
108+
setEnteredAmount={setEnteredAmount}
109+
onChange={onChange}
110+
/>
111+
);
112+
113+
expect(screen.getByRole("textbox")).toHaveDisplayValue("");
114+
});
115+
});

0 commit comments

Comments
 (0)