diff --git a/src/actions/__tests__/media-upload-actions.test.js b/src/actions/__tests__/media-upload-actions.test.js
index bf20ef843..b61f1be36 100644
--- a/src/actions/__tests__/media-upload-actions.test.js
+++ b/src/actions/__tests__/media-upload-actions.test.js
@@ -4,9 +4,16 @@
import configureStore from "redux-mock-store";
import thunk from "redux-thunk";
import flushPromises from "flush-promises";
-import { getRequest } from "openstack-uicore-foundation/lib/utils/actions";
-import { getMediaUpload } from "../media-upload-actions";
+import {
+ getRequest,
+ snackbarErrorHandler
+} from "openstack-uicore-foundation/lib/utils/actions";
+import {
+ getAllMediaUploadTypes,
+ getMediaUpload
+} from "../media-upload-actions";
import * as methods from "../../utils/methods";
+import { MAX_PER_PAGE } from "../../utils/constants";
jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({
__esModule: true,
@@ -72,3 +79,74 @@ describe("getMediaUpload", () => {
expect(actionTypes).toContain("RECEIVE_MEDIA_UPLOAD");
});
});
+
+describe("getAllMediaUploadTypes", () => {
+ const middlewares = [thunk];
+ const mockStore = configureStore(middlewares);
+ const ENDPOINT = "https://api.test/api/v1/summits/42/media-upload-types";
+
+ let requestedParams;
+
+ // One row per page, named after its page, so the accumulated list mirrors
+ // exactly which pages the thunk asked for and in what order it merged them.
+ const mockPagedRequest = (lastPage) => {
+ getRequest.mockImplementation(() => (params) => () => {
+ requestedParams.push(params);
+ return Promise.resolve({
+ response: {
+ last_page: lastPage,
+ data: [{ id: params.page, name: `type-${params.page}` }]
+ }
+ });
+ });
+ };
+
+ beforeEach(() => {
+ requestedParams = [];
+ // getRequest is a module-level jest.fn() shared with the suites above;
+ // restoreAllMocks does not reset its call history.
+ getRequest.mockClear();
+ window.API_BASE_URL = "https://api.test";
+ jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN");
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ delete window.API_BASE_URL;
+ });
+
+ it("loads every remaining page and concatenates them in page order", async () => {
+ mockPagedRequest(4);
+ const store = mockStore({});
+
+ const result = await store.dispatch(getAllMediaUploadTypes(42));
+
+ expect(requestedParams.map((p) => p.page).sort((a, b) => a - b)).toEqual([
+ 1, 2, 3, 4
+ ]);
+ // Promise.all resolves in input order, so page order survives the fan-out.
+ expect(result.map((type) => type.name)).toEqual([
+ "type-1",
+ "type-2",
+ "type-3",
+ "type-4"
+ ]);
+ });
+
+ it("requests only the fields the filter renders, sorted by name, through getRequest", async () => {
+ mockPagedRequest(1);
+ const store = mockStore({});
+
+ await store.dispatch(getAllMediaUploadTypes(42));
+
+ expect(requestedParams[0]).toEqual({
+ access_token: "TOKEN",
+ order: "name",
+ per_page: MAX_PER_PAGE,
+ fields: "id,name",
+ page: 1
+ });
+ expect(getRequest.mock.calls[0][2]).toBe(ENDPOINT);
+ expect(getRequest.mock.calls[0][3]).toBe(snackbarErrorHandler);
+ });
+});
diff --git a/src/actions/media-upload-actions.js b/src/actions/media-upload-actions.js
index 1695a2652..53b6e885c 100644
--- a/src/actions/media-upload-actions.js
+++ b/src/actions/media-upload-actions.js
@@ -12,6 +12,7 @@
* */
import T from "i18n-react/dist/i18n-react";
import URI from "urijs";
+import pLimit from "p-limit";
import {
getRequest,
putRequest,
@@ -27,8 +28,14 @@ import {
fetchErrorHandler
} from "openstack-uicore-foundation/lib/utils/actions";
import debounce from "lodash/debounce";
-import { getAccessTokenSafely } from "../utils/methods";
-import { DEBOUNCE_WAIT, DEFAULT_PER_PAGE } from "../utils/constants";
+import { getAccessTokenSafely, range } from "../utils/methods";
+import {
+ DEBOUNCE_WAIT,
+ DEFAULT_PER_PAGE,
+ MAX_PER_PAGE,
+ TEN,
+ TWO
+} from "../utils/constants";
URI.escapeQuerySpace = false;
@@ -142,6 +149,46 @@ export const queryMediaUploads = debounce(async (summitId, input, callback) => {
.catch(fetchErrorHandler);
}, DEBOUNCE_WAIT);
+export const getAllMediaUploadTypes = (summitId) => async (dispatch) => {
+ const accessToken = await getAccessTokenSafely();
+ const endpoint = `${window.API_BASE_URL}/api/v1/summits/${summitId}/media-upload-types`;
+ const baseParams = {
+ access_token: accessToken,
+ order: "name",
+ per_page: MAX_PER_PAGE,
+ fields: "id,name"
+ };
+ const getPage = (page) =>
+ getRequest(
+ createAction("DUMMY"),
+ createAction("DUMMY"),
+ endpoint,
+ snackbarErrorHandler
+ )({ ...baseParams, page })(dispatch);
+ const limit = pLimit(TEN);
+
+ return (
+ getPage(1)
+ .then(({ response }) => {
+ const { last_page: lastPage, data: firstPageData } = response;
+ if (lastPage <= 1) return firstPageData;
+ // local range() is stop-INCLUSIVE: range(TWO, lastPage, 1) === [2..lastPage]
+ return Promise.all(
+ range(TWO, lastPage, 1).map((page) => limit(() => getPage(page)))
+ ).then((responses) => {
+ // Promise.all preserves input order -> page-order accumulation.
+ const accumulated = [...firstPageData];
+ responses.forEach(({ response: pageResponse }) => {
+ accumulated.push(...pageResponse.data);
+ });
+ return accumulated;
+ });
+ })
+ // Swallow the rejection since message is already shown by error handler
+ .catch(() => [])
+ );
+};
+
export const resetMediaUploadForm = () => (dispatch) => {
dispatch(createAction(RESET_MEDIA_UPLOAD_FORM)({}));
};
diff --git a/src/components/filters/media-type-filter/__tests__/media-type-filter.test.js b/src/components/filters/media-type-filter/__tests__/media-type-filter.test.js
index 6fb5e8a3d..e2e8accec 100644
--- a/src/components/filters/media-type-filter/__tests__/media-type-filter.test.js
+++ b/src/components/filters/media-type-filter/__tests__/media-type-filter.test.js
@@ -1,136 +1,155 @@
import React from "react";
-import { fireEvent, render, screen } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import MediaTypeFilter from "..";
jest.mock("i18n-react/dist/i18n-react", () => ({
translate: (key) => key
}));
-jest.mock("react-select", () => function MockSelect({ id, value, options, onChange }) {
- return (
-
- );
- });
-
-jest.mock("../../../inputs/media-upload-type-input", () => function MockMediaUploadTypeInput({ id, value, onChange }) {
- return (
- onChange({ target: { value: e.target.value } })}
- />
- );
- });
+// Mirrors the real react-select single-select contract: the operator control
+// receives and emits whole option objects, not raw values.
+jest.mock(
+ "react-select",
+ () =>
+ function MockSelect({ id, value, options, onChange }) {
+ return (
+
+ );
+ }
+);
+
+// Mirrors the real Dropdown (react-select) wrapper's isMulti contract:
+// value/onChange both deal in a flat array of raw option ids.
+jest.mock(
+ "openstack-uicore-foundation/lib/components/inputs/dropdown",
+ () =>
+ function MockDropdown({ id, value, options, onChange }) {
+ return (
+
+ );
+ }
+);
describe("MediaTypeFilter", () => {
+ const mediaTypes = [
+ { id: "1", name: "Video" },
+ { id: "2", name: "Slides" }
+ ];
+
+ const getAllMediaUploadTypes = jest.fn();
+
const baseProps = {
onChange: jest.fn(),
- operatorInitialValue: null,
filterInitialValue: null,
id: "media-type-filter",
- summitId: 1
+ summitId: 1,
+ getAllMediaUploadTypes
};
beforeEach(() => {
jest.clearAllMocks();
+ getAllMediaUploadTypes.mockResolvedValue(mediaTypes);
});
- test("does not crash when selecting media type before selecting operator", () => {
+ test("loads the available media types for the given summit", async () => {
render();
- expect(() => {
- fireEvent.change(screen.getByTestId("media-upload-type-input"), {
- target: { value: "video" }
- });
- }).not.toThrow();
-
- expect(baseProps.onChange).toHaveBeenCalledTimes(1);
- expect(baseProps.onChange).toHaveBeenCalledWith({
- target: {
- id: "media-type-filter",
- value: "video",
- type: "mediatypeinput",
- operator: null
- }
- });
+ await waitFor(() => expect(getAllMediaUploadTypes).toHaveBeenCalledWith(1));
+ expect(screen.getByText("Video")).toBeInTheDocument();
+ expect(screen.getByText("Slides")).toBeInTheDocument();
});
- test("keeps existing behavior when selecting operator first and then value", () => {
+ test("selecting a media type reports it as an inclusive filter", async () => {
render();
+ await waitFor(() => screen.getByText("Video"));
- fireEvent.change(screen.getByTestId("operator-select"), {
- target: { value: "has_media_upload_with_type==" }
- });
+ const select = screen.getByTestId("media-type-dropdown");
+ select.querySelector("option[value='1']").selected = true;
+ select.dispatchEvent(new Event("change", { bubbles: true }));
- fireEvent.change(screen.getByTestId("media-upload-type-input"), {
- target: { value: "slides" }
- });
-
- expect(baseProps.onChange).toHaveBeenCalledTimes(2);
- expect(baseProps.onChange).toHaveBeenNthCalledWith(1, {
- target: {
- id: "media-type-filter",
- value: null,
- type: "mediatypeinput",
- operator: "has_media_upload_with_type=="
- }
- });
- expect(baseProps.onChange).toHaveBeenNthCalledWith(2, {
+ expect(baseProps.onChange).toHaveBeenCalledWith({
target: {
id: "media-type-filter",
- value: "slides",
+ value: [{ id: "1", name: "Video" }],
type: "mediatypeinput",
operator: "has_media_upload_with_type=="
}
});
});
- test("does not crash when clearing operator and re-selecting filter value", () => {
+ test("clearing the selection clears the filter", async () => {
render(
);
+ await waitFor(() => screen.getByText("Video"));
- fireEvent.change(screen.getByTestId("media-upload-type-input"), {
- target: { value: "video" }
- });
+ const select = screen.getByTestId("media-type-dropdown");
+ select.querySelector("option[value='1']").selected = false;
+ select.dispatchEvent(new Event("change", { bubbles: true }));
- fireEvent.change(screen.getByTestId("operator-select"), {
- target: { value: "" }
+ expect(baseProps.onChange).toHaveBeenCalledWith({
+ target: {
+ id: "media-type-filter",
+ value: [],
+ type: "mediatypeinput",
+ operator: null
+ }
});
+ });
- expect(() => {
- fireEvent.change(screen.getByTestId("media-upload-type-input"), {
- target: { value: "slides" }
- });
- }).not.toThrow();
+ test("switching the operator reports the current selection as an exclusion", async () => {
+ const user = userEvent.setup();
+ render();
+ await waitFor(() => screen.getByText("Video"));
+
+ await user.selectOptions(screen.getByTestId("media-type-dropdown"), ["1"]);
+ await user.selectOptions(
+ screen.getByTestId("operator-select"),
+ "has_not_media_upload_with_type=="
+ );
expect(baseProps.onChange).toHaveBeenCalledTimes(2);
expect(baseProps.onChange).toHaveBeenLastCalledWith({
target: {
id: "media-type-filter",
- value: "slides",
+ value: [{ id: "1", name: "Video" }],
type: "mediatypeinput",
- operator: null
+ operator: "has_not_media_upload_with_type=="
}
});
});
diff --git a/src/components/filters/media-type-filter/index.js b/src/components/filters/media-type-filter/index.js
index 6eb6ac395..5df01c736 100644
--- a/src/components/filters/media-type-filter/index.js
+++ b/src/components/filters/media-type-filter/index.js
@@ -11,10 +11,10 @@
* limitations under the License.
* */
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
import T from "i18n-react/dist/i18n-react";
import Select from "react-select";
-import MediaUploadTypeInput from "../../inputs/media-upload-type-input";
+import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown";
import styles from "./index.module.less";
@@ -23,8 +23,11 @@ const MediaTypeFilter = ({
operatorInitialValue,
filterInitialValue,
id,
- summitId
+ summitId,
+ getAllMediaUploadTypes
}) => {
+ const [mediaTypes, setMediaTypes] = useState([]);
+
const operatorOptions = [
{
label: T.translate("media_upload_type_filter.has_media_upload"),
@@ -39,10 +42,16 @@ const MediaTypeFilter = ({
const [operatorValue, setOperatorValue] = useState(
operatorInitialValue
? operatorOptions.find((o) => o.value === operatorInitialValue)
- : null
+ : operatorOptions[0]
);
const [filterValue, setFilterValue] = useState(filterInitialValue || null);
+ useEffect(() => {
+ getAllMediaUploadTypes(summitId).then((types) =>
+ setMediaTypes(types || [])
+ );
+ }, [summitId]);
+
const onChangeOperator = (newOperatorValue) => {
setOperatorValue(newOperatorValue);
if (newOperatorValue?.value == null) return;
@@ -59,14 +68,17 @@ const MediaTypeFilter = ({
};
const onChangeFilterValue = (newFilterValue) => {
- const { value } = newFilterValue.target;
+ const selectedIds = newFilterValue.target.value || [];
+ const value = mediaTypes.filter((mediaType) =>
+ selectedIds.includes(mediaType.id)
+ );
setFilterValue(value);
const ev = {
target: {
id,
value,
type: "mediatypeinput",
- operator: operatorValue?.value ?? null
+ operator: value.length > 0 ? operatorValue?.value ?? null : null
}
};
onChange(ev);
@@ -74,11 +86,9 @@ const MediaTypeFilter = ({
return (
-
- {T.translate("media_upload_type_filter.media_type")}
-
-
+
-
-
+ mediaType.id)}
+ onChange={onChangeFilterValue}
+ options={mediaTypes.map((mediaType) => ({
+ label: mediaType.name,
+ value: mediaType.id
+ }))}
+ formatOptionLabel={(option) => option.label}
+ isClearable
+ isMulti
placeholder={T.translate(
- `${
- operatorValue?.value
- ? `media_upload_type_filter.placeholders.${operatorValue.value}`
- : ""
+ `media_upload_type_filter.placeholders.${
+ operatorValue?.value ?? "has_media_upload_with_type=="
}`
)}
- summitId={summitId}
- onChange={onChangeFilterValue}
/>
diff --git a/src/components/filters/media-type-filter/index.module.less b/src/components/filters/media-type-filter/index.module.less
index 5f72b247a..273e828d3 100644
--- a/src/components/filters/media-type-filter/index.module.less
+++ b/src/components/filters/media-type-filter/index.module.less
@@ -1,4 +1,24 @@
.mediaTypeFilterWrapper {
display: flex;
- align-items: baseline;
+ align-items: center;
+}
+
+/* Push the two controls together into one segmented control. */
+.operatorCol {
+ padding-right: 0;
+}
+
+.typesCol {
+ padding-left: 0;
+}
+
+.operatorCol :global(.mediaTypeOperator__control) {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.typesCol :global(.mediaTypeValue__control) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ margin-left: -1px;
}
diff --git a/src/components/inputs/media-upload-type-input.js b/src/components/inputs/media-upload-type-input.js
deleted file mode 100644
index c3ecbe807..000000000
--- a/src/components/inputs/media-upload-type-input.js
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * Copyright 2023 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 React from "react";
-import AsyncSelect from "react-select/lib/Async";
-import { queryMediaUploads } from "../../actions/media-upload-actions";
-import T from "i18n-react/dist/i18n-react";
-
-const MediaUploadTypeInput = ({ summitId, id, value, onChange, ...rest }) => {
- const handleChange = (ev) => {
- if (onChange)
- onChange({ target: { value: ev, type: "media_upload_type_filter", id } });
- };
-
- const getOptionValue = (mediaUploadType) => {
- return mediaUploadType.id;
- };
-
- const getOptionLabel = (mediaUploadType) => {
- return `${mediaUploadType.name}`;
- };
-
- const getTypes = (input, callback) => {
- // we need to map into value/label because of a bug in react-select 2
- // https://github.com/JedWatson/react-select/issues/2998
-
- const translateOptions = (options) => {
- const newOptions = options.map((c) => ({
- id: c.id,
- name: c.name,
- value: c.id,
- label: c.name
- }));
- callback(newOptions);
- };
-
- queryMediaUploads(summitId, input, translateOptions);
- };
-
- return (
-
getOptionValue(m)}
- getOptionLabel={(m) => getOptionLabel(m)}
- isMulti={true}
- cacheOptions
- defaultOptions
- {...rest}
- />
- );
-};
-
-export default MediaUploadTypeInput;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 590912a15..8b8292ad6 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1166,8 +1166,16 @@
"items_qty": "Selected {qty} Speakers | {activitiesQty} Activities",
"placeholders": {
"search_speakers": "Search by Full Name, Email, Speaker Id, Member Id, Title Or Abstract",
- "test_recipient": "Optional Test Recipient"
+ "test_recipient": "Optional Test Recipient",
+ "selection_plan_filter": "Filter By Selection Plan",
+ "track_filter": "Filter By Track",
+ "track_group_filter": "Filter By Track Group",
+ "activity_type_filter": "Filter By Activity Type",
+ "selection_status_filter": "Filter By Selection Status",
+ "pending_submissions_filter": "Filter By Pending Submissions",
+ "media_upload_filter": "Filter by Media Upload Type"
},
+ "email_section_title": "Email Speakers",
"speakers": "Speakers",
"submitters": "Submitters"
},
@@ -1177,6 +1185,7 @@
"select_items": "Select at least one submitter to perform the action.",
"send_emails_title": "You are about to send an EMAIL BLAST to selected submitters !",
"resend_done": "Emails sent successfully.",
+ "email_section_title": "Email Submitters",
"submitters": "Submitters",
"submitters_no_speakers": "Submitters (no speakers)",
"items_qty": "Selected {qty} Submitters | {activitiesQty} Activities"
@@ -3895,13 +3904,12 @@
"following": " of the following: "
},
"media_upload_type_filter": {
- "media_type": "Media Upload Types",
- "has_media_upload": "Has",
- "has_not_media_upload": "Has Not",
+ "has_media_upload": "Uploaded",
+ "has_not_media_upload": "Not uploaded",
"placeholders": {
- "has_media_upload_with_type==": "Select the Media Upload Types to include",
- "has_not_media_upload_with_type==": "Select the Media Upload Types to exclude",
- "operator": "'Has' or 'Has Not' Media Upload Types"
+ "has_media_upload_with_type==": "Select media type(s)",
+ "has_not_media_upload_with_type==": "Select media type(s)",
+ "operator": "Uploaded or not uploaded"
}
},
"edit_reg_feed_metadata": {
diff --git a/src/pages/summit_speakers/summit-speakers-list-page.js b/src/pages/summit_speakers/summit-speakers-list-page.js
index 61904a144..374621d82 100644
--- a/src/pages/summit_speakers/summit-speakers-list-page.js
+++ b/src/pages/summit_speakers/summit-speakers-list-page.js
@@ -55,6 +55,7 @@ import {
AUTO_GENERATED_SPEAKERS_PROMO_CODE,
AUTO_GENERATED_SPEAKERS_DISCOUNT_CODE
} from "../../actions/promocode-actions";
+import { getAllMediaUploadTypes } from "../../actions/media-upload-actions";
import {
DEFAULT_CURRENT_PAGE,
@@ -374,13 +375,13 @@ class SummitSpeakersListPage extends React.Component {
ev.stopPropagation();
ev.preventDefault();
const { currentPromocodeSpecification } = this.props;
- const { promoCodeStrategy, testRecipient, source } = this.state;
+ const { promoCodeStrategy, testRecipient, source } = this.state;
const isSpeakerMode = source === sources.speakers;
const excerptRecipient = this.ingestEmailRef.value;
const shouldSendCopy2Submitter =
- isSpeakerMode && this.shouldSendCopy2SubmitterRef.checked;
+ isSpeakerMode && this.shouldSendCopy2SubmitterRef.checked;
const shouldResend = this.shouldResendRef.checked;
- const { term } = this.getSubjectProps();
+ const { term } = this.getSubjectProps();
this.props.validateSpecs(
promoCodeStrategy,
@@ -502,7 +503,11 @@ class SummitSpeakersListPage extends React.Component {
}
render() {
- const { currentSummit, currentPromocodeSpecification } = this.props;
+ const {
+ currentSummit,
+ currentPromocodeSpecification,
+ getAllMediaUploadTypes
+ } = this.props;
const { testRecipient, source, promoCodeStrategy } = this.state;
@@ -718,7 +723,7 @@ class SummitSpeakersListPage extends React.Component {
: T.translate("summit_submitters_list.submitters")}{" "}
| {totalActivities} {T.translate("general.activities")})
-
+
-
+
-
+
-
+
+
-
-
-
-
-
-
+
+
+ {source === sources.speakers
+ ? T.translate("summit_speakers_list.email_section_title")
+ : T.translate("summit_submitters_list.email_section_title")}
+