From 35f13276d0a3bd33f4e948d005cb8fc618ff6635 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 13:49:51 -0500 Subject: [PATCH 01/12] chore: add react-window for allowed-domains list virtualization --- package.json | 1 + yarn.lock | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) 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/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" From e492f5799a172196694056cbae168962e3c7eb62 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 13:58:24 -0500 Subject: [PATCH 02/12] feat: add bulk-input parser for domain-authorized promo code manage modal --- .../__tests__/bulk-input-parser.test.js | 150 ++++++++++++++++++ .../domain-authorized/bulk-input-parser.js | 78 +++++++++ 2 files changed, 228 insertions(+) create mode 100644 src/components/forms/promocode-form/forms/domain-authorized/__tests__/bulk-input-parser.test.js create mode 100644 src/components/forms/promocode-form/forms/domain-authorized/bulk-input-parser.js 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/bulk-input-parser.js b/src/components/forms/promocode-form/forms/domain-authorized/bulk-input-parser.js new file mode 100644 index 000000000..942144429 --- /dev/null +++ b/src/components/forms/promocode-form/forms/domain-authorized/bulk-input-parser.js @@ -0,0 +1,78 @@ +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: [] + }; + + 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; +}; From 64010090a1f9527b41eb695bb132ffed084fe540 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 14:05:45 -0500 Subject: [PATCH 03/12] style(promo-codes): silence no-restricted-syntax on parser for...of loop --- .../promocode-form/forms/domain-authorized/bulk-input-parser.js | 1 + 1 file changed, 1 insertion(+) 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 index 942144429..c79884145 100644 --- 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 @@ -48,6 +48,7 @@ export const classifyEntries = ({ raw, existing }) => { 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; From 541ee664c63b3fb2078978c556fd0a4215e92f97 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 14:09:58 -0500 Subject: [PATCH 04/12] feat(promo-codes): add ManageAllowedEmailDomainsModal Tier 1 scope --- .../ManageAllowedEmailDomainsModal.jsx | 166 ++++++++++++++++++ .../__tests__/manage-modal.test.jsx | 142 +++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 src/components/forms/promocode-form/forms/domain-authorized/ManageAllowedEmailDomainsModal.jsx create mode 100644 src/components/forms/promocode-form/forms/domain-authorized/__tests__/manage-modal.test.jsx 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..b64413a4d --- /dev/null +++ b/src/components/forms/promocode-form/forms/domain-authorized/ManageAllowedEmailDomainsModal.jsx @@ -0,0 +1,166 @@ +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 `Added ${toast.added} · ${toast.invalid} invalid · ${toast.dup} duplicates`; + }, [toast]); + + const countText = `${T.translate( + "edit_promocode.manage_modal.configured_label" + )} (${working.length})`; + + return ( + + + + {T.translate("edit_promocode.manage_modal.title")} + + + +
+