diff --git a/src/actions/__tests__/speaker-actions.test.js b/src/actions/__tests__/speaker-actions.test.js index 9d7c01e7f..7fd185489 100644 --- a/src/actions/__tests__/speaker-actions.test.js +++ b/src/actions/__tests__/speaker-actions.test.js @@ -5,16 +5,23 @@ import configureStore from "redux-mock-store"; import thunk from "redux-thunk"; import { deleteRequest, - putRequest + putRequest, + getRequest } from "openstack-uicore-foundation/lib/utils/actions"; -import { removeAttachedPicture, saveSpeaker } from "../speaker-actions"; +import { + removeAttachedPicture, + saveSpeaker, + getSpeakersBySummit, + sendSpeakerEmails +} from "../speaker-actions"; import * as methods from "../../utils/methods"; jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ __esModule: true, ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), deleteRequest: jest.fn(), - putRequest: jest.fn() + putRequest: jest.fn(), + getRequest: jest.fn() })); const SPEAKER_ID = 42; @@ -144,3 +151,150 @@ describe("saveSpeaker", () => { expect(settled).toBe(true); }); }); + +describe("getSpeakersBySummit - published filter", () => { + const mockStore = configureStore([thunk]); + const SUMMIT_ID = 1; + let capturedRequests; + + const stateWithSummit = { + currentSummitState: { + currentSummit: { id: SUMMIT_ID, name: "Test Summit" } + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + window.API_BASE_URL = "https://api.test"; + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + capturedRequests = []; + getRequest.mockImplementation( + (_requestAction, receiveAction, url) => (params) => (dispatch) => { + capturedRequests.push({ url, params }); + dispatch(receiveAction({ response: {} })); + return Promise.resolve({ response: {} }); + } + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete window.API_BASE_URL; + }); + + const listRequestFor = () => + capturedRequests.find((r) => r.url.endsWith("/speakers")); + const countRequestFor = () => + capturedRequests.find((r) => r.url.endsWith("/speakers/all/events/count")); + + it.each([ + ["published", "true"], + ["not_published", "false"] + ])( + "maps the '%s' filter to has_published_presentations==%s on both the list and count request", + async (selectionValue, expectedFlag) => { + const store = mockStore(stateWithSummit); + + await store.dispatch( + getSpeakersBySummit(null, 1, 10, "full_name", 1, { + selectionStatusFilter: [selectionValue] + }) + ); + + const expected = `has_published_presentations==${expectedFlag}`; + expect(listRequestFor().params["filter[]"]).toContain(expected); + expect(countRequestFor().params["filter[]"]).toContain(expected); + } + ); + + it("does not regress the existing only_accepted combination", async () => { + const store = mockStore(stateWithSummit); + + await store.dispatch( + getSpeakersBySummit(null, 1, 10, "full_name", 1, { + selectionStatusFilter: ["only_accepted"] + }) + ); + + const filter = listRequestFor().params["filter[]"]; + expect(filter).toEqual( + expect.arrayContaining([ + "has_rejected_presentations==false", + "has_accepted_presentations==true", + "has_alternate_presentations==false" + ]) + ); + expect(filter.join(",")).not.toContain("has_published_presentations"); + }); +}); + +describe("sendSpeakerEmails - published filter", () => { + const mockStore = configureStore([thunk]); + const SUMMIT_ID = 1; + let capturedRequests; + + const baseState = { + currentSummitState: { + currentSummit: { id: SUMMIT_ID, name: "Test Summit" } + }, + currentSummitSpeakersListState: { + selectedAll: true, + selectedItems: [], + excludedItems: [], + currentFlowEvent: "SPEAKER_FLOW_EVENT" + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + window.API_BASE_URL = "https://api.test"; + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + capturedRequests = []; + putRequest.mockImplementation( + (_requestAction, receiveAction, url, payload) => + (params) => + (dispatch) => { + capturedRequests.push({ url, params, payload }); + dispatch(receiveAction({ response: {} })); + return Promise.resolve({ response: {} }); + } + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete window.API_BASE_URL; + }); + + it("forwards the selected Published/Not Published filter unchanged into the bulk-email request", async () => { + const store = mockStore(baseState); + + await store.dispatch( + sendSpeakerEmails(null, { selectionStatusFilter: ["published"] }) + ); + + expect(capturedRequests[0].params["filter[]"]).toContain( + "has_published_presentations==true" + ); + }); + + it("forwards the Published filter through original_filter when specific speakers are selected", async () => { + const store = mockStore({ + ...baseState, + currentSummitSpeakersListState: { + selectedAll: false, + selectedItems: [101, 202], + excludedItems: [], + currentFlowEvent: "SPEAKER_FLOW_EVENT" + } + }); + + await store.dispatch( + sendSpeakerEmails(null, { selectionStatusFilter: ["published"] }) + ); + + expect(capturedRequests[0].payload.original_filter).toContain( + "has_published_presentations==true" + ); + }); +}); diff --git a/src/actions/__tests__/submitter-actions.test.js b/src/actions/__tests__/submitter-actions.test.js new file mode 100644 index 000000000..0de33caef --- /dev/null +++ b/src/actions/__tests__/submitter-actions.test.js @@ -0,0 +1,170 @@ +/** + * @jest-environment jsdom + */ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import { + getRequest, + putRequest +} from "openstack-uicore-foundation/lib/utils/actions"; +import { + getSubmittersBySummit, + sendSubmitterEmails +} from "../submitter-actions"; +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(), + putRequest: jest.fn() +})); + +describe("getSubmittersBySummit - published filter", () => { + const mockStore = configureStore([thunk]); + const SUMMIT_ID = 1; + let capturedRequests; + + const stateWithSummit = { + currentSummitState: { + currentSummit: { id: SUMMIT_ID, name: "Test Summit" } + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + window.API_BASE_URL = "https://api.test"; + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + capturedRequests = []; + getRequest.mockImplementation( + (_requestAction, receiveAction, url) => (params) => (dispatch) => { + capturedRequests.push({ url, params }); + dispatch(receiveAction({ response: {} })); + return Promise.resolve({ response: {} }); + } + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete window.API_BASE_URL; + }); + + const listRequestFor = () => + capturedRequests.find((r) => r.url.endsWith("/submitters")); + const countRequestFor = () => + capturedRequests.find((r) => + r.url.endsWith("/submitters/all/events/count") + ); + + it.each([ + ["published", "true"], + ["not_published", "false"] + ])( + "maps the '%s' filter to has_published_presentations==%s on both the list and count request", + async (selectionValue, expectedFlag) => { + const store = mockStore(stateWithSummit); + + await store.dispatch( + getSubmittersBySummit(null, 1, 10, "full_name", 1, { + selectionStatusFilter: [selectionValue] + }) + ); + + const expected = `has_published_presentations==${expectedFlag}`; + expect(listRequestFor().params["filter[]"]).toContain(expected); + expect(countRequestFor().params["filter[]"]).toContain(expected); + } + ); + + it("does not regress the existing only_accepted combination", async () => { + const store = mockStore(stateWithSummit); + + await store.dispatch( + getSubmittersBySummit(null, 1, 10, "full_name", 1, { + selectionStatusFilter: ["only_accepted"] + }) + ); + + const filter = listRequestFor().params["filter[]"]; + expect(filter).toEqual( + expect.arrayContaining([ + "has_rejected_presentations==false", + "has_accepted_presentations==true", + "has_alternate_presentations==false" + ]) + ); + expect(filter.join(",")).not.toContain("has_published_presentations"); + }); +}); + +describe("sendSubmitterEmails - published filter", () => { + const mockStore = configureStore([thunk]); + const SUMMIT_ID = 1; + let capturedRequests; + + const baseState = { + currentSummitState: { + currentSummit: { id: SUMMIT_ID, name: "Test Summit" } + }, + currentSummitSubmittersListState: { + selectedAll: true, + selectedItems: [], + excludedItems: [], + currentFlowEvent: "SUBMITTER_FLOW_EVENT" + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + window.API_BASE_URL = "https://api.test"; + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + capturedRequests = []; + putRequest.mockImplementation( + (_requestAction, receiveAction, url, payload) => + (params) => + (dispatch) => { + capturedRequests.push({ url, params, payload }); + dispatch(receiveAction({ response: {} })); + return Promise.resolve({ response: {} }); + } + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete window.API_BASE_URL; + }); + + it("forwards the selected Published/Not Published filter unchanged into the bulk-email request", async () => { + const store = mockStore(baseState); + + await store.dispatch( + sendSubmitterEmails(null, { selectionStatusFilter: ["published"] }) + ); + + expect(capturedRequests[0].params["filter[]"]).toContain( + "has_published_presentations==true" + ); + }); + + it("forwards the Published filter through original_filter when specific submitters are selected", async () => { + const store = mockStore({ + ...baseState, + currentSummitSubmittersListState: { + selectedAll: false, + selectedItems: [101, 202], + excludedItems: [], + currentFlowEvent: "SUBMITTER_FLOW_EVENT" + } + }); + + await store.dispatch( + sendSubmitterEmails(null, { selectionStatusFilter: ["published"] }) + ); + + expect(capturedRequests[0].payload.original_filter).toContain( + "has_published_presentations==true" + ); + }); +}); diff --git a/src/actions/speaker-actions.js b/src/actions/speaker-actions.js index a3cea557d..20431d87a 100644 --- a/src/actions/speaker-actions.js +++ b/src/actions/speaker-actions.js @@ -895,6 +895,10 @@ const parseFilters = (filters) => { filter.push("has_rejected_presentations==true"); filter.push("has_accepted_presentations==false"); filter.push("has_alternate_presentations==true"); + } else if (filters.selectionStatusFilter.includes("published")) { + filter.push("has_published_presentations==true"); + } else if (filters.selectionStatusFilter.includes("not_published")) { + filter.push("has_published_presentations==false"); } else { filter.push( filters.selectionStatusFilter.reduce( diff --git a/src/actions/submitter-actions.js b/src/actions/submitter-actions.js index e5209b8cc..7adabf00e 100644 --- a/src/actions/submitter-actions.js +++ b/src/actions/submitter-actions.js @@ -471,6 +471,10 @@ const parseFilters = (filters) => { filter.push("has_rejected_presentations==true"); filter.push("has_accepted_presentations==false"); filter.push("has_alternate_presentations==true"); + } else if (filters.selectionStatusFilter.includes("published")) { + filter.push("has_published_presentations==true"); + } else if (filters.selectionStatusFilter.includes("not_published")) { + filter.push("has_published_presentations==false"); } else { filter.push( filters.selectionStatusFilter.reduce( diff --git a/src/pages/summit_speakers/__tests__/summit-speakers-list-page.test.js b/src/pages/summit_speakers/__tests__/summit-speakers-list-page.test.js new file mode 100644 index 000000000..d3ce71fde --- /dev/null +++ b/src/pages/summit_speakers/__tests__/summit-speakers-list-page.test.js @@ -0,0 +1,105 @@ +/** + * @jest-environment jsdom + */ +import ConnectedSummitSpeakersListPage from "../summit-speakers-list-page"; + +const SummitSpeakersListPage = ConnectedSummitSpeakersListPage.WrappedComponent; + +const buildSubjectProps = () => ({ + term: null, + order: "full_name", + page: 1, + orderDir: 1, + perPage: 10, + selectionPlanFilter: [], + trackFilter: [], + trackGroupFilter: [], + activityTypeFilter: [], + selectionStatusFilter: [], + mediaUploadTypeFilter: { operator: null, value: [] }, + selectedCount: 0, + totalActivities: 0 +}); + +const buildInstance = (overrides = {}) => { + const props = { + currentSummit: { id: 1, name: "Test Summit" }, + speakersProps: buildSubjectProps(), + submittersProps: buildSubjectProps(), + currentPromocodeSpecification: {}, + initSpeakersList: jest.fn(), + initSubmittersList: jest.fn(), + getSpeakersBySummit: jest.fn(), + getSubmittersBySummit: jest.fn(), + getSelectedSpeakersActivityCount: jest.fn(), + getSelectedSubmittersActivityCount: jest.fn(), + ...overrides + }; + return new SummitSpeakersListPage(props); +}; + +describe("SummitSpeakersListPage.handleChangeSelectionStatusFilter", () => { + it.each([ + [["accepted", "published"], ["published"]], + [["accepted", "not_published"], ["not_published"]] + ])( + "collapses %j to only the new exclusive status %j", + (selectedValues, expected) => { + const getSpeakersBySummit = jest.fn(); + const instance = buildInstance({ getSpeakersBySummit }); + + instance.handleChangeSelectionStatusFilter({ + target: { value: selectedValues } + }); + + const filtersArg = getSpeakersBySummit.mock.calls[0][5]; + expect(filtersArg.selectionStatusFilter).toEqual(expected); + } + ); + + it("leaves non-exclusive multi-select combinations unchanged", () => { + const getSpeakersBySummit = jest.fn(); + const instance = buildInstance({ getSpeakersBySummit }); + + instance.handleChangeSelectionStatusFilter({ + target: { value: ["accepted", "rejected"] } + }); + + const filtersArg = getSpeakersBySummit.mock.calls[0][5]; + expect(filtersArg.selectionStatusFilter).toEqual(["accepted", "rejected"]); + }); + + // The underlying isMulti dropdown (openstack-uicore-foundation Dropdown) always reports + // the cumulative selection (previous + newly clicked value), never just the delta - see + // its handleChange, which maps over the full react-select value array. So switching + // between any two exclusive values without clearing first arrives here with BOTH + // present. Resolution must be based on which one was already active (the previous + // selection), not a fixed priority order - a fixed order would always favor the same + // value regardless of which one was just clicked. This applies uniformly to every + // exclusive pair, not just Published/Not Published. + it.each([ + [["published"], ["published", "not_published"], ["not_published"]], + [["not_published"], ["not_published", "published"], ["published"]], + [["only_rejected"], ["only_rejected", "only_accepted"], ["only_accepted"]], + [["only_accepted"], ["only_accepted", "published"], ["published"]] + ])( + "resolves to the newly picked value: previously %j, dropdown reports %j -> %j", + (previousSelectionStatusFilter, selectedValues, expected) => { + const getSpeakersBySummit = jest.fn(); + const instance = buildInstance({ + getSpeakersBySummit, + speakersProps: { + ...buildSubjectProps(), + selectionStatusFilter: previousSelectionStatusFilter + } + }); + + instance.handleChangeSelectionStatusFilter({ + target: { value: selectedValues } + }); + + const filtersArg = getSpeakersBySummit.mock.calls[0][5]; + expect(filtersArg.selectionStatusFilter).toEqual(expected); + } + ); +}); diff --git a/src/pages/summit_speakers/summit-speakers-list-page.js b/src/pages/summit_speakers/summit-speakers-list-page.js index 94c797eb8..5cfcd3a81 100644 --- a/src/pages/summit_speakers/summit-speakers-list-page.js +++ b/src/pages/summit_speakers/summit-speakers-list-page.js @@ -62,6 +62,34 @@ import MediaTypeFilter from "../../components/filters/media-type-filter"; import "../../styles/speakers-list-page.less"; +// Static - not dependent on props/state - so it's declared once here instead of +// being rebuilt on every render. +const SELECTION_STATUS_OPTIONS = [ + { label: "Accepted", value: "accepted" }, + { label: "Alternate", value: "alternate" }, + { label: "Rejected", value: "rejected" }, + { label: "Only Rejected", value: "only_rejected" }, + { label: "Only Accepted", value: "only_accepted" }, + { label: "Only Alternate", value: "only_alternate" }, + { label: "Accepted/Alternate", value: "accepted_alternate" }, + { label: "Accepted/Rejected", value: "accepted_rejected" }, + { label: "Alternate/Rejected", value: "alternate_rejected" }, + { label: "Published", value: "published" }, + { label: "Not Published", value: "not_published" } +]; + +// "accepted"/"alternate"/"rejected" combine freely (see parseFilters); every +// other selection-status value is mutually exclusive with the rest - picking +// one collapses the selection down to just that value. +const NON_EXCLUSIVE_SELECTION_STATUS_VALUES = [ + "accepted", + "alternate", + "rejected" +]; +const EXCLUSIVE_SELECTION_STATUS_VALUES = SELECTION_STATUS_OPTIONS.map( + (option) => option.value +).filter((value) => !NON_EXCLUSIVE_SELECTION_STATUS_VALUES.includes(value)); + class SummitSpeakersListPage extends React.Component { constructor(props) { super(props); @@ -463,22 +491,7 @@ class SummitSpeakersListPage extends React.Component { } handleChangeSelectionStatusFilter(ev) { - let { value: newSelectionStatusFilter } = ev.target; - // exclusive filters tests .... - if (newSelectionStatusFilter.includes("only_rejected")) { - newSelectionStatusFilter = ["only_rejected"]; - } else if (newSelectionStatusFilter.includes("only_alternate")) { - newSelectionStatusFilter = ["only_alternate"]; - } else if (newSelectionStatusFilter.includes("only_accepted")) { - newSelectionStatusFilter = ["only_accepted"]; - } else if (newSelectionStatusFilter.includes("accepted_alternate")) { - newSelectionStatusFilter = ["accepted_alternate"]; - } else if (newSelectionStatusFilter.includes("accepted_rejected")) { - newSelectionStatusFilter = ["accepted_rejected"]; - } else if (newSelectionStatusFilter.includes("alternate_rejected")) { - newSelectionStatusFilter = ["alternate_rejected"]; - } - + const { value: rawSelectionStatusFilter } = ev.target; const { term, order, @@ -489,8 +502,27 @@ class SummitSpeakersListPage extends React.Component { trackFilter, trackGroupFilter, activityTypeFilter, + selectionStatusFilter: previousSelectionStatusFilter, mediaUploadTypeFilter } = this.getSubjectProps(); + + // the isMulti dropdown reports the full selection (previous values + the + // one just clicked), not just the delta, so more than one exclusive value + // can be present here. Prefer whichever wasn't already selected - that's + // the one just picked - falling back to the first if none/more than one + // is new. + const selectedExclusiveValues = rawSelectionStatusFilter.filter((value) => + EXCLUSIVE_SELECTION_STATUS_VALUES.includes(value) + ); + const newSelectionStatusFilter = + selectedExclusiveValues.length > 0 + ? [ + selectedExclusiveValues.find( + (value) => !previousSelectionStatusFilter.includes(value) + ) ?? selectedExclusiveValues[0] + ] + : rawSelectionStatusFilter; + const { speakerFilters: { orAndFilter } } = this.state; @@ -784,17 +816,7 @@ class SummitSpeakersListPage extends React.Component { value: type.id })); - const selectionStatusDDL = [ - { label: "Accepted", value: "accepted" }, - { label: "Alternate", value: "alternate" }, - { label: "Rejected", value: "rejected" }, - { label: "Only Rejected", value: "only_rejected" }, - { label: "Only Accepted", value: "only_accepted" }, - { label: "Only Alternate", value: "only_alternate" }, - { label: "Accepted/Alternate", value: "accepted_alternate" }, - { label: "Accepted/Rejected", value: "accepted_rejected" }, - { label: "Alternate/Rejected", value: "alternate_rejected" } - ]; + const selectionStatusDDL = SELECTION_STATUS_OPTIONS; const speakerSubmitterSourceSelectorDDL = [ {