diff --git a/src/actions/__tests__/media-upload-actions.test.js b/src/actions/__tests__/media-upload-actions.test.js new file mode 100644 index 000000000..bf20ef843 --- /dev/null +++ b/src/actions/__tests__/media-upload-actions.test.js @@ -0,0 +1,74 @@ +/** + * @jest-environment jsdom + */ +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 * 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() +})); + +const requestMock = + (requestActionCreator, receiveActionCreator) => () => (dispatch) => { + if (typeof receiveActionCreator === "function") { + dispatch(receiveActionCreator({ response: { id: 7, name: "Slides" } })); + } + return Promise.resolve({ response: { id: 7, name: "Slides" } }); + }; + +describe("getMediaUpload", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + getRequest.mockImplementation(requestMock); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // Regression test for a bug where the fetch omitted `relations`, so the API + // response never included presentation_types. The reducer then defaulted it + // to [], the form rendered no chips, and saving wiped every real + // association even though nothing about them was touched. See + // SummitMediaUploadTypeSerializer::serialize (only emits presentation_types + // when the relation is requested) and + // SummitMediaUploadTypeService::update() (isset() on an empty array is + // still true, so clearPresentationTypes() runs and nothing is re-added). + it("requests the presentation_types relation so an existing entity's associations survive a fetch", async () => { + let capturedParams; + getRequest.mockImplementation((req, res) => (params) => (dispatch) => { + capturedParams = params; + return requestMock(req, res)(params)(dispatch); + }); + + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } } + }); + + await store.dispatch(getMediaUpload(7)); + await flushPromises(); + + expect(capturedParams).toMatchObject({ relations: "presentation_types" }); + }); + + it("dispatches RECEIVE_MEDIA_UPLOAD with the fetched entity", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 42 } } + }); + + store.dispatch(getMediaUpload(7)); + await flushPromises(); + + const actionTypes = store.getActions().map((a) => a.type); + expect(actionTypes).toContain("RECEIVE_MEDIA_UPLOAD"); + }); +}); diff --git a/src/actions/media-upload-actions.js b/src/actions/media-upload-actions.js index 648398b3b..1695a2652 100644 --- a/src/actions/media-upload-actions.js +++ b/src/actions/media-upload-actions.js @@ -20,15 +20,13 @@ import { createAction, stopLoading, startLoading, - showMessage, - showSuccessMessage, - authErrorHandler, + snackbarErrorHandler, + snackbarSuccessHandler, escapeFilterValue, fetchResponseHandler, fetchErrorHandler } from "openstack-uicore-foundation/lib/utils/actions"; -import debounce from "lodash/debounce" -import history from "../history"; +import debounce from "lodash/debounce"; import { getAccessTokenSafely } from "../utils/methods"; import { DEBOUNCE_WAIT, DEFAULT_PER_PAGE } from "../utils/constants"; @@ -66,8 +64,9 @@ export const getMediaUploads = access_token: accessToken, page, per_page: perPage, - fields: "id,name,description", - relations: "none" + expand: "type", + fields: "id,name,description,type.is_system_defined", + relations: "none,presentation_types" }; if (term) { @@ -89,9 +88,9 @@ export const getMediaUploads = createAction(REQUEST_MEDIA_UPLOADS), createAction(RECEIVE_MEDIA_UPLOADS), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/media-upload-types`, - authErrorHandler, - { order, orderDir, term } - )(params)(dispatch).then(() => { + snackbarErrorHandler, + { order, orderDir, term, perPage } + )(params)(dispatch).finally(() => { dispatch(stopLoading()); }); }; @@ -104,101 +103,97 @@ export const getMediaUpload = (mediaUploadId) => async (dispatch, getState) => { dispatch(startLoading()); const params = { - access_token: accessToken + access_token: accessToken, + relations: "presentation_types" }; return getRequest( null, createAction(RECEIVE_MEDIA_UPLOAD), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/media-upload-types/${mediaUploadId}`, - authErrorHandler - )(params)(dispatch).then(() => { + snackbarErrorHandler + )(params)(dispatch).finally(() => { dispatch(stopLoading()); }); }; -export const queryMediaUploads = debounce( - async (summitId, input, callback) => { - const accessToken = await getAccessTokenSafely(); - const apiUrl = URI( - `${window.API_BASE_URL}/api/v1/summits/${summitId}/media-upload-types` - ); - - apiUrl.addQuery("access_token", accessToken); - apiUrl.addQuery("order", "name"); - apiUrl.addQuery("expand", "type"); - apiUrl.addQuery("per_page", DEFAULT_PER_PAGE); - - if (input) { - input = escapeFilterValue(input); - apiUrl.addQuery("filter[]", `name=@${input}`); - } +export const queryMediaUploads = debounce(async (summitId, input, callback) => { + const accessToken = await getAccessTokenSafely(); + const apiUrl = URI( + `${window.API_BASE_URL}/api/v1/summits/${summitId}/media-upload-types` + ); + + apiUrl.addQuery("access_token", accessToken); + apiUrl.addQuery("order", "name"); + apiUrl.addQuery("expand", "type"); + apiUrl.addQuery("per_page", DEFAULT_PER_PAGE); + + if (input) { + input = escapeFilterValue(input); + apiUrl.addQuery("filter[]", `name=@${input}`); + } - fetch(apiUrl.toString()) - .then(fetchResponseHandler) - .then((json) => { - const options = [...json.data]; - callback(options); - }) - .catch(fetchErrorHandler); - }, - DEBOUNCE_WAIT -); + fetch(apiUrl.toString()) + .then(fetchResponseHandler) + .then((json) => { + const options = [...json.data]; + callback(options); + }) + .catch(fetchErrorHandler); +}, DEBOUNCE_WAIT); export const resetMediaUploadForm = () => (dispatch) => { dispatch(createAction(RESET_MEDIA_UPLOAD_FORM)({})); }; -export const saveMediaUpload = - (entity, noAlert = false) => - async (dispatch, getState) => { - const { currentSummitState } = getState(); - const accessToken = await getAccessTokenSafely(); - const { currentSummit } = currentSummitState; +export const saveMediaUpload = (entity) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const accessToken = await getAccessTokenSafely(); + const { currentSummit } = currentSummitState; - dispatch(startLoading()); + dispatch(startLoading()); - const normalizedEntity = normalizeEntity(entity); - const params = { access_token: accessToken }; + const normalizedEntity = normalizeEntity(entity); + const params = { access_token: accessToken }; - if (entity.id) { - putRequest( - createAction(UPDATE_MEDIA_UPLOAD), - createAction(MEDIA_UPLOAD_UPDATED), - `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/media-upload-types/${entity.id}`, - normalizedEntity, - authErrorHandler, - entity - )(params)(dispatch).then(() => { - if (!noAlert) - dispatch(showSuccessMessage(T.translate("media_upload.saved"))); - else dispatch(stopLoading()); - }); - } else { - const successMessage = { - title: T.translate("general.done"), - html: T.translate("media_upload.created"), - type: "success" - }; - - postRequest( - createAction(UPDATE_MEDIA_UPLOAD), - createAction(MEDIA_UPLOAD_ADDED), - `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/media-upload-types`, - normalizedEntity, - authErrorHandler, - entity - )(params)(dispatch).then((payload) => { + if (entity.id) { + return putRequest( + createAction(UPDATE_MEDIA_UPLOAD), + createAction(MEDIA_UPLOAD_UPDATED), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/media-upload-types/${entity.id}`, + normalizedEntity, + snackbarErrorHandler, + entity + )(params)(dispatch) + .then(() => { dispatch( - showMessage(successMessage, () => { - history.push( - `/app/summits/${currentSummit.id}/media-uploads/${payload.response.id}` - ); + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("media_upload.saved") }) ); - }); - } - }; + }) + .finally(() => dispatch(stopLoading())); + } + + return postRequest( + createAction(UPDATE_MEDIA_UPLOAD), + createAction(MEDIA_UPLOAD_ADDED), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/media-upload-types`, + normalizedEntity, + snackbarErrorHandler, + entity + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("media_upload.created") + }) + ); + }) + .finally(() => dispatch(stopLoading())); +}; export const linkToPresentationType = (mediaUpload, presentationTypeId) => async (dispatch, getState) => { @@ -215,8 +210,8 @@ export const linkToPresentationType = createAction(MEDIA_UPLOAD_LINKED)({ mediaUpload }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/media-upload-types/${mediaUpload.id}/presentation-types/${presentationTypeId}`, null, - authErrorHandler - )(params)(dispatch).then(() => { + snackbarErrorHandler + )(params)(dispatch).finally(() => { dispatch(stopLoading()); }); }; @@ -236,8 +231,8 @@ export const unlinkFromPresentationType = createAction(MEDIA_UPLOAD_UNLINKED)({ mediaUploadId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/media-upload-types/${mediaUploadId}/presentation-types/${presentationTypeId}`, null, - authErrorHandler - )(params)(dispatch).then(() => { + snackbarErrorHandler + )(params)(dispatch).finally(() => { dispatch(stopLoading()); }); }; @@ -257,10 +252,8 @@ export const deleteMediaUpload = createAction(MEDIA_UPLOAD_DELETED)({ mediaUploadId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/media-upload-types/${mediaUploadId}`, null, - authErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - }); + snackbarErrorHandler + )(params)(dispatch); }; export const copyMediaUploads = (summitId) => async (dispatch, getState) => { @@ -272,16 +265,23 @@ export const copyMediaUploads = (summitId) => async (dispatch, getState) => { const params = { access_token: accessToken }; - postRequest( + return postRequest( null, createAction(MEDIA_UPLOADS_COPIED), `${window.API_BASE_URL}/api/v1/summits/${summitId}/media-upload-types/all/clone/${currentSummit.id}`, null, - authErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch(getMediaUploads()); - }); + snackbarErrorHandler + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("media_upload.media_uploads_copied") + }) + ); + dispatch(getMediaUploads()); + }) + .finally(() => dispatch(stopLoading())); }; const normalizeEntity = (entity) => { diff --git a/src/components/forms/__tests__/media-upload-form.test.js b/src/components/forms/__tests__/media-upload-form.test.js new file mode 100644 index 000000000..8e3e02337 --- /dev/null +++ b/src/components/forms/__tests__/media-upload-form.test.js @@ -0,0 +1,292 @@ +import React from "react"; +import { + render, + screen, + waitFor, + act, + fireEvent +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import MediaUploadForm from "../media-upload-form"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + translate: jest.fn((key) => key) +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield", + () => + function MockTextField({ name }) { + // eslint-disable-next-line global-require + const { useField } = require("formik"); + const [field, meta] = useField(name); + return ( + <> + + {meta.touched && meta.error && {meta.error}} + + ); + } +); + +jest.mock( + "openstack-uicore-foundation/lib/components/mui/formik-inputs/file-size-field", + () => + function MockFilesizeField({ name }) { + // eslint-disable-next-line global-require + const { useField } = require("formik"); + const [field, meta] = useField(name); + return ( + <> + + {meta.touched && meta.error && {meta.error}} + + ); + } +); + +jest.mock( + "../../mui/formik-inputs/mui-formik-select", + () => + function MockSelect({ name, children }) { + return
{children}
; + } +); + +jest.mock( + "../../inputs/formik-text-editor", + () => + function MockTextEditor({ name }) { + return