From 91467f306db23d30c0f853c0bfd46fbe6ee8a892 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 20 Oct 2026 15:26:39 -0300 Subject: [PATCH 1/4] chore: refactor grid --- src/actions/promocode-actions.js | 38 +- src/components/import-promocodes/index.js | 145 ++-- src/i18n/en.json | 10 +- .../promocode-list-page.helpers.test.js | 60 ++ .../__tests__/promocode-list-page.test.js | 271 +++++++ .../promocodes/promocode-list-page.helpers.js | 102 +++ src/pages/promocodes/promocode-list-page.js | 700 +++++------------- .../promocodes/promocode-list-reducer.js | 13 +- 8 files changed, 704 insertions(+), 635 deletions(-) create mode 100644 src/pages/promocodes/__tests__/promocode-list-page.helpers.test.js create mode 100644 src/pages/promocodes/__tests__/promocode-list-page.test.js create mode 100644 src/pages/promocodes/promocode-list-page.helpers.js diff --git a/src/actions/promocode-actions.js b/src/actions/promocode-actions.js index 6cc3463c0..0740c4a70 100644 --- a/src/actions/promocode-actions.js +++ b/src/actions/promocode-actions.js @@ -28,7 +28,7 @@ import { 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 +59,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 +79,7 @@ const parseFilters = (filters, term = null) => { filter.push(searchString); } - return checkOrFilter(filters, filter); + return filter; }; const normalizeEntity = (entity) => { @@ -188,7 +164,7 @@ export const getPromocodes = perPage = DEFAULT_PER_PAGE, order = "code", orderDir = 1, - filters = {}, + filters = [], extraColumns = [] ) => async (dispatch, getState) => { @@ -370,7 +346,7 @@ export const sendEmail = (promocodeId) => async (dispatch, getState) => { }; 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(); diff --git a/src/components/import-promocodes/index.js b/src/components/import-promocodes/index.js index f3e928401..544d73fdc 100644 --- a/src/components/import-promocodes/index.js +++ b/src/components/import-promocodes/index.js @@ -1,7 +1,7 @@ import React, { useState } from "react"; import T from "i18n-react"; -import { Modal } from "react-bootstrap"; -import UploadInput from "openstack-uicore-foundation/lib/components/inputs/upload-input"; +import Button from "@mui/material/Button"; +import ImportModal from "../inputs/import-modal"; const ImportPromocodesBtn = ({ onImport, @@ -10,104 +10,73 @@ const ImportPromocodesBtn = ({ showContactEmail = false, allowedClasses = [] }) => { - const [file, setFile] = useState(null); const [showModal, setShowModal] = useState(false); - const handleImport = () => { + const handleImport = (file) => { if (file) { onImport(file); } - setFile(null); setShowModal(false); }; return ( <> - - - setShowModal(false)}> - - - {T.translate("promocode_list.import_promocodes")} - - - -
-
- File must be a CSV file the following format: -
-
    -
  • - code: text -
  • -
  • - class_name: text{" "} - {allowedClasses.length ? `(${allowedClasses.join(",")})` : ""} -
  • -
  • - quantity_available: int -
  • -
  • - badge_features: list of badge feature ids pipe - delimited (optional) -
  • -
  • - allowed_ticket_types: list of allowed ticket type ids - pipe delimited (optional) -
  • - {showSpeakers && ( -
  • - speaker_ids: list of badge speaker ids pipe delimited - (optional) -
  • - )} - {showSponsorId && ( -
  • - sponsor_id: id of the sponsor (optional) -
  • - )} - {showContactEmail && ( -
  • - contact_email: contact email for the promo code - (optional) -
  • - )} -
  • - notes: text (optional) -
  • -
  • - allows_to_reassign: true/false (optional, default true) -
  • -
  • - allows_to_delegate: true/false (optional, default true) -
  • -
-
-
- setFile(file)} - handleRemove={() => setFile(null)} - className="dropzone col-md-6" - multiple={false} - accept=".csv" - /> -
-
-
- - - -
+ + setShowModal(false)} + onIngest={handleImport} + > + + ); }; diff --git a/src/i18n/en.json b/src/i18n/en.json index 2883d16ee..6f68f4e67 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1049,18 +1049,14 @@ "emailed": "Emailed", "redeemed": "Redeemed", "creator": "Creator", + "class_name": "Class Name", + "assignee": "Assignee", "remove_promocode_warning": "Are you sure you want to delete promo code for attendee", "import": "Import", "import_promocodes": "Import Promo Codes", - "ingest": "Ingest File", "placeholders": { "search_promocodes": "Search by Code, Speaker, Member, Email, Company", - "select_type": "Select a Type", - "filter_class_name": "Filter by Class Name", - "filter_tags": "Filter by Tags", - "filter_type": "Filter by Type", - "filter_creator": "Filter by Creator", - "filter_assignee": "Filter by Assignee" + "select_type": "Select a Type" } }, "edit_promocode": { diff --git a/src/pages/promocodes/__tests__/promocode-list-page.helpers.test.js b/src/pages/promocodes/__tests__/promocode-list-page.helpers.test.js new file mode 100644 index 000000000..9d5a3199f --- /dev/null +++ b/src/pages/promocodes/__tests__/promocode-list-page.helpers.test.js @@ -0,0 +1,60 @@ +import { getCriterias } from "../promocode-list-page.helpers"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("openstack-uicore-foundation/lib/utils/query-actions", () => ({ + queryTags: jest.fn(), + queryMembers: jest.fn() +})); + +const criterias = getCriterias( + ["ALL", "VIP", "SPONSOR"], + [{ class_name: "MEMBER_PROMO_CODE" }, { class_name: "SPEAKER_PROMO_CODE" }] +); +const byKey = (key) => criterias.find((c) => c.key === key); +const member = { value: 42, label: "Jane Doe", raw: { email: "jane@x.com" } }; + +describe("promocode list getCriterias", () => { + test("type offers the real types only, not the ALL placeholder, and filters by type", () => { + const type = byKey("type"); + expect(type.values.props.options.map((o) => o.value)).toEqual([ + "VIP", + "SPONSOR" + ]); + expect(type.customParser({ value: "VIP" })).toEqual(["type==VIP"]); + }); + + test("class name joins the picked classes with ||", () => { + expect( + byKey("class_name").customParser({ + value: ["MEMBER_PROMO_CODE", "SPEAKER_PROMO_CODE"] + }) + ).toEqual(["class_name==MEMBER_PROMO_CODE||SPEAKER_PROMO_CODE"]); + }); + + test("tags filter by tag id", () => { + expect( + byKey("tags").customParser({ + value: [ + { value: 3, label: "a" }, + { value: 7, label: "b" } + ] + }) + ).toEqual(["tag_id==3||7"]); + }); + + test("creator filters by the creator's email only", () => { + expect(byKey("creator").customParser({ value: member })).toEqual([ + "creator_email==jane@x.com" + ]); + }); + + test("assignee matches the owner or the contact email", () => { + expect(byKey("assignee").customParser({ value: member })).toEqual([ + "owner_email==jane@x.com,contact_email==jane@x.com" + ]); + }); +}); diff --git a/src/pages/promocodes/__tests__/promocode-list-page.test.js b/src/pages/promocodes/__tests__/promocode-list-page.test.js new file mode 100644 index 000000000..ee6592288 --- /dev/null +++ b/src/pages/promocodes/__tests__/promocode-list-page.test.js @@ -0,0 +1,271 @@ +import React from "react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { Provider } from "react-redux"; +import { applyMiddleware, combineReducers, createStore } from "redux"; +import thunk from "redux-thunk"; +import { + getCSV, + getRequest +} from "openstack-uicore-foundation/lib/utils/actions"; +import { + JOIN_OPERATORS, + allFiltersReducer, + saveFilters +} from "openstack-uicore-foundation/lib/components/mui/grid-filter"; +import PromocodeListPage from "../promocode-list-page"; +import promocodeListReducer from "../../../reducers/promocodes/promocode-list-reducer"; +import * as methods from "../../../utils/methods"; + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + getRequest: jest.fn(), + getCSV: jest.fn(() => ({ type: "MOCK_GET_CSV" })) +})); + +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: (input, callback) => + callback([ + { + id: 42, + first_name: "Jane", + last_name: "Doe", + email: "jane@example.com" + } + ]) +})); + +// stubs the async member select so the test can pick an option without the real popper +jest.mock("@mui/material/Autocomplete", () => ({ + __esModule: true, + default: ({ onChange, options }) => ( + + ) +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/mui/search-input", + () => ({ + __esModule: true, + default: ({ onSearch }) => ( + + ) + }) +); + +jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ + __esModule: true, + default: ({ data, columns, canDelete }) => ( +
+
+ {columns.map((col) => ( + {col.header} + ))} +
+ {data.map((row) => ( +
+ {row.code} + {canDelete(row) && } +
+ ))} +
+ ) +})); + +const FILTER_ID = "promocode_list"; +const PROMOCODES_URL = "/api/v1/summits/1/promo-codes"; + +const buildStore = (listOverrides = {}) => + createStore( + combineReducers({ + allGridFiltersState: allFiltersReducer, + currentSummitState: (state = { currentSummit: { id: 1, name: "S" } }) => + state, + currentPromocodeListState: promocodeListReducer + }), + { + currentPromocodeListState: { + ...promocodeListReducer(undefined, {}), + ...listOverrides + } + }, + applyMiddleware(thunk) + ); + +const renderPage = (store) => + render( + + + + ); + +// filter[] of the last promo codes list request +const lastListFilter = () => { + const calls = getRequest.mock.calls.filter(([, , url]) => + url.endsWith(PROMOCODES_URL) + ); + return calls[calls.length - 1].params["filter[]"]; +}; + +describe("PromocodeListPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + window.API_BASE_URL = ""; + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + getRequest.mockImplementation(() => { + const call = getRequest.mock.calls[getRequest.mock.calls.length - 1]; + return (params) => { + call.params = params; + return () => Promise.resolve({ response: { data: [] } }); + }; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("applying a creator filter from the grid filter requests promo codes by creator email", async () => { + renderPage(buildStore()); + + await userEvent.click( + screen.getByRole("button", { name: "grid_filter.open_filters" }) + ); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getAllByRole("combobox")[0]); + await userEvent.click( + await screen.findByRole("option", { name: "promocode_list.creator" }) + ); + const selectMember = await screen.findByTestId("select-member-option"); + await waitFor(() => expect(selectMember).toBeEnabled()); + await userEvent.click(selectMember); + await userEvent.click( + within(dialog).getByRole("button", { name: "grid_filter.apply_filters" }) + ); + + await waitFor(() => + expect(lastListFilter()).toEqual(["creator_email==jane@example.com"]) + ); + }); + + test("joining filters by ANY wraps each one in or(), while the search term stays ANDed", async () => { + const store = buildStore(); + store.dispatch( + saveFilters( + FILTER_ID, + [ + { criteria: "type", operator: "==", value: "X", parsed: ["type==X"] }, + { + criteria: "class_name", + operator: "==", + value: ["A", "B"], + parsed: ["class_name==A||B"] + } + ], + JOIN_OPERATORS.ANY + ) + ); + renderPage(store); + + await waitFor(() => + expect(lastListFilter()).toEqual(["or(type==X)", "or(class_name==A||B)"]) + ); + + await userEvent.click(screen.getByRole("button", { name: "search-acme" })); + + await waitFor(() => { + const filter = lastListFilter(); + expect(filter.slice(0, 2)).toEqual([ + "or(type==X)", + "or(class_name==A||B)" + ]); + expect(filter[2]).toMatch(/^code=@acme,/); + }); + }); + + test("exports with the applied filters, search term and order", async () => { + const store = buildStore({ term: "acme", order: "id", orderDir: -1 }); + store.dispatch( + saveFilters( + FILTER_ID, + [{ criteria: "type", operator: "==", value: "X", parsed: ["type==X"] }], + JOIN_OPERATORS.ALL + ) + ); + renderPage(store); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "general.export" }) + ); + }); + + await waitFor(() => expect(getCSV).toHaveBeenCalled()); + const [url, params] = getCSV.mock.calls[0]; + expect(url).toBe("/api/v1/summits/1/promo-codes/csv"); + expect(params["filter[]"][0]).toBe("type==X"); + expect(params["filter[]"][1]).toMatch(/^code=@acme,/); + expect(params.order).toBe("-id"); + }); + + test("offers delete only for promo codes that were not redeemed", () => { + renderPage( + buildStore({ + promocodes: [ + { id: 1, code: "FREE", redeemed: "No" }, + { id: 2, code: "USED", redeemed: "Yes" } + ] + }) + ); + + expect( + within(screen.getByTestId("row-1")).getByRole("button", { + name: "delete" + }) + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("row-2")).queryByRole("button", { + name: "delete" + }) + ).not.toBeInTheDocument(); + }); + + test("shows the optional columns remembered from the last visit", () => { + renderPage( + buildStore({ + promocodes: [{ id: 1, code: "FREE", redeemed: "No" }], + extraColumns: ["owner_email"] + }) + ); + + const headers = screen.getByTestId("headers"); + expect( + within(headers).getByText("promocode_list.owner_email") + ).toBeInTheDocument(); + expect( + within(headers).queryByText("promocode_list.creator") + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/promocodes/promocode-list-page.helpers.js b/src/pages/promocodes/promocode-list-page.helpers.js new file mode 100644 index 000000000..a126466d1 --- /dev/null +++ b/src/pages/promocodes/promocode-list-page.helpers.js @@ -0,0 +1,102 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import T from "i18n-react/dist/i18n-react"; +import { OPERATORS } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; +import { + queryMembers, + queryTags +} from "openstack-uicore-foundation/lib/utils/query-actions"; + +const memberFormatOption = (member) => { + const fullName = `${member.first_name ?? ""} ${member.last_name ?? ""}`; + return { + value: member.id, + label: member.email + ? `${fullName} (${member.email})` + : `${fullName} (${member.id})` + }; +}; + +const tagFormatOption = (tag) => ({ value: tag.id, label: tag.tag }); + +// "ALL" is the reducer's no-filter placeholder, not a real type +export const getCriterias = (allTypes, allClasses) => [ + { + key: "type", + label: T.translate("promocode_list.type"), + operators: [OPERATORS.IS], + values: { + type: "select", + props: { + options: allTypes + .filter((t) => t !== "ALL") + .map((t) => ({ label: t, value: t })) + } + }, + customParser: (f) => [`type==${f.value}`] + }, + { + key: "class_name", + label: T.translate("promocode_list.class_name"), + operators: [OPERATORS.IS], + values: { + type: "select", + props: { + options: allClasses.map((c) => ({ + label: c.class_name, + value: c.class_name + })), + multiple: true + } + }, + customParser: (f) => [`class_name==${f.value.join("||")}`] + }, + { + key: "tags", + label: T.translate("promocode_list.tags"), + operators: [OPERATORS.IS], + values: { + type: "asyncSelect", + props: { + queryFunction: (input, callback) => queryTags(null, input, callback), + formatOption: tagFormatOption, + multiple: true + } + }, + customParser: (f) => [`tag_id==${f.value.map((t) => t.value).join("||")}`] + }, + { + key: "creator", + label: T.translate("promocode_list.creator"), + operators: [OPERATORS.IS], + values: { + type: "asyncSelect", + props: { queryFunction: queryMembers, formatOption: memberFormatOption } + }, + customParser: (f) => [`creator_email==${f.value.raw.email}`] + }, + { + key: "assignee", + label: T.translate("promocode_list.assignee"), + operators: [OPERATORS.IS], + values: { + type: "asyncSelect", + props: { queryFunction: queryMembers, formatOption: memberFormatOption } + }, + // the assignee is either the owner or the contact of the promo code + customParser: (f) => [ + `owner_email==${f.value.raw.email},contact_email==${f.value.raw.email}` + ] + } +]; diff --git a/src/pages/promocodes/promocode-list-page.js b/src/pages/promocodes/promocode-list-page.js index d34c473ec..a46042075 100644 --- a/src/pages/promocodes/promocode-list-page.js +++ b/src/pages/promocodes/promocode-list-page.js @@ -11,17 +11,17 @@ * limitations under the License. * */ -import React from "react"; +import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; -import Swal from "sweetalert2"; -import { Pagination } from "react-bootstrap"; -import FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search" -import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown" -import MemberInput from "openstack-uicore-foundation/lib/components/inputs/member-input" -import Table from "openstack-uicore-foundation/lib/components/table" -import TagInput from "openstack-uicore-foundation/lib/components/inputs/tag-input"; -import { getSummitById } from "../../actions/summit-actions"; +import { Box, Button, Divider } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; +import MuiDropdown from "openstack-uicore-foundation/lib/components/mui/dropdown"; +import { + GridFilter, + useGridFilter +} from "openstack-uicore-foundation/lib/components/mui/grid-filter"; import { getPromocodes, getPromocodeMeta, @@ -29,20 +29,23 @@ import { exportPromocodes, importPromoCodesCSV } from "../../actions/promocode-actions"; -import OrAndFilter from "../../components/filters/or-and-filter"; import ImportPromocodesBtn from "../../components/import-promocodes"; +import GridToolbar from "../../components/mui/grid-toolbar"; import { + DEFAULT_CURRENT_PAGE, TRIM_TEXT_LENGTH_50, TRIM_TEXT_LENGTH_40 } from "../../utils/constants"; -import { trim } from "../../utils/methods"; +import { handleDDLSortByLabel, trim } from "../../utils/methods"; +import { getCriterias } from "./promocode-list-page.helpers"; + +const FILTER_ID = "promocode_list"; const fieldNames = [ - { columnKey: "class_name", value: "type" }, + { columnKey: "class_name", header: "type" }, { columnKey: "description", - value: "description", - title: true, + header: "description", render: (row) => row.description?.length > TRIM_TEXT_LENGTH_50 ? ( @@ -52,529 +55,233 @@ const fieldNames = [ row.description ) }, - { columnKey: "tags", value: "tags" }, - { columnKey: "owner_email", value: "owner_email" }, - { columnKey: "owner", value: "owner" }, - { columnKey: "email_sent", value: "emailed" }, - { columnKey: "creator", value: "creator" } + { columnKey: "tags", header: "tags" }, + { + columnKey: "owner_email", + header: "owner_email", + render: (row) => ( + + {trim(row.owner_email, TRIM_TEXT_LENGTH_40)} + + ) + }, + { + columnKey: "owner", + header: "owner", + render: (row) => ( + {trim(row.owner, TRIM_TEXT_LENGTH_40)} + ) + }, + { columnKey: "email_sent", header: "emailed" }, + { columnKey: "creator", header: "creator" } ]; -class PromocodeListPage extends React.Component { - constructor(props) { - super(props); - +const PromocodeListPage = ({ + currentSummit, + promocodes, + currentPage, + perPage, + term, + order, + orderDir, + totalPromocodes, + allClasses, + allTypes, + extraColumns, + history, + ...props +}) => { + const { parsedFilter } = useGridFilter(FILTER_ID); + const [selectedColumns, setSelectedColumns] = useState(extraColumns ?? []); + + useEffect(() => { props.getPromocodeMeta(); + }, []); - this.handleEdit = this.handleEdit.bind(this); - this.handleDelete = this.handleDelete.bind(this); - this.isNotRedeemed = this.isNotRedeemed.bind(this); - this.handlePageChange = this.handlePageChange.bind(this); - this.handleTypeChange = this.handleTypeChange.bind(this); - this.handleClassNameChange = this.handleClassNameChange.bind(this); - this.handleTagsChange = this.handleTagsChange.bind(this); - this.handleSort = this.handleSort.bind(this); - this.handleSearch = this.handleSearch.bind(this); - this.handleNewPromocode = this.handleNewPromocode.bind(this); - this.handleExport = this.handleExport.bind(this); - this.handleColumnsChange = this.handleColumnsChange.bind(this); - this.handleDDLSortByLabel = this.handleDDLSortByLabel.bind(this); - this.handleAssigneeChange = this.handleAssigneeChange.bind(this); - this.handleCreatorChange = this.handleCreatorChange.bind(this); - this.handleOrAndFilter = this.handleOrAndFilter.bind(this); - - this.state = { - selectedColumns: [] - }; - } - - componentDidMount() { - const { - currentSummit, - term, - currentPage, - extraColumns, - perPage, - order, - orderDir, - filters - } = this.props; - this.setState({ - ...this.state, - selectedColumns: extraColumns - }); - if (currentSummit) { - this.props.getPromocodes( + useEffect(() => { + if (currentSummit?.id) { + props.getPromocodes( term, - currentPage, + DEFAULT_CURRENT_PAGE, perPage, order, orderDir, - filters, - extraColumns + parsedFilter, + selectedColumns ); } - } - - handleEdit(promocode_id) { - const { currentSummit, history } = this.props; - history.push(`/app/summits/${currentSummit.id}/promocodes/${promocode_id}`); - } - - handleExport(ev) { - const { term, order, orderDir, filters } = this.props; - ev.preventDefault(); - - this.props.exportPromocodes(term, order, orderDir, filters); - } - - handleDelete(promocodeId) { - const { deletePromocode, promocodes } = this.props; - const promocode = promocodes.find((p) => p.id === promocodeId); - - Swal.fire({ - title: T.translate("general.are_you_sure"), - text: `${T.translate("promocode_list.remove_promocode_warning")} ${ - promocode.owner - }`, - type: "warning", - showCancelButton: true, - confirmButtonColor: "#DD6B55", - confirmButtonText: T.translate("general.yes_delete") - }).then((result) => { - if (result.value) { - deletePromocode(promocodeId); - } - }); - } - - isNotRedeemed(promocodeId) { - const { promocodes } = this.props; - const promocode = promocodes.find((a) => a.id === promocodeId); - - return promocode.redeemed === "No"; - } - - handlePageChange(page) { - const { term, order, orderDir, perPage, filters } = this.props; - const { selectedColumns } = this.state; - this.props.getPromocodes( - term, - page, - perPage, - order, - orderDir, - filters, - selectedColumns - ); - } - - handleTypeChange(type) { - const { term, order, orderDir, perPage, page, filters } = this.props; - const { selectedColumns } = this.state; - filters.typeFilter = type.target.value; - this.props.getPromocodes( - term, - page, - perPage, - order, - orderDir, - filters, - selectedColumns - ); - } + }, [currentSummit?.id, parsedFilter.join(",")]); - handleClassNameChange(classNames) { - const { term, order, orderDir, perPage, page, filters } = this.props; - const { selectedColumns } = this.state; - filters.classNamesFilter = classNames.target.value; - this.props.getPromocodes( + const handlePageChange = (page) => { + props.getPromocodes( term, page, perPage, order, orderDir, - filters, + parsedFilter, selectedColumns ); - } + }; - handleTagsChange(ev) { - const { term, order, orderDir, page, perPage, filters } = this.props; - const { selectedColumns } = this.state; - filters.tagsFilter = ev.target.value; - this.props.getPromocodes( + const handlePerPageChange = (newPerPage) => { + props.getPromocodes( term, - page, - perPage, - order, - orderDir, - filters, - selectedColumns - ); - } - - handleAssigneeChange(ev) { - const { term, order, orderDir, page, perPage, filters } = this.props; - const { selectedColumns } = this.state; - filters.assigneeFilter = ev.target.value; - this.props.getPromocodes( - term, - page, - perPage, + DEFAULT_CURRENT_PAGE, + newPerPage, order, orderDir, - filters, + parsedFilter, selectedColumns ); - } + }; - handleCreatorChange(ev) { - const { term, order, orderDir, page, perPage, filters } = this.props; - const { selectedColumns } = this.state; - filters.creatorFilter = ev.target.value; - this.props.getPromocodes( + const handleSort = (key, dir) => { + props.getPromocodes( term, - page, - perPage, - order, - orderDir, - filters, - selectedColumns - ); - } - - handleSort(index, key, dir) { - const { term, page, perPage, filters } = this.props; - key = key === "name" ? "last_name" : key; - const { selectedColumns } = this.state; - this.props.getPromocodes( - term, - page, + DEFAULT_CURRENT_PAGE, perPage, key, dir, - filters, - selectedColumns - ); - } - - handleSearch(term) { - const { order, orderDir, page, perPage, filters } = this.props; - const { selectedColumns } = this.state; - this.props.getPromocodes( - term, - page, - perPage, - order, - orderDir, - filters, + parsedFilter, selectedColumns ); - } - - handleNewPromocode() { - const { currentSummit, history } = this.props; - history.push(`/app/summits/${currentSummit.id}/promocodes/new`); - } - - handleColumnsChange(ev) { - const { value } = ev.target; - this.setState({ ...this.state, selectedColumns: value }); - } + }; - handleDDLSortByLabel(ddlArray) { - return ddlArray.sort((a, b) => a.label.localeCompare(b.label)); - } - - handleOrAndFilter(ev) { - const { term, order, orderDir, page, perPage, filters } = this.props; - const { selectedColumns } = this.state; - filters.orAndFilter = ev; - this.props.getPromocodes( - term, - page, + const handleSearch = (newTerm) => { + props.getPromocodes( + newTerm, + DEFAULT_CURRENT_PAGE, perPage, order, orderDir, - filters, + parsedFilter, selectedColumns ); - } + }; - render() { - const { - currentSummit, - promocodes, - lastPage, - currentPage, - term, - order, - orderDir, - totalPromocodes, - allClasses, - allTypes, - filters - } = this.props; + const handleEdit = (promocode) => { + history.push(`/app/summits/${currentSummit.id}/promocodes/${promocode.id}`); + }; - let columns = [ - { - columnKey: "id", - value: T.translate("promocode_list.id"), - sortable: true - }, - { - columnKey: "code", - value: T.translate("promocode_list.code"), - sortable: true - }, - { - columnKey: "redeemed", - value: T.translate("promocode_list.redeemed"), - sortable: true - } - ]; - - const ddl_columns = [ - { value: "class_name", label: T.translate("promocode_list.type") }, - { - value: "description", - label: T.translate("promocode_list.description") - }, - { - value: "owner", - label: T.translate("promocode_list.owner") - }, - { - value: "owner_email", - label: T.translate("promocode_list.owner_email") - }, - { value: "tags", label: T.translate("promocode_list.tags") }, - { value: "email_sent", label: T.translate("promocode_list.emailed") }, - { value: "creator", label: T.translate("promocode_list.creator") } - ]; - - const table_options = { - sortCol: order, - sortDir: orderDir, - actions: { - edit: { onClick: this.handleEdit }, - delete: { onClick: this.handleDelete, display: this.isNotRedeemed } - } - }; - - const showColumns = fieldNames - .filter((f) => this.state.selectedColumns.includes(f.columnKey)) - .map((f2) => { - let c = { - columnKey: f2.columnKey, - value: T.translate(`promocode_list.${f2.value}`), - sortable: f2.sortable - }; - // optional fields - if (f2.hasOwnProperty("title")) c = { ...c, title: f2.title }; - - if (f2.hasOwnProperty("render")) c = { ...c, render: f2.render }; - - return c; - }); - - columns = [...columns, ...showColumns]; - - if (!currentSummit.id) return
; - - const promocode_types_ddl = allTypes.map((t) => ({ label: t, value: t })); - - const promocode_classes_ddl = allClasses.map((c) => ({ - label: c.class_name, - value: c.class_name - })); - - const promocodesFormatted = promocodes.map((p) => ({ - ...p, - owner_email: ( - - {trim(p?.owner_email, TRIM_TEXT_LENGTH_40)} - - ), - owner: {trim(p?.owner, TRIM_TEXT_LENGTH_40)} - })); - - return ( -
-

- {" "} - {T.translate("promocode_list.promocode_list")} ({totalPromocodes}) -

- - this.handleOrAndFilter(filter)} + const handleNewPromocode = () => { + history.push(`/app/summits/${currentSummit.id}/promocodes/new`); + }; + + const handleExport = () => { + props.exportPromocodes(term, order, orderDir, parsedFilter); + }; + + const handleColumnsChange = (ev) => { + setSelectedColumns(ev.target.value); + }; + + const columns = [ + { + columnKey: "id", + header: T.translate("promocode_list.id"), + sortable: true + }, + { + columnKey: "code", + header: T.translate("promocode_list.code"), + sortable: true + }, + { + columnKey: "redeemed", + header: T.translate("promocode_list.redeemed"), + sortable: true + }, + ...fieldNames + .filter((f) => selectedColumns.includes(f.columnKey)) + .map((f) => ({ + columnKey: f.columnKey, + header: T.translate(`promocode_list.${f.header}`), + render: f.render + })) + ]; + + const ddlColumns = [ + { value: "class_name", label: T.translate("promocode_list.type") }, + { + value: "description", + label: T.translate("promocode_list.description") + }, + { value: "owner", label: T.translate("promocode_list.owner") }, + { value: "owner_email", label: T.translate("promocode_list.owner_email") }, + { value: "tags", label: T.translate("promocode_list.tags") }, + { value: "email_sent", label: T.translate("promocode_list.emailed") }, + { value: "creator", label: T.translate("promocode_list.creator") } + ]; + + return ( +
+

+ {T.translate("promocode_list.promocode_list")} ({totalPromocodes}) +

+ + + + + + + + + + -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- { - const fullName = `${member.first_name ?? ""} ${ - member.last_name ?? "" - }`; - return member.hasOwnProperty("email") - ? `${fullName} (${member.email})` - : `${fullName} (${member.id})`; - }} - onChange={this.handleCreatorChange} - isClearable - /> -
-
- { - const fullName = `${member.first_name ?? ""} ${ - member.last_name ?? "" - }`; - return member.hasOwnProperty("email") - ? `${fullName} (${member.email})` - : `${fullName} (${member.id})`; - }} - onChange={this.handleAssigneeChange} - isClearable - /> -
-
-
-
-
-
- - - -
-
- -
- -
-
- - -
-
- - {promocodes.length === 0 && ( -
{T.translate("promocode_list.no_promocodes")}
- )} - - {promocodes.length > 0 && ( -
- - - - )} - - ); - } -} + {promocodes.length === 0 ? ( +
{T.translate("promocode_list.no_promocodes")}
+ ) : ( + row.redeemed === "No"} + getName={(row) => row.owner} + deleteDialogBody={(name) => + `${T.translate("promocode_list.remove_promocode_warning")} ${name}` + } + /> + )} + + ); +}; const mapStateToProps = ({ currentSummitState, @@ -585,7 +292,6 @@ const mapStateToProps = ({ }); export default connect(mapStateToProps, { - getSummitById, getPromocodes, getPromocodeMeta, deletePromocode, diff --git a/src/reducers/promocodes/promocode-list-reducer.js b/src/reducers/promocodes/promocode-list-reducer.js index 8696e7aef..c85a0e4ca 100644 --- a/src/reducers/promocodes/promocode-list-reducer.js +++ b/src/reducers/promocodes/promocode-list-reducer.js @@ -19,24 +19,13 @@ import { PROMOCODE_DELETED } from "../../actions/promocode-actions"; -import { ALL_FILTER } from "../../utils/constants"; - import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; -const FILTERS_DEFAULT_STATE = { - assigneeFilter: null, - creatorFilter: null, - typeFilter: null, - classNamesFilter: [], - tagsFilter: [], - orAndFilter: ALL_FILTER -}; - const DEFAULT_STATE = { promocodes: [], tags: [], term: null, - filters: { ...FILTERS_DEFAULT_STATE }, + filters: [], classNames: [], type: "ALL", assignee: null, From a1a84955b6344ba17b98851335f1cba45afa18fb Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 20 Oct 2026 15:33:53 -0300 Subject: [PATCH 2/4] chore: refactor layout and fix delete message --- src/i18n/en.json | 2 +- src/layouts/promocode-layout.js | 59 ++++++++++----------- src/pages/promocodes/promocode-list-page.js | 6 +-- 3 files changed, 31 insertions(+), 36 deletions(-) diff --git a/src/i18n/en.json b/src/i18n/en.json index 6f68f4e67..38d4c0e98 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1051,7 +1051,7 @@ "creator": "Creator", "class_name": "Class Name", "assignee": "Assignee", - "remove_promocode_warning": "Are you sure you want to delete promo code for attendee", + "remove_promocode_warning": "Are you sure you want to delete promo code {code} ?", "import": "Import", "import_promocodes": "Import Promo Codes", "placeholders": { diff --git a/src/layouts/promocode-layout.js b/src/layouts/promocode-layout.js index 5a578eab2..e71d6ab26 100644 --- a/src/layouts/promocode-layout.js +++ b/src/layouts/promocode-layout.js @@ -9,7 +9,7 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. - **/ + * */ import React from "react"; import { Switch, Route, withRouter } from "react-router-dom"; @@ -21,37 +21,32 @@ import EditPromocodePage from "../pages/promocodes/edit-promocode-page"; import PromocodeListPage from "../pages/promocodes/promocode-list-page"; import NoMatchPage from "../pages/no-match-page"; -class PromocodeLayout extends React.Component { - render() { - const { match } = this.props; - return ( -
- +const PromocodeLayout = ({ match }) => ( +
+ - - - - - - -
- ); - } -} + + + + + + +
+); export default Restrict(withRouter(PromocodeLayout), "tickets"); diff --git a/src/pages/promocodes/promocode-list-page.js b/src/pages/promocodes/promocode-list-page.js index a46042075..5e6bd0284 100644 --- a/src/pages/promocodes/promocode-list-page.js +++ b/src/pages/promocodes/promocode-list-page.js @@ -273,9 +273,9 @@ const PromocodeListPage = ({ onEdit={handleEdit} onDelete={props.deletePromocode} canDelete={(row) => row.redeemed === "No"} - getName={(row) => row.owner} - deleteDialogBody={(name) => - `${T.translate("promocode_list.remove_promocode_warning")} ${name}` + getName={(row) => row.code} + deleteDialogBody={(code) => + T.translate("promocode_list.remove_promocode_warning", { code }) } /> )} From ae56bc9e8698b3407f875c125a533cf482c2bc15 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 21 Oct 2026 11:41:54 -0300 Subject: [PATCH 3/4] chore: migrate promocode edit --- src/actions/promocode-actions.js | 125 ++- .../promocode-form.integration.test.js | 865 ------------------ .../__tests__/promocode-form.test.js | 228 +++++ .../__tests__/auto-apply-checkbox.test.jsx | 77 -- .../forms/auto-apply-checkbox.jsx | 49 +- .../promocode-form/forms/base-pc-form.js | 266 +++--- .../forms/checkbox-with-info.js | 30 + .../forms/discount-base-pc-form.js | 145 +-- .../AllowedEmailDomainsRow.jsx | 314 +++---- .../ManageAllowedEmailDomainsModal.jsx | 138 ++- .../domain-authorized/MaxPerAccountInput.jsx | 50 +- .../allowed-email-domains-row.test.jsx | 620 ++----------- .../__tests__/max-per-account-input.test.jsx | 68 -- .../domain-authorized/__tests__/utils.test.js | 22 +- .../forms/domain-authorized/utils.js | 13 - .../promocode-form/forms/email-redeem-form.js | 63 +- .../forms/generic-base-pc-form.js | 53 +- .../promocode-form/forms/label-with-info.js | 30 + .../forms/member-base-pc-form.js | 108 ++- .../forms/speaker-base-pc-form.js | 55 +- .../forms/speakers-base-pc-form.js | 312 +++---- .../forms/sponsor-base-pc-form.js | 45 +- .../forms/promocode-form/forms/tags-input.js | 53 ++ src/components/forms/promocode-form/index.js | 791 +++++----------- src/i18n/en.json | 3 +- .../__tests__/edit-promocode-page.test.js | 144 +++ src/pages/promocodes/edit-promocode-page.js | 205 +++-- src/styles/edit-promocode-page.less | 6 - 28 files changed, 1746 insertions(+), 3132 deletions(-) delete mode 100644 src/components/forms/promocode-form/__tests__/promocode-form.integration.test.js create mode 100644 src/components/forms/promocode-form/__tests__/promocode-form.test.js delete mode 100644 src/components/forms/promocode-form/forms/__tests__/auto-apply-checkbox.test.jsx create mode 100644 src/components/forms/promocode-form/forms/checkbox-with-info.js delete mode 100644 src/components/forms/promocode-form/forms/domain-authorized/__tests__/max-per-account-input.test.jsx create mode 100644 src/components/forms/promocode-form/forms/label-with-info.js create mode 100644 src/components/forms/promocode-form/forms/tags-input.js create mode 100644 src/pages/promocodes/__tests__/edit-promocode-page.test.js delete mode 100644 src/styles/edit-promocode-page.less diff --git a/src/actions/promocode-actions.js b/src/actions/promocode-actions.js index 0740c4a70..042c99728 100644 --- a/src/actions/promocode-actions.js +++ b/src/actions/promocode-actions.js @@ -20,14 +20,12 @@ import { createAction, stopLoading, startLoading, - showMessage, - showSuccessMessage, getCSV, - authErrorHandler, + snackbarErrorHandler, + snackbarSuccessHandler, escapeFilterValue, postFile } from "openstack-uicore-foundation/lib/utils/actions"; -import history from "../history"; import { getAccessTokenSafely } from "../utils/methods"; import { DEFAULT_PER_PAGE } from "../utils/constants"; @@ -151,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()); }); @@ -206,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()); @@ -232,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()); }); @@ -242,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(); @@ -312,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()); }); @@ -332,15 +325,13 @@ 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") + }) ); }); }; @@ -400,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()); }); @@ -423,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()); }); @@ -446,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()); }); @@ -467,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()); }); @@ -488,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(); @@ -557,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()); @@ -582,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)); @@ -614,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 }) => ( + <> + -