diff --git a/src/actions/__tests__/speaker-actions.test.js b/src/actions/__tests__/speaker-actions.test.js new file mode 100644 index 000000000..9d7c01e7f --- /dev/null +++ b/src/actions/__tests__/speaker-actions.test.js @@ -0,0 +1,146 @@ +/** + * @jest-environment jsdom + */ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import { + deleteRequest, + putRequest +} from "openstack-uicore-foundation/lib/utils/actions"; +import { removeAttachedPicture, saveSpeaker } 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() +})); + +const SPEAKER_ID = 42; + +describe("removeAttachedPicture", () => { + const mockStore = configureStore([thunk]); + + beforeEach(() => { + jest.clearAllMocks(); + window.API_BASE_URL = "https://api.test"; + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + deleteRequest.mockImplementation( + (_requestAction, receiveAction) => () => (dispatch) => { + dispatch(receiveAction({ response: {} })); + return Promise.resolve({ response: {} }); + } + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete window.API_BASE_URL; + }); + + it("deletes the profile photo through the photo endpoint", async () => { + const store = mockStore({}); + + await store.dispatch(removeAttachedPicture(SPEAKER_ID, "profile")); + + expect(deleteRequest).toHaveBeenCalledTimes(1); + expect(deleteRequest.mock.calls[0][2]).toBe( + `https://api.test/api/v1/speakers/${SPEAKER_ID}/photo?access_token=TOKEN` + ); + expect(store.getActions().map((a) => a.type)).toContain("PIC_DELETED"); + }); + + it("deletes the big photo through the big-photo endpoint", async () => { + const store = mockStore({}); + + await store.dispatch(removeAttachedPicture(SPEAKER_ID, "big")); + + expect(deleteRequest.mock.calls[0][2]).toBe( + `https://api.test/api/v1/speakers/${SPEAKER_ID}/big-photo?access_token=TOKEN` + ); + expect(store.getActions().map((a) => a.type)).toContain("BIG_PIC_DELETED"); + }); + + it("does not emit a delete action when the request fails", async () => { + deleteRequest.mockImplementation( + () => () => () => Promise.reject(new Error("boom")) + ); + const store = mockStore({}); + + await expect( + store.dispatch(removeAttachedPicture(SPEAKER_ID, "profile")) + ).resolves.toBeUndefined(); + + const types = store.getActions().map((a) => a.type); + expect(types).not.toContain("PIC_DELETED"); + expect(types).not.toContain("STOP_LOADING"); + }); +}); + +describe("saveSpeaker", () => { + const mockStore = configureStore([thunk]); + + beforeEach(() => { + jest.clearAllMocks(); + window.API_BASE_URL = "https://api.test"; + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + putRequest.mockImplementation( + (requestAction, receiveAction, _url, _body, _err, payload) => + () => + (dispatch) => { + dispatch(requestAction(payload)); + dispatch(receiveAction({ response: {} })); + return Promise.resolve({ response: {} }); + } + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete window.API_BASE_URL; + }); + + it("never deletes photos as a side effect of saving", async () => { + const store = mockStore({}); + + await store.dispatch( + saveSpeaker({ id: SPEAKER_ID, title: "Dev", pic: "", big_pic: "" }) + ); + + expect(deleteRequest).not.toHaveBeenCalled(); + }); + + it("resolves only after the update request settles", async () => { + let resolveRequest; + putRequest.mockImplementation( + (requestAction, receiveAction) => () => (dispatch) => { + dispatch(requestAction()); + return new Promise((resolve) => { + resolveRequest = () => { + dispatch(receiveAction({ response: {} })); + resolve({ response: {} }); + }; + }); + } + ); + const store = mockStore({}); + + let settled = false; + const dispatched = store + .dispatch( + saveSpeaker({ id: SPEAKER_ID, title: "Dev", pic: "", big_pic: "" }) + ) + .then(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + resolveRequest(); + await dispatched; + expect(settled).toBe(true); + }); +}); diff --git a/src/actions/speaker-actions.js b/src/actions/speaker-actions.js index 414216865..a3cea557d 100644 --- a/src/actions/speaker-actions.js +++ b/src/actions/speaker-actions.js @@ -61,7 +61,9 @@ export const UPDATE_SPEAKER = "UPDATE_SPEAKER"; export const SPEAKER_UPDATED = "SPEAKER_UPDATED"; export const SPEAKER_ADDED = "SPEAKER_ADDED"; export const PIC_ATTACHED = "PIC_ATTACHED"; +export const PIC_DELETED = "PIC_DELETED"; export const BIG_PIC_ATTACHED = "BIG_PIC_ATTACHED"; +export const BIG_PIC_DELETED = "BIG_PIC_DELETED"; export const MERGE_SPEAKERS = "MERGE_SPEAKERS"; export const SPEAKER_MERGED = "SPEAKER_MERGED"; @@ -141,6 +143,18 @@ const uploadProfilePic = (entity, file) => async (dispatch) => { }); }; +const deleteProfilePic = (speakerId) => async (dispatch) => { + const accessToken = await getAccessTokenSafely(); + + return deleteRequest( + null, + createAction(PIC_DELETED), + `${window.API_BASE_URL}/api/v1/speakers/${speakerId}/photo?access_token=${accessToken}`, + null, + authErrorHandler + )({})(dispatch); +}; + const uploadBigPic = (entity, file) => async (dispatch) => { const accessToken = await getAccessTokenSafely(); @@ -157,6 +171,18 @@ const uploadBigPic = (entity, file) => async (dispatch) => { }); }; +const deleteBigPic = (speakerId) => async (dispatch) => { + const accessToken = await getAccessTokenSafely(); + + return deleteRequest( + null, + createAction(BIG_PIC_DELETED), + `${window.API_BASE_URL}/api/v1/speakers/${speakerId}/big-photo?access_token=${accessToken}`, + null, + authErrorHandler + )({})(dispatch); +}; + export const initSpeakersList = () => async (dispatch) => { dispatch(createAction(INIT_SPEAKERS_LIST_PARAMS)()); }; @@ -351,7 +377,7 @@ export const saveSpeaker = (entity) => async (dispatch) => { const normalizedEntity = normalizeEntity(entity); if (entity.id) { - putRequest( + return putRequest( createAction(UPDATE_SPEAKER), createAction(SPEAKER_UPDATED), `${window.API_BASE_URL}/api/v1/speakers/${entity.id}?access_token=${accessToken}`, @@ -361,28 +387,28 @@ export const saveSpeaker = (entity) => async (dispatch) => { )({})(dispatch).then(() => { dispatch(showSuccessMessage(T.translate("edit_speaker.speaker_saved"))); }); - } else { - const successMessage = { - title: T.translate("general.done"), - html: T.translate("edit_speaker.speaker_created"), - type: "success" - }; - - postRequest( - createAction(UPDATE_SPEAKER), - createAction(SPEAKER_ADDED), - `${window.API_BASE_URL}/api/v1/speakers?access_token=${accessToken}`, - normalizedEntity, - authErrorHandler, - entity - )({})(dispatch).then(() => { - dispatch( - showMessage(successMessage, () => { - history.push("/app/speakers"); - }) - ); - }); } + + const successMessage = { + title: T.translate("general.done"), + html: T.translate("edit_speaker.speaker_created"), + type: "success" + }; + + return postRequest( + createAction(UPDATE_SPEAKER), + createAction(SPEAKER_ADDED), + `${window.API_BASE_URL}/api/v1/speakers?access_token=${accessToken}`, + normalizedEntity, + authErrorHandler, + entity + )({})(dispatch).then(() => { + dispatch( + showMessage(successMessage, () => { + history.push("/app/speakers"); + }) + ); + }); }; export const attachPicture = (entity, file, picAttr) => async (dispatch) => { @@ -409,6 +435,22 @@ export const attachPicture = (entity, file, picAttr) => async (dispatch) => { }); }; +export const removeAttachedPicture = + (speakerId, picAttr) => async (dispatch) => { + const deleteFile = picAttr === "profile" ? deleteProfilePic : deleteBigPic; + + dispatch(startLoading()); + + return dispatch(deleteFile(speakerId)) + .then(() => { + dispatch(stopLoading()); + }) + .catch(() => { + // authErrorHandler already reported the failure and stopped loading; + // the reducer is left untouched so the picture stays visible. + }); + }; + /** ************************************************************************************************* */ /* SPEAKER ATTENDANCE */ /** ************************************************************************************************* */ diff --git a/src/components/forms/__tests__/speaker-form.test.js b/src/components/forms/__tests__/speaker-form.test.js new file mode 100644 index 000000000..77a75b278 --- /dev/null +++ b/src/components/forms/__tests__/speaker-form.test.js @@ -0,0 +1,152 @@ +/** + * @jest-environment jsdom + */ +import React from "react"; +import { render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import SpeakerForm from "../speaker-form"; +import showConfirmDialog from "../../mui/showConfirmDialog"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../../mui/showConfirmDialog", () => ({ + __esModule: true, + default: jest.fn() +})); + +const buildEntity = (overrides = {}) => ({ + id: 42, + title: "Dev", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@test.com", + member: null, + bio: "", + irc: "", + twitter: "", + company: "", + phone_number: "", + pic: "https://cdn.test/pic.png", + big_pic: "https://cdn.test/big.png", + all_presentations: [], + registration_codes: [], + summit_assistances: [], + affiliations: [], + ...overrides +}); + +const renderForm = (props = {}) => { + const onRemoveAttach = props.onRemoveAttach || jest.fn().mockResolvedValue(); + const entity = buildEntity(props.entity); + const { container, rerender } = render( + + ); + const rerenderWithEntity = (nextEntity) => + rerender( + + ); + return { onRemoveAttach, container, entity, rerenderWithEntity }; +}; + +// uicore's UploadInput only renders its `.remove` control while the preview is hovered +const previews = (container) => container.querySelectorAll(".file-box"); + +const clickRemove = async (container, index) => { + const preview = previews(container)[index]; + await userEvent.hover(preview); + await userEvent.click(preview.querySelector(".remove")); +}; + +describe("SpeakerForm photo removal", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("deletes the profile photo only after the user confirms", async () => { + showConfirmDialog.mockResolvedValue(true); + const { onRemoveAttach, container } = renderForm(); + + await clickRemove(container, 0); + + await waitFor(() => + expect(onRemoveAttach).toHaveBeenCalledWith(42, "profile") + ); + expect(showConfirmDialog).toHaveBeenCalledTimes(1); + }); + + it("deletes nothing when the user cancels the confirm", async () => { + showConfirmDialog.mockResolvedValue(false); + const { onRemoveAttach, container } = renderForm(); + + await clickRemove(container, 0); + + await waitFor(() => expect(showConfirmDialog).toHaveBeenCalledTimes(1)); + expect(onRemoveAttach).not.toHaveBeenCalled(); + }); + + it("targets the big photo endpoint from the big photo input", async () => { + showConfirmDialog.mockResolvedValue(true); + const { onRemoveAttach, container } = renderForm(); + + await clickRemove(container, 1); + + await waitFor(() => expect(onRemoveAttach).toHaveBeenCalledWith(42, "big")); + }); + + it("clears the field locally without confirming for an unsaved speaker", async () => { + const { onRemoveAttach, container } = renderForm({ entity: { id: 0 } }); + + await clickRemove(container, 0); + + await waitFor(() => expect(previews(container)).toHaveLength(1)); + expect(showConfirmDialog).not.toHaveBeenCalled(); + expect(onRemoveAttach).not.toHaveBeenCalled(); + }); + + it("preserves unsaved field edits when the removal updates props.entity", async () => { + showConfirmDialog.mockResolvedValue(true); + // real removeAttachedPicture resolves only after the PIC_DELETED-driven + // redux update has already flowed down as new props - a promise that + // never settles during this test reproduces that ordering + const onRemoveAttach = jest.fn(() => new Promise(() => {})); + const { container, entity, rerenderWithEntity } = renderForm({ + onRemoveAttach + }); + + const twitterInput = container.querySelector("#twitter"); + await userEvent.clear(twitterInput); + await userEvent.type(twitterInput, "unsaved-handle"); + expect(twitterInput.value).toBe("unsaved-handle"); + + await clickRemove(container, 0); + await waitFor(() => + expect(onRemoveAttach).toHaveBeenCalledWith(42, "profile") + ); + + // simulates the redux entity coming back down as props after PIC_DELETED, + // with only the picture field cleared and every other field unchanged + rerenderWithEntity({ ...entity, pic: "" }); + + expect(container.querySelector("#twitter").value).toBe("unsaved-handle"); + }); +}); diff --git a/src/components/forms/speaker-form.js b/src/components/forms/speaker-form.js index 9ee1713bc..8d8cdf20d 100644 --- a/src/components/forms/speaker-form.js +++ b/src/components/forms/speaker-form.js @@ -14,9 +14,9 @@ import React from "react"; import T from "i18n-react/dist/i18n-react"; import "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css"; -import MemberInput from "openstack-uicore-foundation/lib/components/inputs/member-input" -import UploadInput from "openstack-uicore-foundation/lib/components/inputs/upload-input" -import Input from "openstack-uicore-foundation/lib/components/inputs/text-input" +import MemberInput from "openstack-uicore-foundation/lib/components/inputs/member-input"; +import UploadInput from "openstack-uicore-foundation/lib/components/inputs/upload-input"; +import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"; import Panel from "openstack-uicore-foundation/lib/components/sections/panel"; import TextEditorV3 from "openstack-uicore-foundation/lib/components/inputs/editor-input-v3"; import { AffiliationsTable } from "../tables/affiliationstable"; @@ -29,6 +29,7 @@ import { } from "../../utils/methods"; import { mustReplaceSpeakerFieldsWithMemberInfo } from "../../models/app-config"; import CopyClipboard from "../buttons/copy-clipboard"; +import showConfirmDialog from "../mui/showConfirmDialog"; class SpeakerForm extends React.Component { constructor(props) { @@ -56,7 +57,14 @@ class SpeakerForm extends React.Component { scrollToError(this.props.errors); if (!shallowEqual(prevProps.entity, this.props.entity)) { - state.entity = { ...this.props.entity }; + state.entity = + this.pendingPictureOnlyUpdates > 0 + ? { + ...this.state.entity, + pic: this.props.entity.pic, + big_pic: this.props.entity.big_pic + } + : { ...this.props.entity }; state.errors = {}; } @@ -124,11 +132,36 @@ class SpeakerForm extends React.Component { this.props.onAttach(this.state.entity, formData, "big"); } - handleRemoveFile(picAttr) { + async handleRemoveFile(picAttr) { const entity = { ...this.state.entity }; - entity[picAttr] = ""; - this.setState({ entity }); + // a speaker that was not persisted yet has nothing to delete server side + if (!entity.id) { + entity[picAttr === "profile" ? "pic" : "big_pic"] = ""; + this.setState({ entity }); + return; + } + + const confirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: T.translate("edit_speaker.remove_pic_warning"), + iconType: "warning", + confirmButtonText: T.translate("general.yes_delete"), + confirmButtonColor: "error" + }); + + if (!confirmed) return; + + // guards componentDidUpdate against clobbering unsaved edits when the + // redux entity update lands (Redux updates pic/big_pic synchronously + // during the dispatch below, before this promise ever resolves). + // Counted rather than boolean so overlapping profile/big-pic deletions + // each keep the guard active until they've all settled. + this.pendingPictureOnlyUpdates = (this.pendingPictureOnlyUpdates || 0) + 1; + + this.props.onRemoveAttach(entity.id, picAttr).finally(() => { + this.pendingPictureOnlyUpdates -= 1; + }); } handleSubmit(publish, ev) { @@ -430,7 +463,7 @@ class SpeakerForm extends React.Component { this.handleRemoveFile("pic")} + handleRemove={() => this.handleRemoveFile("profile")} className="dropzone col-md-6" multiple={false} accept="image/*" @@ -448,7 +481,7 @@ class SpeakerForm extends React.Component { this.handleRemoveFile("big_pic")} + handleRemove={() => this.handleRemoveFile("big")} className="dropzone col-md-6" multiple={false} accept="image/*" diff --git a/src/i18n/en.json b/src/i18n/en.json index ae84c6051..005245cf0 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -768,6 +768,7 @@ "big_pic_size": "484 x 986px", "big_pic_format": "PNG or JPG", "affiliations": "Affiliations", + "remove_pic_warning": "This photo will be deleted right away and cannot be recovered.", "speaker_saved": "Speaker saved successfully.", "speaker_created": "Speaker created successfully.", "company": "Company", diff --git a/src/pages/speakers/edit-summit-speaker-page.js b/src/pages/speakers/edit-summit-speaker-page.js index f8e91e15b..362ed16bb 100644 --- a/src/pages/speakers/edit-summit-speaker-page.js +++ b/src/pages/speakers/edit-summit-speaker-page.js @@ -20,7 +20,8 @@ import { getSpeaker, resetSpeakerForm, saveSpeaker, - attachPicture + attachPicture, + removeAttachedPicture } from "../../actions/speaker-actions"; import { loadSummits } from "../../actions/summit-actions"; import "../../styles/edit-summit-speaker-page.less"; @@ -43,7 +44,7 @@ class EditSummitSpeakerPage extends React.Component { } } - componentDidUpdate(prevProps, prevState, snapshot) { + componentDidUpdate(prevProps) { const oldId = prevProps.match.params.speaker_id; const newId = this.props.match.params.speaker_id; @@ -64,6 +65,7 @@ class EditSummitSpeakerPage extends React.Component { history, saveSpeaker, attachPicture, + removeAttachedPicture, match } = this.props; const title = entity.id @@ -90,6 +92,7 @@ class EditSummitSpeakerPage extends React.Component { errors={errors} onSubmit={saveSpeaker} onAttach={attachPicture} + onRemoveAttach={removeAttachedPicture} /> ); @@ -106,5 +109,6 @@ export default connect(mapStateToProps, { getSpeaker, resetSpeakerForm, saveSpeaker, - attachPicture + attachPicture, + removeAttachedPicture })(EditSummitSpeakerPage); diff --git a/src/reducers/speakers/__tests__/speaker-reducer.test.js b/src/reducers/speakers/__tests__/speaker-reducer.test.js new file mode 100644 index 000000000..0861a2166 --- /dev/null +++ b/src/reducers/speakers/__tests__/speaker-reducer.test.js @@ -0,0 +1,38 @@ +/** + * @jest-environment jsdom + */ +import speakerReducer from "../speaker-reducer"; +import { BIG_PIC_DELETED, PIC_DELETED } from "../../../actions/speaker-actions"; + +const stateWithPics = { + entity: { + id: 42, + first_name: "Ada", + pic: "https://cdn.test/pic.png", + big_pic: "https://cdn.test/big.png" + }, + errors: {} +}; + +describe("speakerReducer photo deletion", () => { + it("clears pic and leaves big_pic untouched on PIC_DELETED", () => { + const next = speakerReducer(stateWithPics, { + type: PIC_DELETED, + payload: { response: {} } + }); + + expect(next.entity.pic).toBe(""); + expect(next.entity.big_pic).toBe("https://cdn.test/big.png"); + expect(next.entity.first_name).toBe("Ada"); + }); + + it("clears big_pic and leaves pic untouched on BIG_PIC_DELETED", () => { + const next = speakerReducer(stateWithPics, { + type: BIG_PIC_DELETED, + payload: { response: {} } + }); + + expect(next.entity.big_pic).toBe(""); + expect(next.entity.pic).toBe("https://cdn.test/pic.png"); + }); +}); diff --git a/src/reducers/speakers/speaker-reducer.js b/src/reducers/speakers/speaker-reducer.js index cb00ee95d..768aafbef 100644 --- a/src/reducers/speakers/speaker-reducer.js +++ b/src/reducers/speakers/speaker-reducer.js @@ -9,8 +9,10 @@ * 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 { VALIDATE } from "openstack-uicore-foundation/lib/utils/actions"; +import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; import { RECEIVE_SPEAKER, RESET_SPEAKER_FORM, @@ -18,17 +20,15 @@ import { SPEAKER_UPDATED, SPEAKER_ADDED, PIC_ATTACHED, - BIG_PIC_ATTACHED + PIC_DELETED, + BIG_PIC_ATTACHED, + BIG_PIC_DELETED } from "../../actions/speaker-actions"; - import { AFFILIATION_ADDED, AFFILIATION_DELETED } from "../../actions/member-actions"; -import { VALIDATE } from "openstack-uicore-foundation/lib/utils/actions"; -import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; - export const DEFAULT_ENTITY = { id: 0, title: "", @@ -57,135 +57,119 @@ const DEFAULT_STATE = { const speakerReducer = (state = DEFAULT_STATE, action) => { const { type, payload } = action; switch (type) { - case LOGOUT_USER: - { - // we need this in case the token expired while editing the form - if (payload.hasOwnProperty("persistStore")) { - return state; - } else { - return { ...state, entity: { ...DEFAULT_ENTITY }, errors: {} }; - } - } - break; - case RESET_SPEAKER_FORM: - { - return DEFAULT_STATE; - } - break; - case UPDATE_SPEAKER: - { - return { ...state, entity: { ...payload }, errors: {} }; + case LOGOUT_USER: { + // we need this in case the token expired while editing the form + if (payload.hasOwnProperty("persistStore")) { + return state; } - break; + return { ...state, entity: { ...DEFAULT_ENTITY }, errors: {} }; + } + case RESET_SPEAKER_FORM: { + return DEFAULT_STATE; + } + case UPDATE_SPEAKER: { + return { ...state, entity: { ...payload }, errors: {} }; + } case SPEAKER_ADDED: - case RECEIVE_SPEAKER: - { - let entity = { ...payload.response }; - let registration_code = "", - on_site_phone = "", - registered = false, - checked_in = false, - confirmed = false; + case RECEIVE_SPEAKER: { + const entity = { ...payload.response }; - for (var key in entity) { - if (entity.hasOwnProperty(key)) { - entity[key] = entity[key] == null ? "" : entity[key]; - } + for (const key in entity) { + if (entity.hasOwnProperty(key)) { + entity[key] = entity[key] == null ? "" : entity[key]; } + } - if (entity.hasOwnProperty("presentations")) { - entity.all_presentations = [ - ...entity.all_presentations, - ...entity.presentations - ]; - } + if (entity.hasOwnProperty("presentations")) { + entity.all_presentations = [ + ...entity.all_presentations, + ...entity.presentations + ]; + } - if (entity.hasOwnProperty("moderated_presentations")) { - entity.all_presentations = [ - ...entity.all_presentations, - ...entity.moderated_presentations - ]; - } + if (entity.hasOwnProperty("moderated_presentations")) { + entity.all_presentations = [ + ...entity.all_presentations, + ...entity.moderated_presentations + ]; + } - entity.all_presentations = entity.all_presentations.filter( - (v, i, a) => a.findIndex((t) => t.id === v.id) === i - ); + entity.all_presentations = entity.all_presentations.filter( + (v, i, a) => a.findIndex((t) => t.id === v.id) === i + ); - if (entity.hasOwnProperty("affiliations")) { - entity.affiliations = entity.affiliations.map((a) => { - let affiliationTmp = {}; - for (var key in a) { - affiliationTmp[key] = a[key] == null ? "" : a[key]; - } - return affiliationTmp; - }); - } + if (entity.hasOwnProperty("affiliations")) { + entity.affiliations = entity.affiliations.map((a) => { + const affiliationTmp = {}; + for (const key in a) { + affiliationTmp[key] = a[key] == null ? "" : a[key]; + } + return affiliationTmp; + }); + } - if (entity.hasOwnProperty("registration_code")) { - entity.registration_code = entity.registration_code.code; - entity.code_redeemed = entity.registration_code.redeemed; - } + if (entity.hasOwnProperty("registration_code")) { + entity.registration_code = entity.registration_code.code; + entity.code_redeemed = entity.registration_code.redeemed; + } - if (entity.hasOwnProperty("summit_assistance")) { - entity.on_site_phone = entity.summit_assistance.on_site_phone; - entity.registered = entity.summit_assistance.registered; - entity.checked_in = entity.summit_assistance.checked_in; - entity.confirmed = entity.summit_assistance.confirmed; - } + if (entity.hasOwnProperty("summit_assistance")) { + entity.on_site_phone = entity.summit_assistance.on_site_phone; + entity.registered = entity.summit_assistance.registered; + entity.checked_in = entity.summit_assistance.checked_in; + entity.confirmed = entity.summit_assistance.confirmed; + } - delete entity.languages; - delete entity.areas_of_expertise; - delete entity.organizational_roles; + delete entity.languages; + delete entity.areas_of_expertise; + delete entity.organizational_roles; - return { - ...state, - entity: { ...DEFAULT_ENTITY, ...entity }, - errors: {} - }; - } - break; + return { + ...state, + entity: { ...DEFAULT_ENTITY, ...entity }, + errors: {} + }; + } case PIC_ATTACHED: { - let pic = state.entity.pic + "?" + new Date().getTime(); - return { ...state, entity: { ...state.entity, pic: pic } }; + const pic = `${state.entity.pic}?${new Date().getTime()}`; + return { ...state, entity: { ...state.entity, pic } }; + } + case PIC_DELETED: { + return { ...state, entity: { ...state.entity, pic: "" } }; } case BIG_PIC_ATTACHED: { - let pic = state.entity.big_pic + "?" + new Date().getTime(); + const pic = `${state.entity.big_pic}?${new Date().getTime()}`; return { ...state, entity: { ...state.entity, big_pic: pic } }; } - case SPEAKER_UPDATED: - { - return state; - } - break; - case VALIDATE: - { - return { ...state, errors: payload.errors }; - } - break; - case AFFILIATION_ADDED: - { - let affiliation = { ...payload.response }; - return { - ...state, - entity: { - ...state.entity, - affiliations: [...state.entity.affiliations, affiliation] - } - }; - } - break; - case AFFILIATION_DELETED: - { - let { affiliationId } = payload; - let affiliations = state.entity.affiliations.filter( - (a) => a.id !== affiliationId - ); - return { - ...state, - entity: { ...state.entity, affiliations: affiliations } - }; - } - break; + case BIG_PIC_DELETED: { + return { ...state, entity: { ...state.entity, big_pic: "" } }; + } + case SPEAKER_UPDATED: { + return state; + } + case VALIDATE: { + return { ...state, errors: payload.errors }; + } + case AFFILIATION_ADDED: { + const affiliation = { ...payload.response }; + return { + ...state, + entity: { + ...state.entity, + affiliations: [...state.entity.affiliations, affiliation] + } + }; + } + case AFFILIATION_DELETED: { + const { affiliationId } = payload; + const affiliations = state.entity.affiliations.filter( + (a) => a.id !== affiliationId + ); + return { + ...state, + entity: { ...state.entity, affiliations } + }; + } default: return state; }