diff --git a/src/actions/promocode-actions.js b/src/actions/promocode-actions.js index 6cc3463c0..042c99728 100644 --- a/src/actions/promocode-actions.js +++ b/src/actions/promocode-actions.js @@ -20,15 +20,13 @@ import { createAction, stopLoading, startLoading, - showMessage, - showSuccessMessage, getCSV, - authErrorHandler, + snackbarErrorHandler, + snackbarSuccessHandler, escapeFilterValue, postFile } from "openstack-uicore-foundation/lib/utils/actions"; -import history from "../history"; -import { checkOrFilter, getAccessTokenSafely } from "../utils/methods"; +import { getAccessTokenSafely } from "../utils/methods"; import { DEFAULT_PER_PAGE } from "../utils/constants"; export const EXISTING_SPEAKERS_PROMO_CODE = 1; @@ -59,33 +57,9 @@ export const GET_ASSIGNED_SPEAKERS_LOCALLY = "GET_ASSIGNED_SPEAKERS_LOCALLY"; export const REQUEST_ASSIGNED_SPEAKERS = "REQUEST_ASSIGNED_SPEAKERS"; export const RECEIVE_ASSIGNED_SPEAKERS = "RECEIVE_ASSIGNED_SPEAKERS"; -const parseFilters = (filters, term = null) => { - const filter = []; - - if (filters?.typeFilter && filters.typeFilter !== "ALL") { - filter.push(`type==${filters.typeFilter}`); - } - - if (filters?.creatorFilter && filters.creatorFilter !== "ALL") { - filter.push(`creator_email==${filters.creatorFilter.email}`); - } - - if (filters?.assigneeFilter && filters.assigneeFilter !== "ALL") { - filter.push( - `owner_email==${filters.assigneeFilter.email},contact_email==${filters.assigneeFilter.email}` - ); - } - - if ( - Array.isArray(filters?.classNamesFilter) && - filters.classNamesFilter.length > 0 - ) { - filter.push(`class_name==${filters.classNamesFilter.join("||")}`); - } - - if (Array.isArray(filters?.tagsFilter) && filters.tagsFilter.length > 0) { - filter.push(`tag_id==${filters.tagsFilter.map((t) => t.id).join("||")}`); - } +// filters is GridFilter's parsedFilter (already or()-wrapped when joined by ANY); the search term is always ANDed +const parseFilters = (filters = [], term = null) => { + const filter = Array.isArray(filters) ? [...filters] : []; if (term) { const escapedTerm = escapeFilterValue(term); @@ -103,7 +77,7 @@ const parseFilters = (filters, term = null) => { filter.push(searchString); } - return checkOrFilter(filters, filter); + return filter; }; const normalizeEntity = (entity) => { @@ -175,7 +149,7 @@ export const getPromocodeMeta = () => async (dispatch, getState) => { null, createAction(RECEIVE_PROMOCODE_META), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes/metadata`, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -188,7 +162,7 @@ export const getPromocodes = perPage = DEFAULT_PER_PAGE, order = "code", orderDir = 1, - filters = {}, + filters = [], extraColumns = [] ) => async (dispatch, getState) => { @@ -230,7 +204,7 @@ export const getPromocodes = createAction(REQUEST_PROMOCODES), createAction(RECEIVE_PROMOCODES), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes`, - authErrorHandler, + snackbarErrorHandler, { page, perPage, order, orderDir, term, filters, extraColumns } )(params)(dispatch).then(() => { dispatch(stopLoading()); @@ -256,7 +230,7 @@ export const getPromocode = (promocodeId) => async (dispatch, getState) => { null, createAction(RECEIVE_PROMOCODE), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes/${promocodeId}`, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -266,61 +240,56 @@ export const resetPromocodeForm = () => (dispatch) => { dispatch(createAction(RESET_PROMOCODE_FORM)({})); }; -export const savePromocode = - (entity, isSponsor) => async (dispatch, getState) => { - const { currentSummitState } = getState(); - const accessToken = await getAccessTokenSafely(); - const { currentSummit } = currentSummitState; - - dispatch(startLoading()); - - const normalizedEntity = normalizeEntity(entity); +export const savePromocode = (entity) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + dispatch(startLoading()); + const accessToken = await getAccessTokenSafely(); + const { currentSummit } = currentSummitState; - const params = { - expand: - "owner,sponsor,sponsor.company,sponsor.sponsorship,speaker,tickets,ticket_type,ticket_types_rules,tags,allowed_ticket_types", - access_token: accessToken - }; + const normalizedEntity = normalizeEntity(entity); - if (entity.id) { - return putRequest( - createAction(UPDATE_PROMOCODE), - createAction(PROMOCODE_UPDATED), - `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes/${entity.id}`, - normalizedEntity, - authErrorHandler, - entity - )(params)(dispatch).then(() => { - dispatch( - showSuccessMessage(T.translate("edit_promocode.promocode_saved")) - ); - }); - } - const successMessage = { - title: T.translate("general.done"), - html: T.translate("edit_promocode.promocode_created"), - type: "success" - }; + const params = { + expand: + "owner,sponsor,sponsor.company,sponsor.sponsorship,speaker,tickets,ticket_type,ticket_types_rules,tags,allowed_ticket_types", + access_token: accessToken + }; - return postRequest( + if (entity.id) { + return putRequest( createAction(UPDATE_PROMOCODE), - createAction(PROMOCODE_ADDED), - `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes`, + createAction(PROMOCODE_UPDATED), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes/${entity.id}`, normalizedEntity, - authErrorHandler, + snackbarErrorHandler, entity )(params)(dispatch).then((payload) => { dispatch( - showMessage(successMessage, () => { - history.push( - `/app/summits/${currentSummit.id}${ - isSponsor ? "/sponsors" : "" - }/promocodes/${payload.response.id}` - ); + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_promocode.promocode_saved") }) ); + return payload; }); - }; + } + + return postRequest( + createAction(UPDATE_PROMOCODE), + createAction(PROMOCODE_ADDED), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes`, + normalizedEntity, + snackbarErrorHandler, + entity + )(params)(dispatch).then((payload) => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_promocode.promocode_created") + }) + ); + return payload; + }); +}; export const deletePromocode = (promocodeId) => async (dispatch, getState) => { const { currentSummitState } = getState(); @@ -336,7 +305,7 @@ export const deletePromocode = (promocodeId) => async (dispatch, getState) => { createAction(PROMOCODE_DELETED)({ promocodeId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes/${promocodeId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -356,21 +325,19 @@ export const sendEmail = (promocodeId) => async (dispatch, getState) => { createAction(EMAIL_SENT), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes/${promocodeId}/mail`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { - dispatch(stopLoading()); dispatch( - showMessage( - T.translate("general.done"), - T.translate("general.mail_sent"), - "success" - ) + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("general.email_sent") + }) ); }); }; export const exportPromocodes = - (term = null, order = "code", orderDir = 1, filters = {}) => + (term = null, order = "code", orderDir = 1, filters = []) => async (dispatch, getState) => { const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); @@ -424,7 +391,7 @@ export const addBadgeFeatureToPromocode = createAction(BADGE_FEATURE_ADDED)({ badgeFeature }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes/${promocodeId}/badge-features/${badgeFeature.id}`, {}, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -447,7 +414,7 @@ export const removeBadgeFeatureFromPromocode = createAction(BADGE_FEATURE_REMOVED)({ badgeFeatureId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes/${promocodeId}/badge-features/${badgeFeatureId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -470,7 +437,7 @@ export const addDiscountTicket = (ticket) => async (dispatch, getState) => { createAction(DISCOUNT_TICKET_ADDED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes/${ticket.owner_id}/ticket-types/${ticket.ticket_type_id}`, ticket, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -491,7 +458,7 @@ export const deleteDiscountTicket = createAction(DISCOUNT_TICKET_DELETED)({ ticketId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes/${promocodeId}/ticket-types/${ticketTypeId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -512,7 +479,7 @@ export const importPromoCodesCSV = (file) => async (dispatch, getState) => { `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/promo-codes/csv`, file, {}, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); window.location.reload(); @@ -581,7 +548,7 @@ export const getAssignedSpeakers = createAction(REQUEST_ASSIGNED_SPEAKERS), createAction(RECEIVE_ASSIGNED_SPEAKERS), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/${promoCodeRoute}/${entity.id}/speakers`, - authErrorHandler, + snackbarErrorHandler, { entity, order, orderDir, page, term } )(params)(dispatch).then(() => { dispatch(stopLoading()); @@ -606,7 +573,7 @@ export const assignSpeaker = (entity) => async (dispatch, getState) => { createAction(SPEAKER_ASSIGNED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/speakers-promo-codes/${entity.id}/speakers/${entity.speaker.id}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); dispatch(getAssignedSpeakers(entity)); @@ -638,7 +605,7 @@ export const unAssignSpeaker = createAction(SPEAKER_UNASSIGNED)({ speakerId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/speakers-promo-codes/${promocodeId}/speakers/${speakerId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); 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 deleted file mode 100644 index 2d06f4545..000000000 --- a/src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js +++ /dev/null @@ -1,865 +0,0 @@ -import React from "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(); - -// openstack-uicore-foundation components used across the PromocodeForm tree. -// Master commit cd8b5b98 ("Reduce bundle size") rewrote barrel imports to -// direct paths to avoid pulling the entire components index. Each component -// the SUT renders is mocked at its direct path below. Each factory inlines the -// passthrough body because babel-plugin-jest-hoist hoists `jest.mock` calls -// above any `const` declarations — references outside the factory ReferenceError. -/* eslint-disable global-require */ -jest.mock("openstack-uicore-foundation/lib/components/inputs/dropdown", () => { - const React = require("react"); - return { - __esModule: true, - default: (props) => - React.createElement("input", { - id: props.id ?? "Dropdown", - "data-mocked": "Dropdown", - type: props.type ?? "text", - value: props.value ?? "", - onChange: props.onChange ?? (() => {}) - }) - }; -}); -jest.mock( - "openstack-uicore-foundation/lib/components/inputs/text-input", - () => { - const React = require("react"); - return { - __esModule: true, - default: (props) => - React.createElement("input", { - id: props.id ?? "Input", - "data-mocked": "Input", - type: props.type ?? "text", - value: props.value ?? "", - onChange: props.onChange ?? (() => {}) - }) - }; - } -); -jest.mock( - "openstack-uicore-foundation/lib/components/inputs/textarea-input", - () => { - const React = require("react"); - return { - __esModule: true, - default: (props) => - React.createElement("input", { - id: props.id ?? "TextArea", - "data-mocked": "TextArea", - type: props.type ?? "text", - value: props.value ?? "", - onChange: props.onChange ?? (() => {}) - }) - }; - } -); -jest.mock( - "openstack-uicore-foundation/lib/components/inputs/datetimepicker", - () => { - const React = require("react"); - return { - __esModule: true, - default: (props) => - React.createElement("input", { - id: props.id ?? "DateTimePicker", - "data-mocked": "DateTimePicker", - type: props.type ?? "text", - value: props.value ?? "", - onChange: props.onChange ?? (() => {}) - }) - }; - } -); -jest.mock( - "openstack-uicore-foundation/lib/components/inputs/ticket-types-input", - () => { - const React = require("react"); - return { - __esModule: true, - default: (props) => - React.createElement("input", { - id: props.id ?? "TicketTypesInput", - "data-mocked": "TicketTypesInput", - type: props.type ?? "text", - value: props.value ?? "", - onChange: props.onChange ?? (() => {}) - }) - }; - } -); -jest.mock( - "openstack-uicore-foundation/lib/components/inputs/speaker-input", - () => { - const React = require("react"); - return { - __esModule: true, - default: (props) => - React.createElement("input", { - id: props.id ?? "SpeakerInput", - "data-mocked": "SpeakerInput", - type: props.type ?? "text", - value: props.value ?? "", - onChange: props.onChange ?? (() => {}) - }) - }; - } -); -jest.mock( - "openstack-uicore-foundation/lib/components/inputs/sponsor-input", - () => { - const React = require("react"); - return { - __esModule: true, - default: (props) => - React.createElement("input", { - id: props.id ?? "SponsorInput", - "data-mocked": "SponsorInput", - type: props.type ?? "text", - value: props.value ?? "", - onChange: props.onChange ?? (() => {}) - }) - }; - } -); -jest.mock("openstack-uicore-foundation/lib/components/free-text-search", () => { - const React = require("react"); - return { - __esModule: true, - default: (props) => - React.createElement("input", { - id: props.id ?? "FreeTextSearch", - "data-mocked": "FreeTextSearch", - type: props.type ?? "text", - value: props.value ?? "", - onChange: props.onChange ?? (() => {}) - }) - }; -}); -jest.mock("openstack-uicore-foundation/lib/components/table", () => ({ - __esModule: true, - default: () => null -})); -/* eslint-enable global-require */ - -jest.mock("openstack-uicore-foundation/lib/components/inputs/tag-input", () => { - // eslint-disable-next-line global-require - const React = require("react"); - const TagInputMock = (props) => { - const draftRef = React.useRef(""); - return React.createElement( - "div", - { - id: props.id, - "data-mocked": "TagInput", - "data-field": props.id - }, - React.createElement("input", { - "data-testid": `taginput-draft-${props.id}`, - onChange: (e) => { - draftRef.current = e.target.value; - } - }), - React.createElement( - "button", - { - type: "button", - "data-testid": `taginput-onCreate-${props.id}`, - onClick: () => props.onCreate && props.onCreate(draftRef.current) - }, - "add" - ) - ); - }; - return { __esModule: true, default: TagInputMock }; -}); - -jest.mock("openstack-uicore-foundation/lib/utils/methods", () => ({ - epochToMomentTimeZone: () => null -})); - -// Other deep imports inside the PromocodeForm tree — stubbed so the component -// tree mounts in jsdom without pulling in chart libs, rich text editors, etc. -// Paths below are relative to THIS test file at -// src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js -// and must resolve to the same files the SUT imports. Verify with: -// node -e "require.resolve('')" from the test file's directory if in doubt. -jest.mock("../../../inputs/owner-input", () => () => null); -jest.mock("../../../inputs/text-area-input-with-counter", () => (props) => { - const React = require("react"); - return React.createElement("textarea", { - id: props.id, - value: props.value ?? "", - onChange: props.onChange ?? (() => {}) - }); -}); -jest.mock("../../../tables/dicount-ticket-table", () => ({ - DiscountTicketTable: () => null -})); -jest.mock( - "../../../../utils/fragmen-parser", - () => - class FragmentParser { - getParam() { - return null; - } - } -); - -const baseEntity = (overrides = {}) => ({ - id: 0, - class_name: null, - type: "", - code: "", - tags: [], - description: "", - allows_to_delegate: false, - allows_to_reassign: true, - allowed_ticket_types: [], - allowed_email_domains: [], - quantity_per_account: 0, - auto_apply: false, - quantity_available: 0, - quantity_used: 0, - valid_since_date: "", - valid_until_date: "", - badge_features: [], - ticket_types_rules: [], - apply_to_all_tix: true, - badge_features_apply_to_all_tix_retroactively: false, - amount: "", - rate: "", - owner: null, - speaker: null, - sponsor: null, - // speakers fragment (speakers-base-pc-form.js) destructures entity.speakers; - // provide an empty structure so SPEAKERS_* class renders without crashing. - speakers: { - filtered_speakers_list: [], - speakers_list: [], - term: "", - order: "id", - orderDir: 1, - currentPage: 1, - lastPage: 1 - }, - ...overrides -}); - -const baseSummit = { - id: 1, - badge_features: [], - ticket_types: [], - time_zone_id: "UTC", - default_ticket_type_currency_symbol: "$" -}; - -const renderForm = (entity, extraProps = {}) => - render( - {}} - onSendEmail={() => {}} - onBadgeFeatureLink={() => {}} - onBadgeFeatureUnLink={() => {}} - onCreateCompany={() => {}} - assignSpeaker={() => {}} - getAssignedSpeakers={() => {}} - unAssignSpeaker={() => {}} - resetPromocodeForm={() => {}} - {...extraProps} - /> - ); - -describe("PromocodeForm class switching", () => { - it("renders domain-authorized access-only fields when class is DOMAIN_AUTHORIZED_PROMO_CODE", () => { - const { container } = renderForm( - baseEntity({ class_name: "DOMAIN_AUTHORIZED_PROMO_CODE" }) - ); - expect( - container.querySelector("#allowed_email_domains") - ).toBeInTheDocument(); - expect( - container.querySelector("#quantity_per_account") - ).toBeInTheDocument(); - expect(container.querySelector("#auto_apply")).toBeInTheDocument(); - expect( - container.querySelector("#allowed_ticket_types") - ).toBeInTheDocument(); - // Discount-only fields must NOT render on the access-only variant. - expect(container.querySelector("#amount")).not.toBeInTheDocument(); - expect(container.querySelector("#rate")).not.toBeInTheDocument(); - }); - - it("renders domain-authorized discount fields when class is DOMAIN_AUTHORIZED_DISCOUNT_CODE", () => { - const { container } = renderForm( - baseEntity({ class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE" }) - ); - expect( - container.querySelector("#allowed_email_domains") - ).toBeInTheDocument(); - expect( - container.querySelector("#quantity_per_account") - ).toBeInTheDocument(); - expect(container.querySelector("#auto_apply")).toBeInTheDocument(); - expect(container.querySelector("#amount")).toBeInTheDocument(); - expect(container.querySelector("#rate")).toBeInTheDocument(); - }); - - it.each([ - "MEMBER_PROMO_CODE", - "MEMBER_DISCOUNT_CODE", - "SPEAKER_PROMO_CODE", - "SPEAKER_DISCOUNT_CODE" - ])("shows auto_apply checkbox when class is %s", (class_name) => { - const { container } = renderForm(baseEntity({ class_name })); - expect(container.querySelector("#auto_apply")).toBeInTheDocument(); - }); - - it("does NOT show auto_apply checkbox when class is SPONSOR_PROMO_CODE", () => { - const { container } = renderForm( - baseEntity({ class_name: "SPONSOR_PROMO_CODE" }) - ); - expect(container.querySelector("#auto_apply")).not.toBeInTheDocument(); - }); - - describe("allowed_email_domains commit path", () => { - const addDomain = (container, value) => { - // Post-PR-#915-review: AllowedEmailDomainsRow uses a bare freeform - // input (no TagInput / react-select) — see santipalenque's review. - const input = container.querySelector( - "[data-testid=\"allowed_email_domains_input\"]" - ); - fireEvent.change(input, { target: { value } }); - fireEvent.keyDown(input, { key: "Enter" }); - }; - - it("rejects malformed entries inline and leaves handleChange (for domains) uncalled", () => { - const { container } = renderForm( - baseEntity({ class_name: "DOMAIN_AUTHORIZED_PROMO_CODE" }) - ); - addDomain(container, "acme.com"); // no leading @ — invalid - // i18n-react renders the raw key in the jest env (no translator is - // configured here), so we match the key itself rather than the - // translated copy. The rendered string is: - // "edit_promocode.errors.allowed_email_domains_format" - expect( - container.querySelector(".text-danger")?.textContent ?? "" - ).toMatch(/allowed_email_domains_format/i); - }); - - it("accepts a valid @domain entry", () => { - const { container } = renderForm( - baseEntity({ class_name: "DOMAIN_AUTHORIZED_PROMO_CODE" }) - ); - addDomain(container, "@acme.com"); - // No inline error after a valid add. - expect(container.querySelector(".text-danger")).toBeNull(); - }); - - it("accepts a valid .tld entry (case-insensitive)", () => { - const { container } = renderForm( - baseEntity({ class_name: "DOMAIN_AUTHORIZED_PROMO_CODE" }) - ); - addDomain(container, ".EDU"); - expect(container.querySelector(".text-danger")).toBeNull(); - }); - }); -}); - -describe("DOMAIN_AUTHORIZED layout positions", () => { - it("renders auto_apply as the third checkbox in the description-row column", () => { - const { container } = renderForm( - baseEntity({ class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE" }) - ); - const checkboxesDiv = container.querySelector(".checkboxes-div"); - const checkboxes = checkboxesDiv.querySelectorAll("input[type=\"checkbox\"]"); - expect(checkboxes).toHaveLength(3); - expect(checkboxes[0].id).toBe("allows_to_delegate"); - expect(checkboxes[1].id).toBe("allows_to_reassign"); - expect(checkboxes[2].id).toBe("auto_apply"); - }); - - it("renders quantity_per_account as the third col-md-4 in the quantity row", () => { - const { container } = renderForm( - baseEntity({ class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE" }) - ); - const quantityRow = container - .querySelector("#quantity_available") - .closest(".row.form-group"); - const cols = quantityRow.querySelectorAll(".col-md-4"); - expect(cols).toHaveLength(3); - expect(cols[0].querySelector("#quantity_available")).toBeTruthy(); - expect(cols[1].querySelector("#quantity_used")).toBeTruthy(); - expect(cols[2].querySelector("#quantity_per_account")).toBeTruthy(); - }); - - it("renders allowed-email-domains-row before the description row", () => { - const { container } = renderForm( - baseEntity({ class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE" }) - ); - const emailDomainsRow = container.querySelector( - "[data-testid=\"allowed-email-domains-row\"]" - ); - const descriptionRow = container - .querySelector("#description") - .closest(".row.form-group"); - expect(emailDomainsRow).toBeTruthy(); - expect(descriptionRow).toBeTruthy(); - /* eslint-disable no-bitwise */ - // bitmask 4 = DOCUMENT_POSITION_FOLLOWING - const followsBit = - emailDomainsRow.compareDocumentPosition(descriptionRow) & 4; - /* eslint-enable no-bitwise */ - expect(followsBit).toBeTruthy(); - }); -}); - -describe("validate() — domain-authorized email-domain enforcement", () => { - it("blocks save on malformed allowed_email_domains for DOMAIN_AUTHORIZED_DISCOUNT_CODE", () => { - const onSubmit = jest.fn(); - const { container } = renderForm( - baseEntity({ - class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE", - allowed_email_domains: ["malformed-no-at-sign"] - }), - { onSubmit } - ); - // T.translate("general.save") renders the raw key "general.save" in the - // jest env (no locale loaded; same behavior the existing file documents - // starting at :227). The regex /save/i matches the substring within the - // raw key, so the button is found. - fireEvent.click(screen.getByRole("button", { name: /save/i })); - expect(onSubmit).not.toHaveBeenCalled(); - // Validate-path error must reach the DOM via the hasErrors prop wired - // through to AllowedEmailDomainsRow — otherwise Save silently no-ops - // (the bug smarcet flagged on PR #915). - expect(container.querySelector(".text-danger")?.textContent ?? "").toMatch( - /allowed_email_domains_format/i - ); - }); - - it("scrolls to the offending field even when other fields were edited first", () => { - // Regression for a bug introduced when scrollToError(state.errors) was - // first added to validate(): handleChange (index.js:114) writes - // newErrors[id] = "" on every edit, so state.errors accumulates - // empty-string keys. scrollToError uses Object.keys()[0] without - // filtering empties, so it would scroll to the first-edited field - // rather than the one with the actual error. Pass a single-key object. - const getByIdSpy = jest.spyOn(document, "getElementById"); - const { container } = renderForm( - baseEntity({ - class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE", - allowed_email_domains: ["malformed"] - }) - ); - // Edit the `code` field first — this seeds state.errors.code = "". - const codeInput = container.querySelector("#code"); - fireEvent.change(codeInput, { target: { value: "EARLY-BIRD" } }); - getByIdSpy.mockClear(); - fireEvent.click(screen.getByRole("button", { name: /save/i })); - expect(getByIdSpy).toHaveBeenCalledWith("allowed_email_domains"); - expect(getByIdSpy).not.toHaveBeenCalledWith("code"); - 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({ - class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE", - allowed_email_domains: ["malformed"] - }) - ); - fireEvent.click(screen.getByRole("button", { name: /save/i })); - expect(container.querySelector(".text-danger")?.textContent ?? "").toMatch( - /allowed_email_domains_format/i - ); - // Typing into the input alone (no commit, no chip-remove) should clear - // the parent error banner — the chip input must mirror handleChange:114 - // semantics that every other field on the form gets via direct - // onChange wiring. - const input = container.querySelector( - "[data-testid='allowed_email_domains_input']" - ); - fireEvent.change(input, { target: { value: "@" } }); - expect(container.querySelector(".text-danger")).toBeNull(); - }); - - it("allows save on valid allowed_email_domains for DOMAIN_AUTHORIZED_DISCOUNT_CODE", () => { - const onSubmit = jest.fn(); - renderForm( - baseEntity({ - class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE", - allowed_email_domains: ["@valid.com", ".edu"] - }), - { onSubmit } - ); - fireEvent.click(screen.getByRole("button", { name: /save/i })); - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - - it("does NOT validate allowed_email_domains for non-DomainAuthorized classes (MEMBER_PROMO_CODE)", () => { - const onSubmit = jest.fn(); - renderForm( - baseEntity({ - class_name: "MEMBER_PROMO_CODE", - allowed_email_domains: ["malformed"] // present but should be ignored - }), - { onSubmit } - ); - fireEvent.click(screen.getByRole("button", { name: /save/i })); - expect(onSubmit).toHaveBeenCalledTimes(1); - }); -}); - -describe("regression — non-DomainAuthorized classes are unaffected by the layout reflow", () => { - // Block 1: classes whose own fragments render #auto_apply - // (MEMBER, SPEAKER variants). See member-base-pc-form.js:27 and - // speaker-base-pc-form.js:25 — both render - // unconditionally. - it.each([ - ["MEMBER_PROMO_CODE"], - ["MEMBER_DISCOUNT_CODE"], - ["SPEAKER_PROMO_CODE"], - ["SPEAKER_DISCOUNT_CODE"] - ])( - "for %s: does NOT render the new DomainAuthorized layout but DOES render fragment-owned #auto_apply", - (class_name) => { - const { container } = renderForm(baseEntity({ class_name })); - const quantityRow = container - .querySelector("#quantity_available") - .closest(".row.form-group"); - const cols = quantityRow.querySelectorAll(".col-md-4"); - expect(cols).toHaveLength(2); - expect( - container.querySelector("#quantity_per_account") - ).not.toBeInTheDocument(); - expect( - container.querySelector("[data-testid=\"allowed-email-domains-row\"]") - ).not.toBeInTheDocument(); - // member/speaker fragments still render auto_apply (unchanged) - expect(container.querySelector("#auto_apply")).toBeInTheDocument(); - } - ); - - // Block 2: classes that should have NO #auto_apply anywhere. - // SUMMIT_*, PRE_PAID_*, SPEAKERS_*, SPONSOR_* — none of their fragments - // render auto_apply. PRE_PAID_* routes through SummitPCForm / - // SummitDiscountPCForm per index.js:494-518. - it.each([ - ["SPONSOR_PROMO_CODE"], - ["SPONSOR_DISCOUNT_CODE"], - ["SUMMIT_PROMO_CODE"], - ["SUMMIT_DISCOUNT_CODE"], - ["PRE_PAID_PROMO_CODE"], - ["PRE_PAID_DISCOUNT_CODE"], - ["SPEAKERS_PROMO_CODE"], - ["SPEAKERS_DISCOUNT_CODE"] - ])( - "for %s: does NOT render the new DomainAuthorized layout and does NOT render #auto_apply", - (class_name) => { - const { container } = renderForm(baseEntity({ class_name })); - const quantityRow = container - .querySelector("#quantity_available") - .closest(".row.form-group"); - const cols = quantityRow.querySelectorAll(".col-md-4"); - expect(cols).toHaveLength(2); - expect( - container.querySelector("#quantity_per_account") - ).not.toBeInTheDocument(); - expect(container.querySelector("#auto_apply")).not.toBeInTheDocument(); - expect( - container.querySelector("[data-testid=\"allowed-email-domains-row\"]") - ).not.toBeInTheDocument(); - } - ); -}); - -describe("regression — handleSubmit must not dehydrate allowed_ticket_types", () => { - // Reproduces the "Ticket Types reverts to undefined" bug (Jam - // b1566c62-c802-4f43-b093-9c5c2f5a5fde, ClickUp 86b9v01bt). Pre-fix, - // handleSubmit mutated state.entity.allowed_ticket_types from [{id, name}] - // to [id]; on a 412, the form re-rendered with raw IDs, and the - // openstack-uicore TicketTypesInput's getOptionLabel = `${e.name}` produced - // the literal string "undefined" for each chip. The action layer - // (normalizeEntity in promocode-actions.js) is responsible for the - // hydrated → API-shape conversion; the form must hand off untouched - // option objects. - it("passes onSubmit an entity whose allowed_ticket_types retain {id,name} objects", () => { - const onSubmit = jest.fn(); - const ticketType = { id: 197, name: "Early Access Members" }; - renderForm( - baseEntity({ - class_name: "DOMAIN_AUTHORIZED_PROMO_CODE", - allowed_email_domains: ["@valid.com"], - allowed_ticket_types: [ticketType] - }), - { onSubmit } - ); - - fireEvent.click(screen.getByRole("button", { name: /save/i })); - - expect(onSubmit).toHaveBeenCalledTimes(1); - const submittedEntity = onSubmit.mock.calls[0][0]; - expect(submittedEntity.allowed_ticket_types).toEqual([ticketType]); - expect(submittedEntity.allowed_ticket_types[0]).toMatchObject({ - id: 197, - name: "Early Access Members" - }); - }); -}); - -describe("DiscountBasePCForm — Apply to all Ticket Types audience restriction copy", () => { - // i18n-react renders raw keys in the jest env (no translator configured), - // so we match against the key strings, not the translated copy. See the - // existing comment around line 368 for the same convention. - const APPLY_ALL_KEY = "edit_promocode.apply_to_all_tix"; - const HELPER_KEY = "edit_promocode.apply_to_all_tix_helper"; - - // Classes that route through DiscountBasePCForm and therefore should - // render both the (Audience: All) label and the helper-text row. - // The helper text belongs to the same col-md-4 column that hosts the - // apply_to_all_tix checkbox. Scope the selector to that column to avoid - // colliding with other `small.form-text.text-muted` rows (e.g. the - // allowed_email_domains caption on the DOMAIN_AUTHORIZED form). - const getApplyToAllHelper = (container) => - container - .querySelector("#apply_to_all_tix") - .closest(".col-md-4") - .querySelector("small.form-text.text-muted"); - - it.each([ - ["SUMMIT_DISCOUNT_CODE"], - ["MEMBER_DISCOUNT_CODE"], - ["SPEAKER_DISCOUNT_CODE"], - ["SPONSOR_DISCOUNT_CODE"], - ["SPEAKERS_DISCOUNT_CODE"], - ["DOMAIN_AUTHORIZED_DISCOUNT_CODE"] - ])( - "for %s: renders the Audience=All label and helper-text below the checkbox", - (class_name) => { - const { container } = renderForm(baseEntity({ class_name })); - - const checkboxLabel = container.querySelector( - "label[for=\"apply_to_all_tix\"]" - ); - expect(checkboxLabel).toBeInTheDocument(); - expect(checkboxLabel.textContent).toBe(APPLY_ALL_KEY); - - const helper = getApplyToAllHelper(container); - expect(helper).toBeInTheDocument(); - expect(helper.textContent).toBe(HELPER_KEY); - } - ); - - it("renders the helper text even when apply_to_all_tix is unchecked", () => { - const { container } = renderForm( - baseEntity({ - class_name: "SUMMIT_DISCOUNT_CODE", - apply_to_all_tix: false - }) - ); - - // Checkbox state reflects the override. - const checkbox = container.querySelector("#apply_to_all_tix"); - expect(checkbox).toBeInTheDocument(); - expect(checkbox.checked).toBe(false); - - // Helper text is always rendered — it's reference copy, not state-gated. - const helper = getApplyToAllHelper(container); - expect(helper).toBeInTheDocument(); - 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/__tests__/promocode-form.test.js b/src/components/forms/promocode-form/__tests__/promocode-form.test.js new file mode 100644 index 000000000..062e71a9d --- /dev/null +++ b/src/components/forms/promocode-form/__tests__/promocode-form.test.js @@ -0,0 +1,228 @@ +import React from "react"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { Formik, Form } from "formik"; +import moment from "moment-timezone"; +import PromocodeForm, { + buildPromocodeValues, + promocodeValidationSchema, + toPromocodeEntity +} from "../index"; +import { DEFAULT_ENTITY } from "../../../../reducers/promocodes/promocode-reducer"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("openstack-uicore-foundation/lib/utils/query-actions", () => ({ + queryTags: jest.fn(), + queryMembers: jest.fn(), + querySpeakers: jest.fn(), + querySpeakersRaw: jest.fn(), + querySponsorsV2: jest.fn(), + queryTicketTypes: jest.fn() +})); + +jest.mock("../../../tables/dicount-ticket-table", () => ({ + DiscountTicketTable: () =>
+})); + +const SUMMIT = { + id: 1, + time_zone_id: "America/Chicago", + badge_features: [{ id: 5, name: "VIP" }], + ticket_types: [], + default_ticket_type_currency_symbol: "$" +}; + +const ALL_CLASSES = [ + { class_name: "MEMBER_PROMO_CODE", type: ["VIP"] }, + { class_name: "SPONSOR_PROMO_CODE", type: [] }, + { class_name: "SUMMIT_DISCOUNT_CODE", type: [] }, + { class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE", type: [] } +]; + +const renderForm = (entityOverrides = {}, onSubmit = jest.fn()) => { + const entity = { ...DEFAULT_ENTITY, ...entityOverrides }; + render( + + {({ values }) => ( +
+ +
{JSON.stringify(values)}
+ + + )} +
+ ); + return onSubmit; +}; + +const formValues = () => JSON.parse(screen.getByTestId("values").textContent); + +describe("PromocodeForm", () => { + test("picking another class starts the form over for that class", async () => { + renderForm({ class_name: "SUMMIT_DISCOUNT_CODE", code: "OLD" }); + + await userEvent.click( + screen.getAllByRole("combobox").find((el) => el.id === "class_name") + ); + await userEvent.click( + within(screen.getByRole("listbox")).getByRole("option", { + name: "MEMBER_PROMO_CODE" + }) + ); + + expect(formValues()).toMatchObject({ + class_name: "MEMBER_PROMO_CODE", + code: "" + }); + // member codes have types and an owner + expect(screen.getByText("edit_promocode.type *")).toBeInTheDocument(); + expect( + screen.getByText("edit_purchase_order.owner_email *") + ).toBeInTheDocument(); + }); + + test("a domain-authorized code shows its domains, max per account and auto apply", () => { + renderForm({ class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE" }); + + expect(screen.getByTestId("allowed-email-domains-row")).toBeInTheDocument(); + expect( + screen.getByText("edit_promocode.quantity_per_account") + ).toBeInTheDocument(); + expect(screen.getByText("edit_promocode.auto_apply")).toBeInTheDocument(); + }); + + test("a sponsor code has no auto apply", () => { + renderForm({ class_name: "SPONSOR_PROMO_CODE" }); + + expect( + screen.queryByText("edit_promocode.auto_apply") + ).not.toBeInTheDocument(); + }); + + test("a saved malformed domain blocks the save", async () => { + const onSubmit = renderForm({ + class_name: "DOMAIN_AUTHORIZED_DISCOUNT_CODE", + allowed_email_domains: ["@ok.com", "not a domain"] + }); + + await userEvent.click(screen.getByRole("button", { name: "submit" })); + + expect( + await screen.findByText( + "edit_promocode.errors.allowed_email_domains_format" + ) + ).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + test("domains are not validated for other classes", async () => { + const onSubmit = renderForm({ + class_name: "SUMMIT_DISCOUNT_CODE", + allowed_email_domains: ["not a domain"] + }); + + await userEvent.click(screen.getByRole("button", { name: "submit" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + }); + + test("unchecking apply to all ticket types clears the amount and rate", async () => { + renderForm({ class_name: "SUMMIT_DISCOUNT_CODE", amount: 10, rate: 0 }); + + await userEvent.click( + screen.getByRole("checkbox", { name: "edit_promocode.apply_to_all_tix" }) + ); + + expect(formValues()).toMatchObject({ + apply_to_all_tix: false, + amount: 0, + rate: 0 + }); + // a new code has no id to attach ticket type rates to yet + expect( + screen.getByText("edit_promocode.save_before_ticket_rates") + ).toBeInTheDocument(); + }); + + test("a saved discount code edits its ticket type rates in the table", () => { + renderForm({ + id: 7, + class_name: "SUMMIT_DISCOUNT_CODE", + apply_to_all_tix: false + }); + + expect(screen.getByTestId("discount-ticket-table")).toBeInTheDocument(); + }); +}); + +describe("promo code form values", () => { + const entity = { + ...DEFAULT_ENTITY, + id: 3, + class_name: "SPONSOR_PROMO_CODE", + email: "owner@x.com", + first_name: "Ann", + last_name: "Lee", + owner: { email: "owner@x.com", first_name: "Ann", last_name: "Lee" }, + sponsor: { id: 9, company: { name: "Acme" } }, + allowed_ticket_types: [{ id: 11, name: "General" }], + badge_features: [5], + valid_since_date: 1767225600, // 2026-01-01 00:00 UTC + speakers: { ...DEFAULT_ENTITY.speakers, speakers_list: [{ id: 1 }] } + }; + + test("load the sponsor by company name and ticket types as options", () => { + const values = buildPromocodeValues(entity, SUMMIT); + + expect(values.sponsor).toEqual({ id: 9, name: "Acme" }); + expect(values.allowed_ticket_types).toEqual([ + { value: 11, label: "General" } + ]); + expect(values.valid_until_date).toBeNull(); + expect(values.valid_since_date.tz()).toBe("America/Chicago"); + }); + + test("save dates at 00:00 summit time, ticket types as ids, and keep the assignments kept in redux", () => { + const values = { + ...buildPromocodeValues(entity, SUMMIT), + first_name: "Bea", + valid_since_date: moment.tz("2026-03-10 15:30", "America/Chicago") + }; + + const saved = toPromocodeEntity(values, entity, SUMMIT); + + expect(saved.valid_since_date).toBe( + moment.tz("2026-03-10", "America/Chicago").unix() + ); + expect(saved.valid_until_date).toBe(""); + expect(saved.allowed_ticket_types).toEqual([11]); + expect(saved.owner).toEqual({ + email: "owner@x.com", + first_name: "Bea", + last_name: "Lee" + }); + expect(saved.speakers).toBe(entity.speakers); + }); +}); diff --git a/src/components/forms/promocode-form/forms/__tests__/auto-apply-checkbox.test.jsx b/src/components/forms/promocode-form/forms/__tests__/auto-apply-checkbox.test.jsx deleted file mode 100644 index 01726d693..000000000 --- a/src/components/forms/promocode-form/forms/__tests__/auto-apply-checkbox.test.jsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from "react"; -import { render, fireEvent, screen } from "@testing-library/react"; -import AutoApplyCheckbox from "../auto-apply-checkbox"; - -const baseEntity = (overrides = {}) => ({ - auto_apply: false, - ...overrides -}); - -describe("AutoApplyCheckbox", () => { - it("renders unchecked when entity.auto_apply is false", () => { - const { container } = render( - {}} /> - ); - const cb = container.querySelector("#auto_apply"); - expect(cb).toBeInTheDocument(); - expect(cb.checked).toBe(false); - }); - - it("renders checked when entity.auto_apply is true", () => { - const { container } = render( - {}} - /> - ); - expect(container.querySelector("#auto_apply").checked).toBe(true); - }); - - it("calls handleChange with checkbox event shape on toggle", () => { - const handleChange = jest.fn(); - const { container } = render( - - ); - fireEvent.click(container.querySelector("#auto_apply")); - expect(handleChange).toHaveBeenCalledTimes(1); - expect(handleChange).toHaveBeenCalledWith({ - target: { - id: "auto_apply", - type: "checkbox", - value: true, - checked: true - } - }); - }); - - it("does not render the caption by default (member/speaker fragment shape)", () => { - render( {}} />); - expect( - screen.queryByText("edit_promocode.captions.auto_apply") - ).not.toBeInTheDocument(); - }); - - it("renders the caption when withCaption is set (DomainAuthorized leaf shape)", () => { - render( - {}} - withCaption - /> - ); - expect( - screen.getByText("edit_promocode.captions.auto_apply") - ).toBeInTheDocument(); - }); - - it("applies marginTop:10px when marginTop is set", () => { - const { container } = render( - {}} - marginTop - /> - ); - expect(container.firstChild).toHaveStyle({ marginTop: "10px" }); - }); -}); diff --git a/src/components/forms/promocode-form/forms/auto-apply-checkbox.jsx b/src/components/forms/promocode-form/forms/auto-apply-checkbox.jsx index 42104de2a..68babd334 100644 --- a/src/components/forms/promocode-form/forms/auto-apply-checkbox.jsx +++ b/src/components/forms/promocode-form/forms/auto-apply-checkbox.jsx @@ -1,46 +1,23 @@ import React from "react"; import T from "i18n-react"; -import { fireChange } from "./domain-authorized/utils"; +import { Typography } from "@mui/material"; +import CheckboxWithInfo from "./checkbox-with-info"; -// Shared `auto_apply` checkbox. Used by: -// - DomainAuthorized leaf (orchestrator's .checkboxes-div) with -// {withCaption, marginTop} — host provides no row context. -// - Member/Speaker fragments — host wraps in its own .row.form-group. -const AutoApplyCheckbox = ({ - entity, - handleChange, - withCaption = false, - marginTop = false -}) => ( -
- - fireChange(handleChange, "auto_apply", ev.target.checked, "checkbox", { - checked: ev.target.checked - }) - } - className="form-check-input" +// Shared `auto_apply` checkbox. The DomainAuthorized form shows it with the caption, +// the Member/Speaker forms without it. +const AutoApplyCheckbox = ({ withCaption = false }) => ( + <> + -