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 (
-
-
-
-
- {domains.map((d, idx) => (
-
+
+
+
+
+
+
+ {domains.length > LARGE_DOMAIN_LIST_THRESHOLD ? (
+
- {d}
-
+ ) : (
+
+ {domains.map((d, idx) => (
+
+ {d}
+
+
+ ))}
+ {inlineAddInput}
+
+ )}
+
+ {T.translate("edit_promocode.captions.allowed_email_domains")}
+
+ {renderedError && (
+
+ {renderedError}
+
+ )}
-
- {T.translate("edit_promocode.captions.allowed_email_domains")}
-
- {renderedError && (
-
- {renderedError}
-
- )}
-
+
setManageOpen(false)}
+ onApply={(next) => {
+ setDomainsError("");
+ setDraft("");
+ fireChange(handleChange, "allowed_email_domains", next);
+ }}
+ existing={domains}
+ />
+ >
);
};
diff --git a/src/components/forms/promocode-form/forms/domain-authorized/ManageAllowedEmailDomainsModal.jsx b/src/components/forms/promocode-form/forms/domain-authorized/ManageAllowedEmailDomainsModal.jsx
new file mode 100644
index 000000000..1f6184f34
--- /dev/null
+++ b/src/components/forms/promocode-form/forms/domain-authorized/ManageAllowedEmailDomainsModal.jsx
@@ -0,0 +1,173 @@
+import React, {
+ useState,
+ useEffect,
+ useMemo,
+ useRef,
+ useCallback
+} from "react";
+import { Modal } from "react-bootstrap";
+import { FixedSizeList } from "react-window";
+import T from "i18n-react";
+import { parseTextBlob, classifyEntries } from "./bulk-input-parser";
+
+const ROW_HEIGHT = 32;
+const LIST_HEIGHT = 320;
+
+const Row = React.memo(({ index, style, data }) => {
+ const entry = data[index];
+ return (
+
+ {entry}
+
+ );
+});
+
+const ManageAllowedEmailDomainsModal = ({
+ show,
+ onHide,
+ onApply,
+ existing
+}) => {
+ const [working, setWorking] = useState([]);
+ const [draftText, setDraftText] = useState("");
+ const [toast, setToast] = useState(null);
+ const listRef = useRef(null);
+
+ // Intentionally depend only on `show`: snapshot `existing` when the modal opens
+ // and ignore subsequent prop changes — modal owns the working copy until Done/Cancel.
+ useEffect(() => {
+ if (show) {
+ setWorking(Array.isArray(existing) ? [...existing] : []);
+ setDraftText("");
+ setToast(null);
+ }
+ }, [show]);
+
+ const handleAddDomains = useCallback(() => {
+ const rows = parseTextBlob(draftText);
+ if (rows.length === 0) return;
+
+ const classified = classifyEntries({ raw: rows, existing: working });
+ const additions = classified.valid.map((v) => v.normalized);
+ const next = [...working, ...additions];
+
+ setWorking(next);
+ setToast({
+ added: classified.valid.length,
+ invalid: classified.invalid.length,
+ dup: classified.dupExisting.length + classified.dupInput.length
+ });
+ setDraftText("");
+
+ if (listRef.current && next.length > 0) {
+ listRef.current.scrollToItem(next.length - 1, "end");
+ }
+ }, [draftText, working]);
+
+ const handleKeyDown = (ev) => {
+ if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey)) {
+ ev.preventDefault();
+ handleAddDomains();
+ }
+ };
+
+ const handleDone = () => {
+ onApply(working);
+ onHide();
+ };
+
+ const handleCancel = () => {
+ onHide();
+ };
+
+ const toastText = useMemo(() => {
+ if (!toast) return null;
+ return T.translate("edit_promocode.manage_modal.added_toast", {
+ added: toast.added,
+ invalid: toast.invalid,
+ dup: toast.dup
+ });
+ }, [toast]);
+
+ const countText = T.translate(
+ "edit_promocode.manage_modal.configured_count",
+ {
+ n: working.length
+ }
+ );
+
+ return (
+
+
+
+ {T.translate("edit_promocode.manage_modal.title")}
+
+
+
+
+
+ {toastText && (
+
+ {toastText}
+
+ )}
+
+ {countText}
+
+
+ {Row}
+
+
+
+
+
+
+
+ );
+};
+
+export default ManageAllowedEmailDomainsModal;
diff --git a/src/components/forms/promocode-form/forms/domain-authorized/__tests__/allowed-email-domains-row.test.jsx b/src/components/forms/promocode-form/forms/domain-authorized/__tests__/allowed-email-domains-row.test.jsx
index c0b51fa7c..3b6bedeb6 100644
--- a/src/components/forms/promocode-form/forms/domain-authorized/__tests__/allowed-email-domains-row.test.jsx
+++ b/src/components/forms/promocode-form/forms/domain-authorized/__tests__/allowed-email-domains-row.test.jsx
@@ -2,6 +2,32 @@ import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import AllowedEmailDomainsRow from "../AllowedEmailDomainsRow";
+jest.mock("../ManageAllowedEmailDomainsModal", () => ({
+ __esModule: true,
+ default: ({ show, onApply, onHide, existing }) =>
+ show ? (
+
+
+ {JSON.stringify(existing)}
+
+
+
+
+ ) : null
+}));
+
const baseEntity = (overrides = {}) => ({
allowed_email_domains: [],
...overrides
@@ -301,3 +327,271 @@ describe("AllowedEmailDomainsRow", () => {
).toBe("");
});
});
+
+describe("AllowedEmailDomainsRow — compact summary + modal", () => {
+ it("renders chip wall when domains.length ≤ 50, with Manage List button always present (Tier 1.1)", () => {
+ const small = Array.from({ length: 50 }, (_, i) => `@e${i}.com`);
+ const { container } = render(
+ {}}
+ />
+ );
+ expect(
+ container.querySelector("[data-testid='domain-chip-@e0.com']")
+ ).toBeInTheDocument();
+ // Tier 1.1: Manage List button is threshold-independent; present at all counts.
+ expect(
+ container.querySelector("[data-testid='manage-list-button']")
+ ).toBeInTheDocument();
+ // Compact summary must NOT be visible at ≤ 50 — chip wall owns this range.
+ expect(
+ container.querySelector("[data-testid='compact-summary-count']")
+ ).not.toBeInTheDocument();
+ });
+
+ it("Manage List button present at count=0 (Tier 1.1 — empty-form bulk-paste entry)", () => {
+ const { container } = render(
+ {}}
+ />
+ );
+ expect(
+ container.querySelector("[data-testid='manage-list-button']")
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector("[data-testid='compact-summary-count']")
+ ).not.toBeInTheDocument();
+ });
+
+ it("boundary: chip wall at length=50, compact summary at length=51 (pins > vs >=)", () => {
+ const exactly50 = Array.from({ length: 50 }, (_, i) => `@e${i}.com`);
+ const { container: c50, unmount } = render(
+ {}}
+ />
+ );
+ // Probe compact summary, not Manage List button (button is threshold-independent post-Tier 1.1).
+ expect(
+ c50.querySelector("[data-testid='compact-summary-count']")
+ ).not.toBeInTheDocument();
+ expect(
+ c50.querySelector("[data-testid='domain-chip-@e0.com']")
+ ).toBeInTheDocument();
+ unmount();
+
+ const exactly51 = Array.from({ length: 51 }, (_, i) => `@e${i}.com`);
+ const { container: c51 } = render(
+ {}}
+ />
+ );
+ expect(
+ c51.querySelector("[data-testid='compact-summary-count']")
+ ).toBeInTheDocument();
+ expect(
+ c51.querySelector("[data-testid='domain-chip-@e0.com']")
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders compact summary when domains.length > 50 (Manage List button is always present)", () => {
+ const big = Array.from({ length: 60 }, (_, i) => `@e${i}.com`);
+ const { container } = render(
+ {}}
+ />
+ );
+ expect(
+ container.querySelector("[data-testid='domain-chip-@e0.com']")
+ ).not.toBeInTheDocument();
+ // i18n-react renders raw keys in jest env; the {n} interpolation is
+ // not visible, so we assert on the key wiring + button presence.
+ expect(
+ container.querySelector("[data-testid='compact-summary-count']")
+ ).toHaveTextContent("edit_promocode.large_list.summary_count");
+ expect(
+ container.querySelector("[data-testid='manage-list-button']")
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector("[data-testid='manage-list-button']")
+ ).toHaveTextContent("edit_promocode.manage_button");
+ });
+
+ it("type-mix hint counts @domain / .tld / user@email correctly", () => {
+ // Total must exceed LARGE_DOMAIN_LIST_THRESHOLD (50) to render compact mode.
+ const mix = [
+ ...Array.from({ length: 46 }, (_, i) => `@d${i}.com`),
+ ".edu",
+ ".gov",
+ "user@example.com",
+ "x@y.com",
+ "z@w.com"
+ ];
+ const { container } = render(
+ {}}
+ />
+ );
+ const mixHint = container.querySelector(
+ "[data-testid='compact-summary-type-mix']"
+ );
+ // Raw key in jest env (no translator); the per-category numbers (46,
+ // 2, 3) are passed as i18n params and only visible at runtime. Pin
+ // the key wiring here.
+ expect(mixHint).toHaveTextContent(
+ "edit_promocode.large_list.summary_type_mix"
+ );
+ });
+
+ it("clicking Manage List opens the modal with current domains as existing", () => {
+ const big = Array.from({ length: 60 }, (_, i) => `@e${i}.com`);
+ const { container } = render(
+ {}}
+ />
+ );
+ fireEvent.click(
+ container.querySelector("[data-testid='manage-list-button']")
+ );
+ expect(
+ container.querySelector("[data-testid='manage-modal-mock']")
+ ).toBeInTheDocument();
+ expect(
+ container.querySelector("[data-testid='manage-modal-existing']")
+ ).toHaveTextContent(JSON.stringify(big));
+ });
+
+ it("modal onApply bubbles via fireChange (handleChange called with new array)", () => {
+ const big = Array.from({ length: 60 }, (_, i) => `@e${i}.com`);
+ const handleChange = jest.fn();
+ const { container } = render(
+
+ );
+ fireEvent.click(
+ container.querySelector("[data-testid='manage-list-button']")
+ );
+ fireEvent.click(
+ container.querySelector("[data-testid='manage-modal-mock-apply']")
+ );
+ expect(handleChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ target: expect.objectContaining({
+ id: "allowed_email_domains",
+ value: ["@bulk1.com", "@bulk2.com"]
+ })
+ })
+ );
+ });
+});
+
+describe("AllowedEmailDomainsRow — case-insensitive single-entry dedup", () => {
+ it("rejects @ACME.COM when @acme.com is already present", () => {
+ const handleChange = jest.fn();
+ const { container } = render(
+
+ );
+ typeAndCommit(container, "@ACME.COM");
+ const calls = handleChange.mock.calls.filter(
+ (c) => Array.isArray(c[0]?.target?.value) && c[0].target.value.length > 1
+ );
+ expect(calls).toHaveLength(0);
+ });
+});
+
+describe("AllowedEmailDomainsRow — inline + add one input present at every size (SDS L58/69/100/186)", () => {
+ it("renders the inline input when domains.length <= 50 (chip wall)", () => {
+ const small = Array.from({ length: 10 }, (_, i) => `@e${i}.com`);
+ const { container } = render(
+ {}}
+ />
+ );
+ expect(
+ container.querySelector("[data-testid='allowed_email_domains_input']")
+ ).toBeInTheDocument();
+ });
+
+ it("renders the inline input when domains.length > 50 (compact summary)", () => {
+ const big = Array.from({ length: 60 }, (_, i) => `@e${i}.com`);
+ const { container } = render(
+ {}}
+ />
+ );
+ expect(
+ container.querySelector("[data-testid='allowed_email_domains_input']")
+ ).toBeInTheDocument();
+ });
+
+ it("commits a single entry from compact mode via Enter (parity with chip-wall behavior)", () => {
+ const big = Array.from({ length: 60 }, (_, i) => `@e${i}.com`);
+ const handleChange = jest.fn();
+ const { container } = render(
+
+ );
+ typeAndCommit(container, "@new.com");
+ expect(handleChange).toHaveBeenCalledTimes(1);
+ expect(handleChange.mock.calls[0][0].target.value).toEqual([
+ ...big,
+ "@new.com"
+ ]);
+ });
+
+ it("case-insensitive single-entry dedup works in compact mode", () => {
+ const big = Array.from({ length: 60 }, (_, i) => `@e${i}.com`);
+ const handleChange = jest.fn();
+ const { container } = render(
+
+ );
+ typeAndCommit(container, "@E0.COM"); // dup of @e0.com case-insensitively
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+});
+
+describe("AllowedEmailDomainsRow — scrollToError target present in both branches (Codex A2)", () => {
+ it("compact branch carries id='allowed_email_domains' so document.getElementById finds it", () => {
+ const big = Array.from({ length: 60 }, (_, i) => `@e${i}.com`);
+ const { container } = render(
+ {}}
+ />
+ );
+ expect(
+ container.querySelector("#allowed_email_domains")
+ ).toBeInTheDocument();
+ });
+
+ it("chip-wall branch still carries id='allowed_email_domains' (existing behavior, regression check)", () => {
+ const small = Array.from({ length: 10 }, (_, i) => `@e${i}.com`);
+ const { container } = render(
+ {}}
+ />
+ );
+ expect(
+ container.querySelector("#allowed_email_domains")
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/forms/promocode-form/forms/domain-authorized/__tests__/bulk-input-parser.test.js b/src/components/forms/promocode-form/forms/domain-authorized/__tests__/bulk-input-parser.test.js
new file mode 100644
index 000000000..7e52da454
--- /dev/null
+++ b/src/components/forms/promocode-form/forms/domain-authorized/__tests__/bulk-input-parser.test.js
@@ -0,0 +1,150 @@
+import {
+ parseTextBlob,
+ normalizeEntry,
+ classifyEntries,
+ LARGE_DOMAIN_LIST_THRESHOLD
+} from "../bulk-input-parser";
+
+describe("LARGE_DOMAIN_LIST_THRESHOLD", () => {
+ it("is 50", () => {
+ expect(LARGE_DOMAIN_LIST_THRESHOLD).toBe(50);
+ });
+});
+
+describe("parseTextBlob", () => {
+ it("splits on newline, comma, tab, semicolon, and mixed separators", () => {
+ const raw = "@a.com\n@b.com,@c.com\t@d.com;@e.com";
+ const rows = parseTextBlob(raw);
+ expect(rows.map((r) => r.entry)).toEqual([
+ "@a.com",
+ "@b.com",
+ "@c.com",
+ "@d.com",
+ "@e.com"
+ ]);
+ });
+
+ it("trims whitespace and drops empty rows", () => {
+ const rows = parseTextBlob(" @a.com \n\n@b.com\n \n");
+ expect(rows.map((r) => r.entry)).toEqual(["@a.com", "@b.com"]);
+ });
+
+ it("preserves 1-based source row numbers", () => {
+ const rows = parseTextBlob("@a.com\n@b.com\n@c.com");
+ expect(rows[0].sourceRow).toBeGreaterThan(0);
+ expect(rows.map((r) => r.entry)).toEqual(["@a.com", "@b.com", "@c.com"]);
+ });
+
+ it("returns [] for non-string input", () => {
+ expect(parseTextBlob(null)).toEqual([]);
+ expect(parseTextBlob(undefined)).toEqual([]);
+ expect(parseTextBlob(123)).toEqual([]);
+ });
+});
+
+describe("normalizeEntry", () => {
+ it("preserves user-typed casing in normalized; dedupKey is lowercased", () => {
+ expect(normalizeEntry("User@ACME.com")).toEqual({
+ normalized: "User@ACME.com",
+ dedupKey: "user@acme.com",
+ autoPrefixed: false
+ });
+ });
+
+ it("auto-prefixes @ on bare domains; preserves casing of the rest", () => {
+ expect(normalizeEntry("ACME.com")).toEqual({
+ normalized: "@ACME.com",
+ dedupKey: "@acme.com",
+ autoPrefixed: true
+ });
+ });
+
+ it("auto-prefixes q.io (single-char SLD)", () => {
+ expect(normalizeEntry("q.io")).toEqual({
+ normalized: "@q.io",
+ dedupKey: "@q.io",
+ autoPrefixed: true
+ });
+ });
+
+ it("auto-prefixes a.co (single-char label, 2-char TLD)", () => {
+ expect(normalizeEntry("a.co")).toEqual({
+ normalized: "@a.co",
+ dedupKey: "@a.co",
+ autoPrefixed: true
+ });
+ });
+
+ it("does NOT auto-prefix entries that already start with @ or .", () => {
+ expect(normalizeEntry("@acme.com").autoPrefixed).toBe(false);
+ expect(normalizeEntry(".edu").autoPrefixed).toBe(false);
+ });
+
+ it("does NOT auto-prefix entries containing @ (likely user@domain)", () => {
+ expect(normalizeEntry("user@acme.com").autoPrefixed).toBe(false);
+ });
+
+ it("trims whitespace; returns empty fields for whitespace-only input", () => {
+ expect(normalizeEntry(" ")).toEqual({
+ normalized: "",
+ dedupKey: "",
+ autoPrefixed: false
+ });
+ });
+});
+
+describe("classifyEntries", () => {
+ it("classifies a single valid entry", () => {
+ const raw = parseTextBlob("@acme.com");
+ expect(classifyEntries({ raw, existing: [] })).toMatchObject({
+ valid: [expect.objectContaining({ normalized: "@acme.com" })],
+ invalid: [],
+ dupExisting: [],
+ dupInput: [],
+ autoPrefixed: []
+ });
+ });
+
+ it("case-insensitive dedup against existing", () => {
+ const raw = parseTextBlob("@ACME.COM");
+ const result = classifyEntries({ raw, existing: ["@acme.com"] });
+ expect(result.valid).toHaveLength(0);
+ expect(result.dupExisting).toHaveLength(1);
+ });
+
+ it("case-insensitive dedup within input (first wins)", () => {
+ const raw = parseTextBlob("@Acme.com\n@ACME.COM");
+ const result = classifyEntries({ raw, existing: [] });
+ expect(result.valid).toHaveLength(1);
+ expect(result.valid[0].normalized).toBe("@Acme.com");
+ expect(result.dupInput).toHaveLength(1);
+ });
+
+ it("classifies invalid entries (server-relaxed, client-strict)", () => {
+ const raw = parseTextBlob("@acme_corp.com\nuser@abc");
+ const result = classifyEntries({ raw, existing: [] });
+ expect(result.invalid).toHaveLength(2);
+ expect(result.valid).toHaveLength(0);
+ });
+
+ it("tracks auto-prefixed valid entries in both valid AND autoPrefixed lists", () => {
+ const raw = parseTextBlob("acme.com");
+ const result = classifyEntries({ raw, existing: [] });
+ expect(result.valid).toHaveLength(1);
+ expect(result.autoPrefixed).toHaveLength(1);
+ expect(result.valid[0].normalized).toBe("@acme.com");
+ });
+
+ it("realistic mixed input — 1 valid, 1 dupExisting, 1 dupInput, 1 invalid, 1 bare auto-prefix", () => {
+ const raw = parseTextBlob(
+ "@new.com\n@acme.com\n@ACME.com\nnot-a-domain\nbeta.io"
+ );
+ const result = classifyEntries({ raw, existing: ["@acme.com"] });
+ expect(result.valid.map((v) => v.normalized).sort()).toEqual(
+ ["@beta.io", "@new.com"].sort()
+ );
+ expect(result.dupExisting).toHaveLength(1);
+ expect(result.invalid).toHaveLength(1);
+ expect(result.autoPrefixed).toHaveLength(1);
+ });
+});
diff --git a/src/components/forms/promocode-form/forms/domain-authorized/__tests__/manage-modal.test.jsx b/src/components/forms/promocode-form/forms/domain-authorized/__tests__/manage-modal.test.jsx
new file mode 100644
index 000000000..92ca42329
--- /dev/null
+++ b/src/components/forms/promocode-form/forms/domain-authorized/__tests__/manage-modal.test.jsx
@@ -0,0 +1,158 @@
+import React from "react";
+import { render, screen, fireEvent, within } from "@testing-library/react";
+import ManageAllowedEmailDomainsModal from "../ManageAllowedEmailDomainsModal";
+
+// Mock react-window — jsdom has no layout, so FixedSizeList won't render rows.
+// Mock renders first 10 rows directly to match production's ~10-rows-visible cap.
+jest.mock("react-window", () => {
+ 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
+ })
+ )
+ );
+ }
+ )
+ };
+});
+
+const onApply = jest.fn();
+const onHide = jest.fn();
+
+beforeEach(() => {
+ onApply.mockClear();
+ onHide.mockClear();
+});
+
+const openModal = (existing = []) =>
+ render(
+
+ );
+
+describe("ManageAllowedEmailDomainsModal — Tier 1", () => {
+ it("renders title and Add Domains controls when shown", () => {
+ openModal();
+ expect(
+ screen.getByText("edit_promocode.manage_modal.title")
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", {
+ name: "edit_promocode.manage_modal.add_button"
+ })
+ ).toBeInTheDocument();
+ });
+
+ it("paste 5 entries (1 valid, 1 dup of existing, 1 invalid, 1 bare auto-prefix, 1 blank) → toast tally", () => {
+ openModal(["@acme.com"]);
+ const textarea = screen.getByTestId("manage-modal-textarea");
+ fireEvent.change(textarea, {
+ target: { value: "@new.com\n@acme.com\nnot-a-domain\nbeta.io\n " }
+ });
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "edit_promocode.manage_modal.add_button"
+ })
+ );
+ // i18n-react renders the raw key string in the jest env (no translator
+ // configured), so the toast/count text matches the key, not the
+ // interpolated copy. Verify the numeric outcome via the rendered row
+ // count, which reflects the actual working array.
+ expect(screen.getByTestId("manage-modal-toast")).toHaveTextContent(
+ "edit_promocode.manage_modal.added_toast"
+ );
+ expect(screen.getByTestId("manage-modal-count")).toHaveTextContent(
+ "edit_promocode.manage_modal.configured_count"
+ );
+ // 1 existing (@acme.com) + 2 new valid (@new.com, beta.io→@beta.io) = 3 rows
+ const list = screen.getByTestId("fixed-size-list");
+ expect(within(list).getAllByTestId(/manage-modal-row-/)).toHaveLength(3);
+ });
+
+ it("Cancel → onApply NOT called; onHide called", () => {
+ openModal(["@a.com"]);
+ fireEvent.click(
+ screen.getByRole("button", { name: "edit_promocode.manage_modal.cancel" })
+ );
+ expect(onApply).not.toHaveBeenCalled();
+ expect(onHide).toHaveBeenCalledTimes(1);
+ });
+
+ it("Done → onApply called with modal-scoped working array", () => {
+ openModal(["@a.com"]);
+ const textarea = screen.getByTestId("manage-modal-textarea");
+ fireEvent.change(textarea, { target: { value: "@b.com" } });
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "edit_promocode.manage_modal.add_button"
+ })
+ );
+ fireEvent.click(
+ screen.getByRole("button", { name: "edit_promocode.manage_modal.done" })
+ );
+ expect(onApply).toHaveBeenCalledWith(["@a.com", "@b.com"]);
+ expect(onHide).toHaveBeenCalledTimes(1);
+ });
+
+ it("Cmd+Enter inside textarea fires Add Domains", () => {
+ openModal();
+ const textarea = screen.getByTestId("manage-modal-textarea");
+ fireEvent.change(textarea, { target: { value: "@x.com" } });
+ fireEvent.keyDown(textarea, { key: "Enter", metaKey: true });
+ // Toast/count render the raw key in the jest env; verify the side
+ // effect via the row count instead of the interpolated number.
+ expect(screen.getByTestId("manage-modal-toast")).toHaveTextContent(
+ "edit_promocode.manage_modal.added_toast"
+ );
+ const list = screen.getByTestId("fixed-size-list");
+ expect(within(list).getAllByTestId(/manage-modal-row-/)).toHaveLength(1);
+ });
+
+ it("renders 1,400-entry list without crashing; virtualization shows only ~10 rows", () => {
+ const big = Array.from({ length: 1400 }, (_, i) => `@e${i}.com`);
+ openModal(big);
+ // Count text is the raw i18n key in jest env; the row-virtualization
+ // assertion below proves the modal handled the 1,400-entry input.
+ expect(screen.getByTestId("manage-modal-count")).toHaveTextContent(
+ "edit_promocode.manage_modal.configured_count"
+ );
+ const list = screen.getByTestId("fixed-size-list");
+ expect(within(list).getAllByTestId(/manage-modal-row-/)).toHaveLength(10);
+ });
+
+ it("snapshots existing on open — does NOT see parent state mutations after open", () => {
+ const { rerender } = openModal(["@a.com"]);
+ rerender(
+
+ );
+ fireEvent.click(
+ screen.getByRole("button", { name: "edit_promocode.manage_modal.done" })
+ );
+ expect(onApply).toHaveBeenCalledWith(["@a.com"]);
+ });
+});
diff --git a/src/components/forms/promocode-form/forms/domain-authorized/bulk-input-parser.js b/src/components/forms/promocode-form/forms/domain-authorized/bulk-input-parser.js
new file mode 100644
index 000000000..c79884145
--- /dev/null
+++ b/src/components/forms/promocode-form/forms/domain-authorized/bulk-input-parser.js
@@ -0,0 +1,79 @@
+import { validateAllowedEmailDomainEntry } from "../../../../../utils/methods";
+
+export const LARGE_DOMAIN_LIST_THRESHOLD = 50;
+
+const PASTE_SEPARATORS = /[\n,\t;]+/;
+
+export const parseTextBlob = (raw) => {
+ if (typeof raw !== "string") return [];
+ return raw
+ .split(PASTE_SEPARATORS)
+ .map((s, idx) => ({ entry: s.trim(), sourceRow: idx + 1 }))
+ .filter((r) => r.entry.length > 0);
+};
+
+const BARE_DOMAIN_RE =
+ /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i;
+
+export const normalizeEntry = (raw) => {
+ const trimmed = (raw ?? "").trim();
+ if (trimmed.length === 0) {
+ return { normalized: "", dedupKey: "", autoPrefixed: false };
+ }
+ if (!trimmed.includes("@") && BARE_DOMAIN_RE.test(trimmed)) {
+ const normalized = `@${trimmed}`;
+ return {
+ normalized,
+ dedupKey: normalized.toLowerCase(),
+ autoPrefixed: true
+ };
+ }
+ return {
+ normalized: trimmed,
+ dedupKey: trimmed.toLowerCase(),
+ autoPrefixed: false
+ };
+};
+
+export const classifyEntries = ({ raw, existing }) => {
+ const existingKeys = new Set(
+ (existing ?? []).map((e) => String(e).toLowerCase())
+ );
+ const seenInInput = new Set();
+ const result = {
+ valid: [],
+ invalid: [],
+ dupExisting: [],
+ dupInput: [],
+ autoPrefixed: []
+ };
+
+ // eslint-disable-next-line no-restricted-syntax
+ for (const row of raw ?? []) {
+ const { normalized, dedupKey, autoPrefixed } = normalizeEntry(row.entry);
+ if (normalized.length === 0) continue;
+
+ if (!validateAllowedEmailDomainEntry(normalized)) {
+ result.invalid.push({ ...row, normalized });
+ continue;
+ }
+ if (existingKeys.has(dedupKey)) {
+ if (!seenInInput.has(dedupKey)) {
+ result.dupExisting.push({ ...row, normalized });
+ seenInInput.add(dedupKey);
+ } else {
+ result.dupInput.push({ ...row, normalized });
+ }
+ continue;
+ }
+ if (seenInInput.has(dedupKey)) {
+ result.dupInput.push({ ...row, normalized });
+ continue;
+ }
+ seenInInput.add(dedupKey);
+ result.valid.push({ ...row, normalized });
+ if (autoPrefixed) result.autoPrefixed.push({ ...row, normalized });
+ }
+
+ return result;
+};
diff --git a/src/i18n/en.json b/src/i18n/en.json
index f5a408eb3..6e09b6b04 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1043,6 +1043,21 @@
},
"errors": {
"allowed_email_domains_format": "Each entry must be an exact domain (@acme.com), a TLD suffix (.edu), or a full email address."
+ },
+ "manage_button": "Manage List",
+ "manage_modal": {
+ "title": "Manage Allowed Email Domains",
+ "add_helper": "Accepts domains (@acme.com), TLD suffixes (.edu, .gov), or exact emails (user@example.com).",
+ "add_button": "Add Domains",
+ "added_toast": "Added {added} · skipped {invalid} invalid · {dup} duplicates",
+ "configured_count": "Configured Domains ({n})",
+ "done": "Done",
+ "cancel": "Cancel"
+ },
+ "large_list": {
+ "summary_count": "{n} domains configured",
+ "summary_type_mix": "{atDomain} @domain · {tld} .tld · {email} user@email",
+ "summary_example": "e.g. {entry}"
}
},
"discount_ticket": {
diff --git a/yarn.lock b/yarn.lock
index 81f28eeff..20eb24c71 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -979,7 +979,7 @@
"@babel/plugin-transform-react-jsx-development" "^7.27.1"
"@babel/plugin-transform-react-pure-annotations" "^7.27.1"
-"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.13", "@babel/runtime@^7.25.7", "@babel/runtime@^7.26.0", "@babel/runtime@^7.28.6", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
+"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.13", "@babel/runtime@^7.25.7", "@babel/runtime@^7.26.0", "@babel/runtime@^7.28.6", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
version "7.29.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.2.tgz#9a6e2d05f4b6692e1801cd4fb176ad823930ed5e"
integrity sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==
@@ -8549,7 +8549,7 @@ memfs@^4.43.1:
tree-dump "^1.0.3"
tslib "^2.0.0"
-memoize-one@^5.0.0, memoize-one@^5.1.1:
+"memoize-one@>=3.1.1 <6", memoize-one@^5.0.0, memoize-one@^5.1.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
@@ -10185,6 +10185,14 @@ react-transition-group@^4.4.5:
loose-envify "^1.4.0"
prop-types "^15.6.2"
+react-window@^1.8.10:
+ version "1.8.11"
+ resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.11.tgz#a857b48fa85bd77042d59cc460964ff2e0648525"
+ integrity sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==
+ dependencies:
+ "@babel/runtime" "^7.0.0"
+ memoize-one ">=3.1.1 <6"
+
react@^16.1.0, react@^16.13.1:
version "16.14.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"