Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Implemented functionality to wait & sync host's calendar before requesting interview. #594

Merged
merged 3 commits into from Dec 20, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/constants/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ export const POPUP_STAGES = {
SELECT_DURATION: "selectDuration",
SEND_INTERVIEW_INVITE: "sendInterviewInvite",
SUCCESS: "sucess",
CALENDAR_SYNC_TIMED_OUT: "calendarSyncTimedOut",
CLOSE: "close",
};

Expand Down Expand Up @@ -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",
},
];

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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 (
<div styleName="timedout-wrapper">
<div styleName="timedout-text">
<p>Strange, it takes too long to sync your calendar {primaryCalendar.email}.</p>
<p>Please, try re-connecting your calendar by clicking "manage connected calendars". Would you have any issues, please don't hesitate to reach out to us by [email protected].</p>
</div>
<div styleName="button-wrapper">
<Button
onClick={() => onContinue(POPUP_STAGES.SEND_INTERVIEW_INVITE)}
size={BUTTON_SIZE.MEDIUM}
type={BUTTON_TYPE.PRIMARY}
>
OK
</Button>
</div>
</div>);
}

CalendarSyncTimedOut.propTypes = {
userSettings: PT.object,
};

export default CalendarSyncTimedOut;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@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;
}
143 changes: 115 additions & 28 deletions src/routes/PositionDetails/components/InterviewPopup/Confirm/index.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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";
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,
Expand All @@ -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
Expand All @@ -25,12 +28,62 @@ const Confirm = ({
onGoBack,
onContinue,
onShowingLoader,
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);

// disable loading indicator
onSetLoadingMessage(`Scheduling interview...`);

// continue with interview scheduling
onContinueAhead();
}
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 onSetLoadingMessage = (text) => setLoadingMessage(text);

/**
* This will trigger the API call to the server to request an interview
*/
Expand All @@ -40,6 +93,7 @@ const Confirm = ({
duration: scheduleDetails.duration,
availableTime: scheduleDetails.slots,
};
onSetLoadingMessage(null);
onShowingLoader(true);

confirmInterview(candidateId, params)
Expand All @@ -53,34 +107,65 @@ const Confirm = ({
});
};

const inviteInterviewCandidate = (userSettingsParam) => {
let primaryCalendar = getPrimaryCalendar(userSettingsParam);
let calendarSynced = isCalendarInSync(primaryCalendar);

if (primaryCalendar && !calendarSynced)
{
// show loading indicator with message
onSetLoadingMessage(`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 (
<div styleName="confirm-wrapper">
<StepsIndicator steps={SCHEDULE_INTERVIEW_STEPS} currentStep="confirm" />
<div styleName="confirm-text">
Send a <span styleName="confirm-text-bold">{duration} Minute</span>{" "}
Interview invite to <span styleName="confirm-text-bold">{handle}</span>.
This invite will allow <span styleName="confirm-text-bold">{handle}</span> to select and schedule an interview date
and time based on your availability.
</div>

<div styleName="button-wrapper">
<Button
styleName="back-button"
onClick={() => onGoBack()}
size={BUTTON_SIZE.MEDIUM}
type={BUTTON_TYPE.SECONDARY}
>
Back
</Button>
<Button
onClick={() => onContinueAhead()}
size={BUTTON_SIZE.MEDIUM}
type={BUTTON_TYPE.PRIMARY}
>
Confirm
</Button>
</div>
</div>
<>
{loadingMessage && <div
styleName={cn("spinner-wrapper", {
"show-spinner": loadingMessage,
})}
>
<Spinner stype="Oval" width={80} height={80} />
<p styleName="loading-message">{loadingMessage}</p>
</div>}
{!loadingMessage && <div styleName="confirm-wrapper">
<StepsIndicator steps={SCHEDULE_INTERVIEW_STEPS} currentStep="confirm" />
<div styleName="confirm-text">
Send a <span styleName="confirm-text-bold">{duration} Minute</span>{" "}
Interview invite to <span styleName="confirm-text-bold">{handle}</span>.
This invite will allow <span styleName="confirm-text-bold">{handle}</span> to select and schedule an interview date
and time based on your availability.
</div>

<div styleName="button-wrapper">
<Button
styleName="back-button"
onClick={() => onGoBack()}
size={BUTTON_SIZE.MEDIUM}
type={BUTTON_TYPE.SECONDARY}
>
Back
</Button>
<Button
onClick={() => inviteInterviewCandidate(userSettings)}
disabled={initiateInterviewConfirmation}
size={BUTTON_SIZE.MEDIUM}
type={BUTTON_TYPE.PRIMARY}
>
Confirm
</Button>
</div>
</div>}
</>
);
};

Expand All @@ -90,6 +175,8 @@ Confirm.propTypes = {
onGoBack: PT.func,
onContinue: PT.func,
onShowingLoader: PT.func,
userSettings: PT.object,
getSettingsModular: PT.func,
};

export default Confirm;
Original file line number Diff line number Diff line change
@@ -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 {
}

Expand Down
22 changes: 22 additions & 0 deletions src/routes/PositionDetails/components/InterviewPopup/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,6 +63,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
*/
Expand Down Expand Up @@ -213,12 +229,18 @@ const InterviewPopup = ({
onGoBack={onGoingBack}
onShowingLoader={onShowingLoader}
candidate={candidate}
userSettings={userSettings}
getSettingsModular={getSettingsModular}
/>
);
case POPUP_STAGES.SUCCESS:
return (
<Success candidate={candidate} onContinue={onChangeStage} />
);
case POPUP_STAGES.CALENDAR_SYNC_TIMED_OUT:
return (
<CalendarSyncTimedOut userSettings={userSettings} onContinue={onChangeStage} />
);
default:
return null;
}
Expand Down
2 changes: 1 addition & 1 deletion src/server/misc/routes/interview-thank-you-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async function getInterviewThankYouPageController(req, res) {
);
const object = {
jobTitle: nylasconfig.name,
calendarName: _.get(timeslots, '[0].host_name', ''),
calendarName: _.get(timeslots, "[0].host_name", ""),
tz: query.tz,
week: moment.unix(query.start_time).format("dddd"),
startDate: moment.unix(query.start_time).format("MMMM DD, yyyy"),
Expand Down
17 changes: 17 additions & 0 deletions src/utils/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,20 @@ export const setCurrentStage = (currentStepIdx, stages, setStagesCallback) => {
*/
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;
};