diff --git a/package.json b/package.json index e9a61ed21..c12d63a11 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "react-star-ratings": "^2.3.0", "react-switch": "^6.0.0", "react-tooltip": "^5.28.0", + "react-window": "^1.8.10", "redux": "^3.7.2", "redux-persist": "^5.10.0", "redux-thunk": "^2.3.0", diff --git a/src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js b/src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js index 05cf847f7..2d06f4545 100644 --- a/src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js +++ b/src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js @@ -1,8 +1,40 @@ import React from "react"; -import { render, fireEvent, screen } from "@testing-library/react"; +import { render, fireEvent, screen, within } from "@testing-library/react"; import PromocodeForm from "../index"; +// jsdom has no layout, so react-window's FixedSizeList won't render rows. +// The modal under test uses it for the working-list view; mock it like the +// manage-modal unit test does so the integration test can read the rows. +jest.mock("react-window", () => { + // eslint-disable-next-line global-require + const React = require("react"); + return { + __esModule: true, + FixedSizeList: React.forwardRef( + ({ itemCount, itemData, children }, ref) => { + const Row = children; + const visible = Math.min(itemCount, 10); + if (ref) { + ref.current = { scrollToItem: jest.fn() }; + } + return React.createElement( + "div", + { "data-testid": "fixed-size-list" }, + Array.from({ length: visible }, (_, i) => + React.createElement(Row, { + key: i, + index: i, + style: {}, + data: itemData + }) + ) + ); + } + ) + }; +}); + // jsdom does not implement scrollIntoView; polyfill so componentDidUpdate // (which calls scrollToError → firstNode.scrollIntoView) does not throw. window.HTMLElement.prototype.scrollIntoView = jest.fn(); @@ -489,6 +521,43 @@ describe("validate() — domain-authorized email-domain enforcement", () => { getByIdSpy.mockRestore(); }); + it("scrolls to the compact-mode wrapper when allowed_email_domains has > 50 entries (Codex A-fix regression)", () => { + // Codex pass-2 minor finding: A-fix added id="allowed_email_domains" to the + // compact wrapper, but no integration test exercised the FULL scrollToError + // path with > 50 entries. This test does. If a future refactor changes + // where scrollToError looks up the field, the unit tests at lines :545-:568 + // still cover the static id presence — this test pins the end-to-end + // validate() → scrollToError → document.getElementById path in compact mode. + const getByIdSpy = jest.spyOn(document, "getElementById"); + // 60 entries (> LARGE_DOMAIN_LIST_THRESHOLD=50) where at least one is + // malformed, so validate() fails and scrollToError fires. + const compactEntries = Array.from({ length: 59 }, (_, i) => `@e${i}.com`); + compactEntries.push("malformed"); + const { container } = renderForm( + baseEntity({ + class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE", + allowed_email_domains: compactEntries + }) + ); + // Sanity-check we're in compact mode (compact-summary-count present — + // Manage List button is threshold-independent post-Tier 1.1 and no longer + // a valid compact-mode probe). + expect( + container.querySelector("[data-testid='compact-summary-count']") + ).toBeInTheDocument(); + getByIdSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + // scrollToError must have looked up the compact wrapper by id. + expect(getByIdSpy).toHaveBeenCalledWith("allowed_email_domains"); + // And the lookup must have resolved (not returned null) — proves the id + // is actually present in the rendered DOM at large N. + const calls = getByIdSpy.mock.results.filter( + (r) => r.type === "return" && r.value !== null + ); + expect(calls.length).toBeGreaterThan(0); + getByIdSpy.mockRestore(); + }); + it("clears the .text-danger banner on plain typing after a failed save (regression — Codex review)", () => { const { container } = renderForm( baseEntity({ @@ -697,3 +766,100 @@ describe("DiscountBasePCForm — Apply to all Ticket Types audience restriction expect(helper.textContent).toBe(HELPER_KEY); }); }); + +describe("PromocodeForm — DOMAIN_AUTHORIZED bulk input (Tier 1)", () => { + // End-to-end through the row → modal → onApply → form state → onSubmit + // path. Does NOT mock the modal (unlike the row unit test) — exercises + // the actual ManageAllowedEmailDomainsModal component, which is why the + // file-level react-window mock is required (jsdom can't virtualize). + it("60 existing domains → compact summary → modal paste → Done → entity reflects merge", () => { + const seed = Array.from({ length: 60 }, (_, i) => `@e${i}.com`); + const onSubmit = jest.fn(); + const { container } = renderForm( + baseEntity({ + class_name: "DOMAIN_AUTHORIZED_PROMO_CODE", + allowed_email_domains: seed + }), + { onSubmit } + ); + + // Above LARGE_DOMAIN_LIST_THRESHOLD (50) — chip wall is replaced by + // the compact summary + Manage List button. + expect( + container.querySelector("[data-testid='compact-summary-count']") + ).toBeInTheDocument(); + expect( + container.querySelector("[data-testid='domain-chip-@e0.com']") + ).not.toBeInTheDocument(); + expect( + container.querySelector("[data-testid='manage-list-button']") + ).toBeInTheDocument(); + + // Open the modal. + fireEvent.click( + container.querySelector("[data-testid='manage-list-button']") + ); + expect(screen.getByTestId("manage-modal-textarea")).toBeInTheDocument(); + + // Paste mix: 3 valid net-new, 1 invalid, 1 dup-of-existing. + fireEvent.change(screen.getByTestId("manage-modal-textarea"), { + target: { + value: "@new1.com\n@new2.com\n@e0.com\nnot-a-domain\n@new3.com" + } + }); + fireEvent.click( + screen.getByRole("button", { + name: "edit_promocode.manage_modal.add_button" + }) + ); + + // Toast renders the raw i18n key in jest env (no translator); the + // interpolated numbers are not visible. Verify the tally via the + // working-list row count instead: 60 existing + 3 new = 63. + expect(screen.getByTestId("manage-modal-toast")).toHaveTextContent( + "edit_promocode.manage_modal.added_toast" + ); + // FixedSizeList mock caps visible rows at 10; assert all 10 render. + const modalList = screen.getByTestId("fixed-size-list"); + expect(within(modalList).getAllByTestId(/manage-modal-row-/)).toHaveLength( + 10 + ); + + // Commit via Done — onApply fires up through fireChange, parent + // handleChange updates entity.allowed_email_domains. + fireEvent.click( + screen.getByRole("button", { name: "edit_promocode.manage_modal.done" }) + ); + + // The form's internal entity state should now hold the merged array. + // Trigger Save and inspect the entity passed to onSubmit. (react-bootstrap + // 0.31's animated Modal can stay mounted in jsdom because the exit + // transition never resolves, so the modal's buttons remain in the + // accessibility tree and shadow the form's Save. Query the Save input + // directly via its DOM selector instead.) + const saveBtn = container.querySelector( + "input[type=\"button\"].btn.btn-primary.pull-right" + ); + expect(saveBtn).toBeInTheDocument(); + fireEvent.click(saveBtn); + + expect(onSubmit).toHaveBeenCalledTimes(1); + const submittedEntity = onSubmit.mock.calls[0][0]; + expect(submittedEntity.allowed_email_domains).toHaveLength(63); + // Original entries preserved. + expect(submittedEntity.allowed_email_domains).toEqual( + expect.arrayContaining(["@e0.com", "@e59.com"]) + ); + // New entries appended. + expect(submittedEntity.allowed_email_domains).toEqual( + expect.arrayContaining(["@new1.com", "@new2.com", "@new3.com"]) + ); + // Invalid + dup NOT present. + expect(submittedEntity.allowed_email_domains).not.toContain("not-a-domain"); + // No duplicate @e0.com (still only one instance). + const e0count = submittedEntity.allowed_email_domains.filter( + (d) => d === "@e0.com" + ).length; + expect(e0count).toBe(1); + }); +}); diff --git a/src/components/forms/promocode-form/forms/domain-authorized/AllowedEmailDomainsRow.jsx b/src/components/forms/promocode-form/forms/domain-authorized/AllowedEmailDomainsRow.jsx index 1a92557d6..ec2c0248c 100644 --- a/src/components/forms/promocode-form/forms/domain-authorized/AllowedEmailDomainsRow.jsx +++ b/src/components/forms/promocode-form/forms/domain-authorized/AllowedEmailDomainsRow.jsx @@ -2,6 +2,14 @@ import React, { useState } from "react"; import T from "i18n-react"; import { validateAllowedEmailDomainEntry } from "../../../../../utils/methods"; import { fireChange } from "./utils"; +import { LARGE_DOMAIN_LIST_THRESHOLD } from "./bulk-input-parser"; +import ManageAllowedEmailDomainsModal from "./ManageAllowedEmailDomainsModal"; + +const typeOf = (entry) => { + if (entry.startsWith("@")) return "atDomain"; + if (entry.startsWith(".")) return "tld"; + return "email"; +}; // Freeform chip input. Deliberately NOT openstack-uicore-foundation's // TagInput: that component wraps react-select's AsyncCreatable and fires @@ -14,6 +22,7 @@ const AllowedEmailDomainsRow = ({ }) => { const [draft, setDraft] = useState(""); const [domainsError, setDomainsError] = useState(""); + const [manageOpen, setManageOpen] = useState(false); const domains = Array.isArray(entity.allowed_email_domains) ? entity.allowed_email_domains @@ -34,7 +43,11 @@ const AllowedEmailDomainsRow = ({ ); return; } - if (!domains.includes(trimmed)) { + const lowerTrimmed = trimmed.toLowerCase(); + const alreadyPresent = domains.some( + (d) => d.toLowerCase() === lowerTrimmed + ); + if (!alreadyPresent) { fireChange(handleChange, "allowed_email_domains", [...domains, trimmed]); } setDraft(""); @@ -63,113 +76,204 @@ const AllowedEmailDomainsRow = ({ if (draft.length > 0) commit(draft); }; + // Single-entry inline `+ add one` input. Declared once, mounted in BOTH + // branches (chip wall + compact summary) so the small-add affordance stays + // available at every list size — SDS L58/L69/L100/L186. + const inlineAddInput = ( + { + setDraft(e.target.value); + setDomainsError(""); + // Parallel-clear the parent validate()-path error so the + // .text-danger banner disappears as soon as the user starts + // editing — same UX every other field gets via index.js:114 + // (newErrors[id] = "" inside handleChange). The chip input's + // onChange normally doesn't bubble to the parent (typing + // updates local draft only), so we synthesize a no-op array + // change to trigger the parent reset. Only fires when a + // parent error is currently set, so the cost (one extra + // setState) is paid only after a failed Save. + if (hasErrors("allowed_email_domains")) { + fireChange(handleChange, "allowed_email_domains", domains); + } + }} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + style={{ + border: 0, + outline: "none", + flex: 1, + minWidth: 120, + background: "transparent", + padding: 0 + }} + /> + ); + return ( -
-
-