diff --git a/.env.example b/.env.example index d0facb2fc..1ac7e9a39 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,7 @@ EMAIL_SCOPES="clients/read templates/read templates/write emails/read" FILE_UPLOAD_SCOPES="files/upload" SPONSOR_PAGES_API_URL=https://sponsor-pages-api.dev.fnopen.com SPONSOR_PAGES_SCOPES="page-template/read page-template/write show-page/read show-page/write media-upload/read" -SCOPES="profile openid offline_access reports/all ${EMAIL_SCOPES} ${INVENTORY_API_SCOPES} ${FILE_UPLOAD_SCOPES} ${PURCHASES_API_SCOPES} ${SPONSOR_USERS_SCOPES} ${SPONSOR_PAGES_SCOPES} ${DROPBOX_MATERIALIZER_API_SCOPES} ${SCOPES_BASE_REALM}/summits/delete-event ${SCOPES_BASE_REALM}/companies/read ${SCOPES_BASE_REALM}/companies/write ${SCOPES_BASE_REALM}/summits/write ${SCOPES_BASE_REALM}/summits/write-event ${SCOPES_BASE_REALM}/summits/read/all ${SCOPES_BASE_REALM}/summits/read ${SCOPES_BASE_REALM}/summits/publish-event ${SCOPES_BASE_REALM}/members/read ${SCOPES_BASE_REALM}/members/read/me ${SCOPES_BASE_REALM}/speakers/write ${SCOPES_BASE_REALM}/attendees/write ${SCOPES_BASE_REALM}/members/write ${SCOPES_BASE_REALM}/organizations/write ${SCOPES_BASE_REALM}/organizations/read ${SCOPES_BASE_REALM}/summits/write-presentation-materials ${SCOPES_BASE_REALM}/summits/registration-orders/update ${SCOPES_BASE_REALM}/summits/registration-orders/delete ${SCOPES_BASE_REALM}/summits/registration-orders/create/offline ${SCOPES_BASE_REALM}/summits/badge-scans/read config-values/write ${SCOPES_BASE_REALM}/summit-administrator-groups/read ${SCOPES_BASE_REALM}/summit-administrator-groups/write ${SCOPES_BASE_REALM}/summit-media-file-types/read ${SCOPES_BASE_REALM}/summit-media-file-types/write user-roles/write entity-updates/publish ${SCOPES_BASE_REALM}/audit-logs/read filter-criteria/read filter-criteria/write" +SCOPES="profile openid offline_access reports/all ${EMAIL_SCOPES} ${INVENTORY_API_SCOPES} ${FILE_UPLOAD_SCOPES} ${PURCHASES_API_SCOPES} ${SPONSOR_USERS_SCOPES} ${SPONSOR_PAGES_SCOPES} ${DROPBOX_MATERIALIZER_API_SCOPES} ${SCOPES_BASE_REALM}/summits/delete-event ${SCOPES_BASE_REALM}/companies/read ${SCOPES_BASE_REALM}/companies/write ${SCOPES_BASE_REALM}/summits/write ${SCOPES_BASE_REALM}/summits/write-event ${SCOPES_BASE_REALM}/summits/read/all ${SCOPES_BASE_REALM}/summits/read ${SCOPES_BASE_REALM}/summits/publish-event ${SCOPES_BASE_REALM}/members/read ${SCOPES_BASE_REALM}/members/read/me ${SCOPES_BASE_REALM}/speakers/write ${SCOPES_BASE_REALM}/attendees/write ${SCOPES_BASE_REALM}/members/write ${SCOPES_BASE_REALM}/organizations/write ${SCOPES_BASE_REALM}/organizations/read ${SCOPES_BASE_REALM}/summits/write-presentation-materials ${SCOPES_BASE_REALM}/summits/registration-orders/update ${SCOPES_BASE_REALM}/summits/registration-orders/delete ${SCOPES_BASE_REALM}/summits/registration-orders/create/offline ${SCOPES_BASE_REALM}/summits/badge-scans/read ${SCOPES_BASE_REALM}/summits/badge-scans/write config-values/write ${SCOPES_BASE_REALM}/summit-administrator-groups/read ${SCOPES_BASE_REALM}/summit-administrator-groups/write ${SCOPES_BASE_REALM}/summit-media-file-types/read ${SCOPES_BASE_REALM}/summit-media-file-types/write user-roles/write entity-updates/publish ${SCOPES_BASE_REALM}/audit-logs/read filter-criteria/read filter-criteria/write" GOOGLE_API_KEY= ALLOWED_USER_GROUPS="super-admins administrators summit-front-end-administrators summit-room-administrators track-chairs-admins sponsors" APP_CLIENT_NAME="openstack" diff --git a/package.json b/package.json index 487c0058b..d83ebe1c6 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "moment": "^2.29.1", "moment-duration-format": "^2.3.2", "moment-timezone": "^0.5.33", - "openstack-uicore-foundation": "5.0.12", + "openstack-uicore-foundation": "5.0.13", "p-limit": "^6.1.0", "path-browserify": "^1.0.1", "postcss-loader": "^6.2.1", diff --git a/src/actions/attendee-actions.js b/src/actions/attendee-actions.js index 460511120..fc5f47ea4 100644 --- a/src/actions/attendee-actions.js +++ b/src/actions/attendee-actions.js @@ -809,3 +809,29 @@ export const queryPaidAttendees = _.debounce( }, DEBOUNCE_WAIT ); + +export const queryAttendees = _.debounce(async (input, summitId, callback) => { + const accessToken = await getAccessTokenSafely(); + + const endpoint = URI( + `${window.API_BASE_URL}/api/v1/summits/${summitId}/attendees` + ); + + input = escapeFilterValue(input); + endpoint.addQuery("access_token", accessToken); + endpoint.addQuery("order", "first_name,last_name"); + endpoint.addQuery("page", 1); + endpoint.addQuery("per_page", DEFAULT_PER_PAGE); + + if (input) { + endpoint.addQuery("filter[]", `full_name=@${input},email=@${input}`); + } + + fetch(endpoint) + .then(fetchResponseHandler) + .then((json) => { + const options = [...json.data]; + callback(options); + }) + .catch(fetchErrorHandler); +}, DEBOUNCE_WAIT); diff --git a/src/actions/sponsor-actions.js b/src/actions/sponsor-actions.js index 34779011f..a4ada1494 100644 --- a/src/actions/sponsor-actions.js +++ b/src/actions/sponsor-actions.js @@ -109,6 +109,7 @@ export const REQUEST_BADGE_SCANS = "REQUEST_BADGE_SCANS"; export const RECEIVE_BADGE_SCANS = "RECEIVE_BADGE_SCANS"; export const RECEIVE_BADGE_SCAN = "RECEIVE_BADGE_SCAN"; export const BADGE_SCAN_UPDATED = "BADGE_SCAN_UPDATED"; +export const BADGE_SCAN_ADDED = "BADGE_SCAN_ADDED"; export const RESET_BADGE_SCAN_FORM = "RESET_BADGE_SCAN_FORM"; export const RECEIVE_SPONSORS_WITH_SCANS = "RECEIVE_SPONSORS_WITH_SCANS"; @@ -308,7 +309,7 @@ export const getSponsor = (sponsorId) => async (dispatch, getState) => { const params = { access_token: accessToken, expand: - "company,members,sponsorships,sponsorships.type,featured_event,extra_questions,lead_report_setting", + "company,members,sponsorships,sponsorships.type,featured_event,extra_questions,extra_questions.values,lead_report_setting", fields: "featured_event.id,featured_event.title,sponsorships.id,sponsorships.type.id,sponsorships.type.type_id" }; @@ -343,15 +344,16 @@ export const addSponsorToSummit = (entity) => async (dispatch, getState) => { normalizedEntity, snackbarErrorHandler, entity - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate("sponsor_list.sponsor_added") - }) - ); - }); + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("sponsor_list.sponsor_added") + }) + ); + }) + .finally(() => dispatch(stopLoading())); }; export const getSponsorTiers = @@ -418,15 +420,16 @@ export const addTierToSponsor = normalizedSponsorships, snackbarErrorHandler, sponsorships - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate("edit_sponsor.sponsorship_added") - }) - ); - }); + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_sponsor.sponsorship_added") + }) + ); + }) + .finally(() => dispatch(stopLoading())); }; export const removeTierFromSponsor = @@ -450,15 +453,16 @@ export const removeTierFromSponsor = `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/sponsorships/${sponsorshipId}`, null, snackbarErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate("edit_sponsor.sponsorship_removed") - }) - ); - }); + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_sponsor.sponsorship_removed") + }) + ); + }) + .finally(() => dispatch(stopLoading())); }; const normalizeSponsorToAdd = (entity) => { @@ -595,15 +599,16 @@ export const removeAddonToSponsorship = `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/sponsorships/${sponsorshipId}/add-ons/${addonId}`, null, snackbarErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate("edit_sponsor.sponsorship_addon_removed") - }) - ); - }); + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_sponsor.sponsorship_addon_removed") + }) + ); + }) + .finally(() => dispatch(stopLoading())); }; const normalizeAddons = (entity) => { @@ -860,15 +865,16 @@ export const deleteExtraQuestion = `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsorId}/extra-questions/${questionId}`, null, snackbarErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.done"), - html: T.translate("edit_sponsor.extra_question_deleted") - }) - ); - }); + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_sponsor.extra_question_deleted") + }) + ); + }) + .finally(() => dispatch(stopLoading())); }; export const saveSponsorExtraQuestion = @@ -904,15 +910,16 @@ export const saveSponsorExtraQuestion = normalizedEntity, snackbarErrorHandler, entity - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.done"), - html: T.translate("edit_sponsor.extra_question_saved") - }) - ); - }); + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_sponsor.extra_question_saved") + }) + ); + }) + .finally(() => dispatch(stopLoading())); } return postRequest( @@ -922,16 +929,17 @@ export const saveSponsorExtraQuestion = normalizedEntity, snackbarErrorHandler, entity - )(params)(dispatch).then(({ response }) => { - dispatch(stopLoading()); - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.done"), - html: T.translate("edit_sponsor.extra_question_created") - }) - ); - return response; - }); + )(params)(dispatch) + .then(({ response }) => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_sponsor.extra_question_created") + }) + ); + return response; + }) + .finally(() => dispatch(stopLoading())); }; export const getSponsorExtraQuestion = @@ -1266,15 +1274,16 @@ export const deleteSummitSponsorship = `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/sponsorships-types/${sponsorshipId}`, null, snackbarErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate("summit_sponsorship_list.tier_deleted") - }) - ); - }); + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("summit_sponsorship_list.tier_deleted") + }) + ); + }) + .finally(() => dispatch(stopLoading())); }; const normalizeSponsorship = (entity) => { @@ -1436,9 +1445,7 @@ export const getBadgeScan = (scanId) => async (dispatch, getState) => { createAction(RECEIVE_BADGE_SCAN), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/badge-scans/${scanId}`, authErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - }); + )(params)(dispatch).finally(() => dispatch(stopLoading())); }; export const saveBadgeScan = (entity) => async (dispatch, getState) => { @@ -1461,15 +1468,45 @@ export const saveBadgeScan = (entity) => async (dispatch, getState) => { normalizedEntity, snackbarErrorHandler, entity - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch( - snackbarSuccessHandler({ - title: T.translate("general.success"), - html: T.translate("edit_badge_scan.badge_scan_saved") - }) - ); - }); + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_badge_scan.badge_scan_saved") + }) + ); + }) + .finally(() => dispatch(stopLoading())); +}; + +export const addBadgeScan = (entity) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const accessToken = await getAccessTokenSafely(); + const { currentSummit } = currentSummitState; + + dispatch(startLoading()); + + const params = { + access_token: accessToken + }; + + return postRequest( + null, + createAction(BADGE_SCAN_ADDED), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/badge-scans`, + entity, + snackbarErrorHandler + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_badge_scan.badge_scan_saved") + }) + ); + }) + .finally(() => dispatch(stopLoading())); }; export const resetBadgeScanForm = () => (dispatch) => { diff --git a/src/components/mui/__tests__/mui-qr-badge-popup.test.js b/src/components/mui/__tests__/mui-qr-badge-popup.test.js new file mode 100644 index 000000000..923a7dd30 --- /dev/null +++ b/src/components/mui/__tests__/mui-qr-badge-popup.test.js @@ -0,0 +1,364 @@ +// mui-qr-badge-popup.test.js +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import MuiQrBadgePopup from "../mui-qr-badge-popup"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("moment-timezone", () => { + const mockMoment = jest.fn(() => ({ unix: () => 1234567890 })); + return mockMoment; +}); + +jest.mock("../../qr-reader", () => { + const React = require("react"); + return { + __esModule: true, + default: ({ onScan, onError }) => ( +
+ + +
+ ) + }; +}); + +jest.mock("../formik-inputs/mui-formik-async-select", () => { + const React = require("react"); + const { useField } = require("formik"); + return { + __esModule: true, + default: ({ name, placeholder }) => { + const [field, , helpers] = useField(name); + return ( + + helpers.setValue({ value: e.target.value, label: e.target.value }) + } + /> + ); + } + }; +}); + +jest.mock( + "openstack-uicore-foundation/lib/components/extra-questions-mui", + () => ({ + __esModule: true, + default: () =>
+ }) +); + +const mockErrorMessage = jest.fn(); +jest.mock( + "openstack-uicore-foundation/lib/components/mui/snackbar-notification", + () => ({ + useSnackbarMessage: () => ({ errorMessage: mockErrorMessage }) + }) +); + +jest.mock("../../../actions/attendee-actions", () => ({ + queryAttendees: jest.fn() +})); + +jest.mock("@mui/material/IconButton", () => { + const React = require("react"); + return { + __esModule: true, + default: ({ children, onClick, ...rest }) => ( + + ) + }; +}); + +const defaultProps = { + onClose: jest.fn(), + onSave: jest.fn(), + extraQuestions: [], + isAdmin: false, + summitId: 123 +}; + +const renderComponent = (props = {}) => + render(); + +describe("MuiQrBadgePopup", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Initialization", () => { + it("should render radio buttons, no content panels, and a disabled submit button", () => { + renderComponent(); + + expect( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.scan_qr" + }) + ).toBeInTheDocument(); + expect( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.attendee" + }) + ).toBeInTheDocument(); + expect(screen.queryByTestId("qr-reader")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("async-select-attendee_email") + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "general.save" }) + ).toBeDisabled(); + }); + + it("should call onClose when close button is clicked", async () => { + const onClose = jest.fn(); + renderComponent({ onClose }); + + await userEvent.click(screen.getByRole("button", { name: "" })); + + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe("QR scan mode", () => { + it("should show QR reader and keep submit disabled when QR mode is selected", async () => { + renderComponent(); + + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.scan_qr" + }) + ); + + expect(screen.getByTestId("qr-reader")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "general.save" }) + ).toBeDisabled(); + }); + + it("should show success alert, hide QR reader, and enable submit after scanning", async () => { + renderComponent(); + + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.scan_qr" + }) + ); + await userEvent.click(screen.getByText("Simulate Scan")); + + expect( + screen.getByText("sponsor_badge_scans.scan_popup.badge_scanned") + ).toBeInTheDocument(); + expect(screen.queryByTestId("qr-reader")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "general.save" }) + ).not.toBeDisabled(); + }); + + it("should restore QR reader when rescan button is clicked", async () => { + renderComponent(); + + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.scan_qr" + }) + ); + await userEvent.click(screen.getByText("Simulate Scan")); + await userEvent.click( + screen.getByRole("button", { + name: "sponsor_badge_scans.scan_popup.rescan" + }) + ); + + expect(screen.getByTestId("qr-reader")).toBeInTheDocument(); + expect( + screen.queryByText("sponsor_badge_scans.scan_popup.badge_scanned") + ).not.toBeInTheDocument(); + }); + + it("should call error handler when QR scan fails", async () => { + renderComponent(); + + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.scan_qr" + }) + ); + await userEvent.click(screen.getByText("Simulate Error")); + + expect(mockErrorMessage).toHaveBeenCalledWith( + "sponsor_badge_scans.scan_popup.error" + ); + }); + + it("should submit payload with qr_code only and no attendee_email", async () => { + const onSave = jest.fn(); + renderComponent({ onSave }); + + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.scan_qr" + }) + ); + await userEvent.click(screen.getByText("Simulate Scan")); + await userEvent.click( + screen.getByRole("button", { name: "general.save" }) + ); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ qr_code: "TEST_QR_CODE" }) + ); + const [payload] = onSave.mock.calls[0]; + expect(payload).not.toHaveProperty("attendee_email"); + }); + }); + }); + + describe("Attendee mode", () => { + it("should show attendee autocomplete and keep submit disabled when attendee mode is selected", async () => { + renderComponent(); + + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.attendee" + }) + ); + + expect( + screen.getByTestId("async-select-attendee_email") + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "general.save" }) + ).toBeDisabled(); + }); + + it("should enable submit and send attendee_email only after selecting an attendee", async () => { + const onSave = jest.fn(); + renderComponent({ onSave }); + + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.attendee" + }) + ); + await userEvent.type( + screen.getByTestId("async-select-attendee_email"), + "john@example.com" + ); + + expect( + screen.getByRole("button", { name: "general.save" }) + ).not.toBeDisabled(); + + await userEvent.click( + screen.getByRole("button", { name: "general.save" }) + ); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ attendee_email: "john@example.com" }) + ); + const [payload] = onSave.mock.calls[0]; + expect(payload).not.toHaveProperty("qr_code"); + }); + }); + }); + + describe("Mode switching", () => { + it("should hide QR reader when switching from QR to attendee mode", async () => { + renderComponent(); + + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.scan_qr" + }) + ); + expect(screen.getByTestId("qr-reader")).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.attendee" + }) + ); + + expect(screen.queryByTestId("qr-reader")).not.toBeInTheDocument(); + expect( + screen.getByTestId("async-select-attendee_email") + ).toBeInTheDocument(); + }); + + it("should reset scanned QR code when switching mode", async () => { + renderComponent(); + + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.scan_qr" + }) + ); + await userEvent.click(screen.getByText("Simulate Scan")); + expect( + screen.getByText("sponsor_badge_scans.scan_popup.badge_scanned") + ).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.attendee" + }) + ); + await userEvent.click( + screen.getByRole("radio", { + name: "sponsor_badge_scans.scan_popup.scan_qr" + }) + ); + + expect(screen.getByTestId("qr-reader")).toBeInTheDocument(); + expect( + screen.queryByText("sponsor_badge_scans.scan_popup.badge_scanned") + ).not.toBeInTheDocument(); + }); + }); + + describe("Admin-only fields", () => { + const extraQuestions = [ + { id: 1, name: "Company", type: "Text", order: 1 }, + { id: 2, name: "Title", type: "Text", order: 2 } + ]; + + it("should show notes field and extra questions for admin when questions are provided", () => { + renderComponent({ isAdmin: true, extraQuestions }); + + expect(screen.getByText("edit_badge_scan.notes")).toBeInTheDocument(); + expect(screen.getByTestId("extra-questions")).toBeInTheDocument(); + }); + + it("should not show notes field or extra questions for non-admin users", () => { + renderComponent({ isAdmin: false, extraQuestions }); + + expect( + screen.queryByText("edit_badge_scan.notes") + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("extra-questions")).not.toBeInTheDocument(); + }); + + it("should not show extra questions when list is empty even for admin", () => { + renderComponent({ isAdmin: true, extraQuestions: [] }); + + expect(screen.queryByTestId("extra-questions")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/mui/mui-qr-badge-popup.js b/src/components/mui/mui-qr-badge-popup.js new file mode 100644 index 000000000..894fd45cf --- /dev/null +++ b/src/components/mui/mui-qr-badge-popup.js @@ -0,0 +1,251 @@ +/** + * Copyright 2019 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, { useState } from "react"; +import moment from "moment-timezone"; +import T from "i18n-react/dist/i18n-react"; +import { FormikProvider, useFormik } from "formik"; +import ExtraQuestionsMUI from "openstack-uicore-foundation/lib/components/extra-questions-mui"; +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + FormControlLabel, + IconButton, + InputLabel, + Radio, + RadioGroup, + Typography +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { useSnackbarMessage } from "openstack-uicore-foundation/lib/components/mui/snackbar-notification"; +import MuiFormikTextField from "./formik-inputs/mui-formik-textfield"; +import QrReader from "../qr-reader"; +import { getTypeValue, toSlug } from "../../utils/extra-questions"; +import MuiFormikAsyncAutocomplete from "./formik-inputs/mui-formik-async-select"; +import { queryAttendees } from "../../actions/attendee-actions"; + +const buildInitialValues = (extraQuestions) => { + const values = { notes: "", attendee_email: "" }; + extraQuestions.forEach((q) => { + values[toSlug(q.name, q.id)] = getTypeValue("", q.type); + }); + return values; +}; + +const BADGE_SCAN_MODE_QR = "qr"; +const BADGE_SCAN_MODE_ATTENDEE = "attendee"; + +const MuiQrBadgePopup = ({ + onClose, + onSave, + extraQuestions = [], + isAdmin, + summitId +}) => { + const { errorMessage } = useSnackbarMessage(); + const [scannedCode, setScannedCode] = useState(null); + const [scanMode, setScanMode] = useState(null); + + const formik = useFormik({ + initialValues: buildInitialValues(extraQuestions), + onSubmit: (values) => { + const { attendee_email, notes, ...extraValues } = values; + + const extra_questions = Object.entries(extraValues) + .map(([slug, value]) => ({ + question_id: parseInt(slug.split("_").pop()), + answer: Array.isArray(value) + ? value.filter((v) => v !== "").join(",") + : value + })) + .filter((q) => q.answer); + + const entity = { + ...(scanMode === BADGE_SCAN_MODE_QR + ? { qr_code: scannedCode } + : { attendee_email: attendee_email.value }), + scan_date: moment().unix(), + notes, + extra_questions + }; + + return onSave(entity); + } + }); + + const handleScanModeChange = (e) => { + setScanMode(e.target.value); + setScannedCode(null); + }; + + const handleScan = (data) => { + if (!data) return; + setScannedCode(data); + }; + + const handleRescan = () => { + setScannedCode(null); + }; + + const handleError = () => { + errorMessage(T.translate("sponsor_badge_scans.scan_popup.error")); + }; + + const isSubmitDisabled = + formik.isSubmitting || + !scanMode || + (scanMode === BADGE_SCAN_MODE_QR && !scannedCode) || + (scanMode === BADGE_SCAN_MODE_ATTENDEE && !formik.values.attendee_email); + + return ( + + + + {T.translate("sponsor_badge_scans.scan_popup.scan_qr")} + + + + + + + + + + + } + label={T.translate("sponsor_badge_scans.scan_popup.scan_qr")} + /> + } + label={T.translate("sponsor_badge_scans.scan_popup.attendee")} + /> + + + {scanMode === BADGE_SCAN_MODE_QR && + (scannedCode ? ( + + {T.translate("sponsor_badge_scans.scan_popup.rescan")} + + } + > + {T.translate("sponsor_badge_scans.scan_popup.badge_scanned")} + + ) : ( + + + + ))} + + {scanMode === BADGE_SCAN_MODE_ATTENDEE && ( + + + {T.translate("sponsor_badge_scans.scan_popup.attendee")} + + ({ + value: attendee.email.toString(), + label: `${attendee.first_name || ""} ${ + attendee.last_name || "" + } (${attendee.email || attendee.id})` + })} + /> + + )} + + {isAdmin && ( + + + {T.translate("edit_badge_scan.notes")} + + + + )} + + {extraQuestions.length > 0 && isAdmin && ( + <> + + {T.translate("edit_badge_scan.extra_questions")} + + a.order - b.order + )} + formik={formik} + allowEdit + /> + + )} + + + + + + + + + ); +}; + +export default MuiQrBadgePopup; diff --git a/src/i18n/en.json b/src/i18n/en.json index c8c218949..6a83d8242 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2608,6 +2608,14 @@ "last_name": "Last Name", "email": "Email", "company": "Company", + "scan_popup": { + "scan_qr": "Scan QR", + "attendee": "Attendee", + "error": "Cannot read QR code, please try again", + "badge_scanned": "Badge scanned successfully", + "rescan": "Re-scan", + "attendee_placeholder": "Enter Attendee..." + }, "placeholders": { "search": "Search..." } diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-badge-scans/index.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-badge-scans/index.js index e6221391d..8dfa79781 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-badge-scans/index.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-badge-scans/index.js @@ -23,13 +23,18 @@ import { getBadgeScans, exportBadgeScans, getBadgeScan, - saveBadgeScan + saveBadgeScan, + addBadgeScan } from "../../../../../actions/sponsor-actions"; import { DEFAULT_CURRENT_PAGE } from "../../../../../utils/constants"; import EditBadgeScanPopup from "./edit-badge-scan-popup"; +import MuiQrBadgePopup from "../../../../../components/mui/mui-qr-badge-popup"; +import Member from "../../../../../models/member"; const SponsorBadgeScans = ({ + member, sponsor, + summitId, badgeScans, totalBadgeScans, term, @@ -41,14 +46,20 @@ const SponsorBadgeScans = ({ exportBadgeScans, getBadgeScan, saveBadgeScan, + addBadgeScan, currentBadgeScan }) => { useEffect(() => { if (sponsor?.id) getBadgeScans(sponsor.id); }, [sponsor]); + const memberObj = new Member(member); + const isAdmin = memberObj.hasAccess("admin-sponsors"); + const [searchTerm, setSearchTerm] = useState(term); const [showEditBadgeScanPopup, setShowEditBadgeScanPopup] = useState(false); + const [showManualBadgeScanPopup, setShowManualBadgeScanPopup] = + useState(false); const handleSearch = (ev) => { if (ev.key === "Enter") { @@ -94,7 +105,23 @@ const SponsorBadgeScans = ({ saveBadgeScan(badgeScan).then(() => setShowEditBadgeScanPopup(false)); }; - const handleNewManualScan = () => {}; + const handleNewManualScan = () => { + setShowManualBadgeScanPopup(true); + }; + + const handleManualScanSubmit = (entity) => { + addBadgeScan(entity).then(() => { + setShowManualBadgeScanPopup(false); + return getBadgeScans( + sponsor.id, + term, + DEFAULT_CURRENT_PAGE, + perPage, + order, + orderDir + ); + }); + }; const handleExportBadgeScans = () => { exportBadgeScans(sponsor); @@ -233,23 +260,37 @@ const SponsorBadgeScans = ({ onSubmit={handleBadgeScanSave} /> )} + {showManualBadgeScanPopup && ( + setShowManualBadgeScanPopup(false)} + extraQuestions={sponsor.extra_questions} + isAdmin={isAdmin} + summitId={summitId} + /> + )} ); }; const mapStateToProps = ({ + loggedUserState, badgeScansListState, currentBadgeScanState, - currentSponsorState + currentSponsorState, + currentSummitState }) => ({ ...badgeScansListState, currentBadgeScan: currentBadgeScanState.entity, - sponsor: currentSponsorState.entity + member: loggedUserState.member, + sponsor: currentSponsorState.entity, + summitId: currentSummitState.currentSummit.id }); export default connect(mapStateToProps, { getBadgeScans, exportBadgeScans, getBadgeScan, - saveBadgeScan + saveBadgeScan, + addBadgeScan })(SponsorBadgeScans); diff --git a/src/utils/extra-questions.js b/src/utils/extra-questions.js index 375ccd8ce..c9c545dbe 100644 --- a/src/utils/extra-questions.js +++ b/src/utils/extra-questions.js @@ -18,7 +18,7 @@ export const getTypeValue = (ans, type) => { case QuestionType_Checkbox: return ans === "true"; case QuestionType_CheckBoxList: - return ans?.split(",") || []; + return ans ? ans.split(",") : []; case QuestionType_CountryComboBox: case QuestionType_ComboBox: return ans || ""; diff --git a/yarn.lock b/yarn.lock index 7f95cfb32..a7d130a7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8832,10 +8832,10 @@ open@^10.0.3: is-inside-container "^1.0.0" wsl-utils "^0.1.0" -openstack-uicore-foundation@5.0.12: - version "5.0.12" - resolved "https://registry.npmjs.org/openstack-uicore-foundation/-/openstack-uicore-foundation-5.0.12.tgz#a14122cc8aef683bbe4aaf699f38e0c8d9d3aa09" - integrity sha512-zPJ0abHHVwT7SVOle3iXoWvn9QvbWe+qWKvppnTrNRivYNbZi1yOBj0oaUALfmgBlG+uD4xu6jSiWWiNdyQQmA== +openstack-uicore-foundation@5.0.13: + version "5.0.13" + resolved "https://registry.yarnpkg.com/openstack-uicore-foundation/-/openstack-uicore-foundation-5.0.13.tgz#4ca8c0d5505781d589a5b148fe22f71ed4924271" + integrity sha512-SkUgfk5/UweqHmjBxgROu6/doMYJyG+ZbkUDVFoTq6FxBq7nJ8Ma2EqW+oKHuKKbNtPVJiXF3THSIaNFOshlEA== optionator@^0.9.1: version "0.9.4"