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")} -
-
+