diff --git a/src/constants/index.js b/src/constants/index.js index cf076c1..741c466 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -440,6 +440,7 @@ export const POPUP_STAGES = { SELECT_DURATION: "selectDuration", SEND_INTERVIEW_INVITE: "sendInterviewInvite", SUCCESS: "sucess", + CALENDAR_SYNC_TIMED_OUT: "calendarSyncTimedOut", CLOSE: "close", }; @@ -478,6 +479,10 @@ export const INTERVIEW_POPUP_STAGES = [ id: POPUP_STAGES.SUCCESS, title: "Success! Interview invite sent", }, + { + id: POPUP_STAGES.CALENDAR_SYNC_TIMED_OUT, + title: "Send Interview Invite", + }, ]; /** diff --git a/src/routes/PositionDetails/components/InterviewPopup/CalendarSyncTimedOut/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/CalendarSyncTimedOut/index.jsx new file mode 100644 index 0000000..3bde428 --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/CalendarSyncTimedOut/index.jsx @@ -0,0 +1,44 @@ +import React from "react"; +import PT from "prop-types"; +import Button from "components/Button"; +import { BUTTON_SIZE, BUTTON_TYPE, POPUP_STAGES } from "constants"; +import "./styles.module.scss"; +import { getPrimaryCalendar } from "utils/helpers"; + +/** + * The CalendarSyncTimedOut component showing error message about calendar sync failure + */ +const CalendarSyncTimedOut = ({ userSettings, onContinue }) => { + const primaryCalendar = getPrimaryCalendar(userSettings); + + return ( +
+
+

Strange, it takes too long to sync your calendar {primaryCalendar.email}.

+

+ Please, try re-connecting your calendar by clicking " + onContinue(POPUP_STAGES.MANAGE_CALENDAR)} + styleName="manage-calendar" + > + manage connected calendars". Would you have any issues, please don't hesitate to reach out to us + by support@topcoder.com. +

+
+
+ +
+
); +} + +CalendarSyncTimedOut.propTypes = { + userSettings: PT.object, +}; + +export default CalendarSyncTimedOut; diff --git a/src/routes/PositionDetails/components/InterviewPopup/CalendarSyncTimedOut/styles.module.scss b/src/routes/PositionDetails/components/InterviewPopup/CalendarSyncTimedOut/styles.module.scss new file mode 100644 index 0000000..a2bc7e0 --- /dev/null +++ b/src/routes/PositionDetails/components/InterviewPopup/CalendarSyncTimedOut/styles.module.scss @@ -0,0 +1,42 @@ +@import "styles/include"; + +.timedout-wrapper { +} + +.timedout-text { + margin-top: 70px; + margin-bottom: 232px; + @include font-roboto; + font-size: 16px; + line-height: 26px; + font-weight: 400; +} + +.button-wrapper { + display: flex; + align-items: center; + margin-top: 16px; + justify-content: flex-end; +} + +.confirm-text-bold { + font-weight: bold; +} + +.manage-calendar { + color: #0d61bf; + @include font-roboto; + font-weight: 400; + font-size: 14px; + cursor: pointer; + text-decoration: underline; +} + +.topcoder-support-email-link { + color: #0d61bf !important; + @include font-roboto; + font-weight: 400; + font-size: 14px; + cursor: pointer; + text-decoration: underline; +} diff --git a/src/routes/PositionDetails/components/InterviewPopup/Confirm/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/Confirm/index.jsx index a45de9b..82fe067 100644 --- a/src/routes/PositionDetails/components/InterviewPopup/Confirm/index.jsx +++ b/src/routes/PositionDetails/components/InterviewPopup/Confirm/index.jsx @@ -1,5 +1,6 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import PT from "prop-types"; +import cn from "classnames"; import { useDispatch } from "react-redux"; import { useParams } from "@reach/router"; import { loadPosition } from "../../../actions"; @@ -7,6 +8,7 @@ import Button from "components/Button"; import { toastr } from "react-redux-toastr"; import StepsIndicator from "../../StepsIndicator"; +import Spinner from "components/CenteredSpinner"; import { confirmInterview } from "../../../../../services/interviews"; import { SCHEDULE_INTERVIEW_STEPS, @@ -15,6 +17,7 @@ import { POPUP_STAGES, } from "constants"; import "./styles.module.scss"; +import { getPrimaryCalendar, isCalendarInSync } from "utils/helpers"; /** * This component is used to get the confirmation before scheduling the interview @@ -25,23 +28,76 @@ const Confirm = ({ onGoBack, onContinue, onShowingLoader, + onSetLoadingMessage, + userSettings, + getSettingsModular, }) => { const { teamId, positionId } = useParams(); + const [loadingMessage, setLoadingMessage] = useState(null); + const [syncCalendarTimeoutId, setSyncCalendarTimeoutId] = useState(null); + // flag indicating the 1st attempt to refetch UserMeetingSettings is made, must repeat requests every 10secs + const [initiateInterviewConfirmation, setInitiateInterviewConfirmation] = useState(null); + // each timeout equals 10 seconds - so 120 seconds = 2mins = 12 timeouts - this excludes time taken for request response + const [totalSyncTimeouts, setTotalSyncTimeouts] = useState(0); const dispatch = useDispatch(); const { handle, id: candidateId } = candidate; const { duration } = scheduleDetails; + // check if primary calendar is syncing or synced and handle accordingly + useEffect(() => { + const primaryCalendar = getPrimaryCalendar(userSettings); + const calendarSynced = primaryCalendar ? isCalendarInSync(primaryCalendar) : false; + + // to know if calendar is ready and synced to request interview, we check for primary calendar + // & its sync status, combined with either the timeoutid or initiate confirmation flag + if ((primaryCalendar && calendarSynced) && (syncCalendarTimeoutId || initiateInterviewConfirmation)) + { + // clear timeout since calendar is now synced + clearTimeout(syncCalendarTimeoutId); + + // reset state + setSyncCalendarTimeoutId(null); + setInitiateInterviewConfirmation(false); + + // continue with interview scheduling + onContinueAhead(true); + } + else if ((primaryCalendar && !calendarSynced) && (syncCalendarTimeoutId || initiateInterviewConfirmation)) + { + if (totalSyncTimeouts > 12) + { + onContinue(POPUP_STAGES.CALENDAR_SYNC_TIMED_OUT); + } + else + { + // since primary calendar is still not synced, reset timeout + setSyncCalendarTimeoutId(setTimeout(() => getSettingsModular(), 10000)); + setTotalSyncTimeouts(totalSyncTimeouts + 1); + } + } + + // clear timeout on component unmount + return () => clearTimeout(syncCalendarTimeoutId); + }, [userSettings]); + + const onSetLoadingMessageLocal = (text) => setLoadingMessage(text); + /** * This will trigger the API call to the server to request an interview */ - const onContinueAhead = () => { + const onContinueAhead = (showSyncCompletedMessage) => { const params = { hostTimezone: scheduleDetails.timezone, duration: scheduleDetails.duration, availableTime: scheduleDetails.slots, }; + onSetLoadingMessageLocal(null); onShowingLoader(true); + // sync completed, let the user know scheduling is underway + if (showSyncCompletedMessage) + onSetLoadingMessage("Scheduling interview..."); + confirmInterview(candidateId, params) .then(() => dispatch(loadPosition(teamId, positionId))) .then(() => onContinue(POPUP_STAGES.SUCCESS)) @@ -53,34 +109,65 @@ const Confirm = ({ }); }; + const inviteInterviewCandidate = (userSettingsParam) => { + let primaryCalendar = getPrimaryCalendar(userSettingsParam); + let calendarSynced = isCalendarInSync(primaryCalendar); + + if (primaryCalendar && !calendarSynced) + { + // show loading indicator with message + onSetLoadingMessageLocal(`Syncing your new calendar ${primaryCalendar.email}, it might take a few minutes...`); + + // fetch UserMeetingSettings in the background, for the 1st time, no timeout is necessary, + // so we set initiateInterviewConfirmation value to true + getSettingsModular(); + setInitiateInterviewConfirmation(true); + } + else if (primaryCalendar && calendarSynced) + { + onContinueAhead(); + } + } + return ( -
- -
- Send a {duration} Minute{" "} - Interview invite to {handle}. - This invite will allow {handle} to select and schedule an interview date - and time based on your availability. -
- -
- - -
-
+ <> + {loadingMessage &&
+ +

{loadingMessage}

+
} + {!loadingMessage &&
+ +
+ Send a {duration} Minute{" "} + Interview invite to {handle}. + This invite will allow {handle} to select and schedule an interview date + and time based on your availability. +
+ +
+ + +
+
} + ); }; @@ -90,6 +177,9 @@ Confirm.propTypes = { onGoBack: PT.func, onContinue: PT.func, onShowingLoader: PT.func, + onSetLoadingMessage: PT.func, + userSettings: PT.object, + getSettingsModular: PT.func, }; export default Confirm; diff --git a/src/routes/PositionDetails/components/InterviewPopup/Confirm/styles.module.scss b/src/routes/PositionDetails/components/InterviewPopup/Confirm/styles.module.scss index bcb9411..884d1db 100644 --- a/src/routes/PositionDetails/components/InterviewPopup/Confirm/styles.module.scss +++ b/src/routes/PositionDetails/components/InterviewPopup/Confirm/styles.module.scss @@ -1,5 +1,18 @@ @import "styles/include"; +.spinner-wrapper { + padding-top: 103px; + display: none; +} + +.show-spinner { + display: block; +} + +.loading-message { + margin-top: 10px; +} + .confirm-wrapper { } diff --git a/src/routes/PositionDetails/components/InterviewPopup/index.jsx b/src/routes/PositionDetails/components/InterviewPopup/index.jsx index 494289f..0af8740 100644 --- a/src/routes/PositionDetails/components/InterviewPopup/index.jsx +++ b/src/routes/PositionDetails/components/InterviewPopup/index.jsx @@ -12,6 +12,7 @@ import ConnectCalendar from "./ConnectCalendar"; import SelectDuration from "./SelectDuration"; import Confirm from "./Confirm"; import Success from "./Success"; +import CalendarSyncTimedOut from "./CalendarSyncTimedOut"; import { getUserSettings } from "../../../../services/interviews"; import IconCrossBlack from "../../../../assets/images/icon-cross-black.svg"; @@ -35,6 +36,7 @@ const InterviewPopup = ({ const [stage, setStage] = useState(initialStage); const [previousStage, setPreviousStage] = useState(initialStage); const [isLoading, setLoading] = useState(false); + const [loadingMessage, setLoadingMessage] = useState(null); const [userSettings, setUserSettings] = useState(); const { v5UserProfile } = useSelector((state) => state.authUser); const [scheduleDetails, setScheduleDetails] = useState({ @@ -48,6 +50,12 @@ const InterviewPopup = ({ } }, [open]); + // if loading finished, reset value of loadingMessage + useEffect(() => { + if (!isLoading) + setLoadingMessage(null); + }, [isLoading]) + const onCloseInterviewPopup = () => { setStage(""); setScheduleDetails({ @@ -62,6 +70,21 @@ const InterviewPopup = ({ slots: userSettings.defaultAvailableTime || [] }); + const getSettingsModular = () => { + getUserSettings(v5UserProfile.id) + .then((res) => { + setUserSettings(res.data); + }) + .catch((e) => { + if (e.response && e.response.status === 404) { + setStage(POPUP_STAGES.SCHEDULE_INTERVIEW); + } else { + toastr.error("Failed to get user settings"); + onCloseInterviewPopup(); + } + }) + } + /** * Gets the settings from the backend and checks if the calendar is already available */ @@ -153,6 +176,8 @@ const InterviewPopup = ({ setLoading(loading); }; + const onSetLoadingMessage = (text) => setLoadingMessage(text); + /** * Removes the calendar from the state once it is removed from the server */ @@ -213,12 +238,19 @@ const InterviewPopup = ({ onGoBack={onGoingBack} onShowingLoader={onShowingLoader} candidate={candidate} + userSettings={userSettings} + getSettingsModular={getSettingsModular} + onSetLoadingMessage={onSetLoadingMessage} /> ); case POPUP_STAGES.SUCCESS: return ( ); + case POPUP_STAGES.CALENDAR_SYNC_TIMED_OUT: + return ( + + ); default: return null; } @@ -279,6 +311,7 @@ const InterviewPopup = ({ })} > + {isLoading && loadingMessage &&

{loadingMessage}

}
{ */ export const isCustomRole = (role) => !role || !role.name || CUSTOM_ROLE_NAMES.includes(role.name.toLowerCase()); + +/** + * Extracts primaryCalendar from UserMeetingSettings object + * @param {Object} userMeetingSettings UserMeetingSettings instance to check + * @returns {object} the calendar object which is primary + */ +export const getPrimaryCalendar = (userMeetingSettings) => + _.find(userMeetingSettings.nylasCalendars, (calendar) => calendar.isPrimary); + +/** + * Checks if calendar is synced or not + * @param {Object} calendar calendar to check + * @returns {boolean} whether the calendar is in sync or not + */ +export const isCalendarInSync = (calendar) => { + if (calendar) return calendar && calendar.calendarId; +};