diff --git a/package-lock.json b/package-lock.json
index 84f77334..a32ecd62 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6988,6 +6988,11 @@
"integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
"dev": true
},
+ "faker": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/faker/-/faker-5.5.3.tgz",
+ "integrity": "sha512-wLTv2a28wjUyWkbnX7u/ABZBkUkIF2fCd73V6P2oFqEGEktDfzWx4UxrSqtPRw0xPRAcjeAOIiJWqZm3pP4u3g=="
+ },
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -7113,6 +7118,11 @@
"@babel/runtime": "^7.10.0"
}
},
+ "final-form-arrays": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.0.2.tgz",
+ "integrity": "sha512-TfO8aZNz3RrsZCDx8GHMQcyztDNpGxSSi9w4wpSNKlmv2PfFWVVM8P7Yj5tj4n0OWax+x5YwTLhT5BnqSlCi+w=="
+ },
"finalhandler": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
@@ -14472,6 +14482,24 @@
}
}
},
+ "react-final-form-arrays": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.3.tgz",
+ "integrity": "sha512-dzBiLfbr9l1YRExARBpJ8uA/djBenCvFrbrsXjd362joDl3vT+WhmMKKr6HDQMJffjA8T4gZ3n5+G9M59yZfuQ==",
+ "requires": {
+ "@babel/runtime": "^7.12.1"
+ },
+ "dependencies": {
+ "@babel/runtime": {
+ "version": "7.13.10",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.10.tgz",
+ "integrity": "sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==",
+ "requires": {
+ "regenerator-runtime": "^0.13.4"
+ }
+ }
+ }
+ },
"react-input-autosize": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-3.0.0.tgz",
diff --git a/package.json b/package.json
index cf19d263..31bd4caf 100644
--- a/package.json
+++ b/package.json
@@ -60,7 +60,9 @@
"axios": "^0.21.0",
"classnames": "^2.2.6",
"express": "^4.17.1",
+ "faker": "^5.5.3",
"final-form": "^4.20.1",
+ "final-form-arrays": "^3.0.2",
"immutability-helper": "^3.1.1",
"lodash": "^4.17.20",
"moment": "^2.29.1",
@@ -71,6 +73,7 @@
"react-datepicker": "^3.4.1",
"react-dom": "^16.12.0",
"react-final-form": "^6.5.2",
+ "react-final-form-arrays": "^3.1.3",
"react-loader-spinner": "^4.0.0",
"react-outside-click-handler": "^1.3.0",
"react-popper": "^2.2.3",
diff --git a/src/components/Accordion/index.jsx b/src/components/Accordion/index.jsx
new file mode 100644
index 00000000..890639e9
--- /dev/null
+++ b/src/components/Accordion/index.jsx
@@ -0,0 +1,44 @@
+/**
+ * Accordion
+ *
+ * An expandable item which can be used
+ * repeatadly to form an accordion style display
+ */
+
+import React, { useState } from "react";
+import PT from "prop-types";
+import "./styles.module.scss";
+
+function Accordion(props) {
+ const { title, sidebar, subhead, children } = props;
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
setIsOpen(!isOpen)} styleName="button">
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+ {isOpen &&
{children}
}
+
+ );
+}
+
+Accordion.propTypes = {
+ title: PT.string,
+ sidebar: PT.string,
+ subhead: PT.string,
+ children: PT.node,
+};
+
+export default Accordion;
diff --git a/src/components/Accordion/styles.module.scss b/src/components/Accordion/styles.module.scss
new file mode 100644
index 00000000..ffa9a997
--- /dev/null
+++ b/src/components/Accordion/styles.module.scss
@@ -0,0 +1,70 @@
+@import "styles/include";
+
+.accordion {
+ padding-bottom: 10px;
+ border-bottom: 1px solid #e9e9e9;
+}
+
+.button {
+ cursor: pointer;
+ width: 100%;
+ border: none;
+ outline: none;
+ background-color: #fff;
+ color: #2a2a2a;
+ display: flex;
+ text-align: left;
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: center;
+ padding: 15px 0 10px 0;
+
+ p {
+ @include font-roboto;
+ font-size: 14px;
+ }
+}
+
+.down-arrow {
+ display: inline-block;
+ content: '';
+ height: 13px;
+ width: 13px;
+ margin-right: 16px;
+ border-bottom: 3px solid #137D60;
+ border-right: 3px solid #137D60;
+ transform: rotate(45deg);
+}
+
+.right-arrow {
+ display: inline-block;
+ content: '';
+ height: 13px;
+ width: 13px;
+ margin-right: 16px;
+ border-bottom: 3px solid #137D60;
+ border-right: 3px solid #137D60;
+ transform: rotate(-45deg);
+}
+
+.heading {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+}
+
+.title {
+ @include font-barlow;
+ font-weight: 600;
+ font-size: 20px;
+ margin: 0;
+ padding: 0;
+ text-transform: uppercase;
+}
+
+.panel {
+ padding-left: 28px;
+ font-size: 14px;
+}
\ No newline at end of file
diff --git a/src/components/Radio/index.jsx b/src/components/Radio/index.jsx
new file mode 100644
index 00000000..5072d2c7
--- /dev/null
+++ b/src/components/Radio/index.jsx
@@ -0,0 +1,45 @@
+/**
+ * Radio
+ *
+ * A styled radio button
+ * Used in RadioFieldGroup component
+ */
+
+import React from "react";
+import PT from "prop-types";
+import cn from "classnames";
+import "./styles.module.scss";
+
+function Radio(props) {
+ return (
+
+
+
+ {props.label}
+
+ );
+}
+
+Radio.propTypes = {
+ onChange: PT.func,
+ onBlur: PT.func,
+ onFocus: PT.func,
+ value: PT.string.isRequired,
+ disabled: PT.bool,
+ type: PT.string.isRequired,
+ label: PT.string,
+ checked: PT.bool,
+ horizontal: PT.bool,
+};
+
+export default Radio;
diff --git a/src/components/Radio/styles.module.scss b/src/components/Radio/styles.module.scss
new file mode 100644
index 00000000..4e4001e0
--- /dev/null
+++ b/src/components/Radio/styles.module.scss
@@ -0,0 +1,59 @@
+.radio-input {
+ width: auto;
+ height: auto;
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.container {
+ display: block;
+ position: relative;
+ padding-left: 35px;
+ margin-bottom: 24px;
+ cursor: pointer;
+ font-size: 14px;
+ user-select: none;
+
+ &.horizontal {
+ margin-bottom: 0;
+ display: inline-block;
+ margin-left: 24px;
+ }
+}
+
+.custom {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 24px;
+ width: 24px;
+ background-color: #fff;
+ border-radius: 50%;
+ border: 1px solid #AAA;
+}
+
+.radio-input:checked ~ .custom {
+ background-color: #0AB88A;
+ box-shadow: inset 0 1px 2px 0 rgba(0, 0, 0, 0.29);
+}
+
+.custom:after {
+ content: "";
+ position: absolute;
+ display: none;
+}
+
+.radio-input:checked ~ .custom:after {
+ display: block;
+}
+
+.container .custom:after {
+ top: 5px;
+ left: 5px;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.35);
+ background: white;
+}
\ No newline at end of file
diff --git a/src/components/RadioFieldGroup/index.jsx b/src/components/RadioFieldGroup/index.jsx
new file mode 100644
index 00000000..b36608ac
--- /dev/null
+++ b/src/components/RadioFieldGroup/index.jsx
@@ -0,0 +1,48 @@
+/**
+ * RadioFieldGroup
+ *
+ * Component that takes a configuration object
+ * and returns a group of react-final-form radio fields
+ */
+
+import React from "react";
+import PT from "prop-types";
+import Radio from "components/Radio";
+import { Field } from "react-final-form";
+
+function RadioFieldGroup({ name, isHorizontal, radios }) {
+ return (
+
+ {radios.map((radio) => (
+
+ {({ input }) => (
+
+ )}
+
+ ))}
+
+ );
+}
+
+RadioFieldGroup.propTypes = {
+ name: PT.string.isRequired,
+ isHorizontal: PT.bool,
+ radios: PT.arrayOf(
+ PT.shape({
+ label: PT.string.isRequired,
+ value: PT.any.isRequired,
+ })
+ ),
+};
+
+export default RadioFieldGroup;
diff --git a/src/components/SimpleModal/index.jsx b/src/components/SimpleModal/index.jsx
new file mode 100644
index 00000000..1180d415
--- /dev/null
+++ b/src/components/SimpleModal/index.jsx
@@ -0,0 +1,73 @@
+/**
+ * SimpleModal
+ *
+ * Wraps the react-responsive-modal
+ * and adds the app's style
+ *
+ * The same as the BaseModal, but with only a single close button
+ * Supports title
+ * children are displayed as modal content
+ */
+
+import React from "react";
+import PT from "prop-types";
+import { Modal } from "react-responsive-modal";
+import Button from "../Button";
+import IconCross from "../../assets/images/icon-cross-light.svg";
+import "./styles.module.scss";
+
+const modalStyle = {
+ borderRadius: "8px",
+ padding: "32px 32px 22px 32px",
+ maxWidth: "640px",
+ width: "100%",
+ margin: 0,
+};
+
+const containerStyle = {
+ padding: "10px",
+};
+
+export const SimpleModal = ({
+ open,
+ onClose,
+ extraModalStyle,
+ title,
+ children,
+ disabled,
+}) => (
+ }
+ styles={{
+ modal: { ...modalStyle, ...extraModalStyle },
+ modalContainer: containerStyle,
+ }}
+ center={true}
+ >
+ {title && {title} }
+ {children}
+
+
+ Close
+
+
+
+);
+
+SimpleModal.propTypes = {
+ open: PT.bool.isRequired,
+ onClose: PT.func.isRequired,
+ children: PT.node,
+ title: PT.string,
+ disabled: PT.bool,
+ extraModalStyle: PT.object,
+};
+
+export default SimpleModal;
diff --git a/src/components/SimpleModal/styles.module.scss b/src/components/SimpleModal/styles.module.scss
new file mode 100644
index 00000000..e1c1d36c
--- /dev/null
+++ b/src/components/SimpleModal/styles.module.scss
@@ -0,0 +1,28 @@
+@import "styles/include";
+
+.title {
+ @include font-barlow-condensed;
+ font-weight: normal;
+ font-size: 34px;
+ line-height: 40px;
+ margin: 0 0 24px 0;
+ overflow-wrap: anywhere;
+ padding: 0;
+ text-transform: uppercase;
+}
+
+.content {
+ @include font-roboto;
+ line-height: 140%;
+}
+
+.button-group {
+ margin: 24px 0 0 0;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ button {
+ margin-right: 10px;
+ margin-bottom: 10px;
+ }
+}
diff --git a/src/constants/index.js b/src/constants/index.js
index 6afdbdf5..a46a7e77 100644
--- a/src/constants/index.js
+++ b/src/constants/index.js
@@ -124,14 +124,14 @@ export const CANDIDATE_STATUS_FILTERS = [
},
{
key: CANDIDATE_STATUS_FILTER_KEY.INTERESTED,
- buttonText: "Interested",
- title: "Interested Candidates",
+ buttonText: "Interviews",
+ title: "Interviews",
statuses: [CANDIDATE_STATUS.SHORTLIST, CANDIDATE_STATUS.INTERVIEW],
},
{
key: CANDIDATE_STATUS_FILTER_KEY.NOT_INTERESTED,
- buttonText: "Not Interested",
- title: "Not Interested Candidates",
+ buttonText: "Declined",
+ title: "Declined",
statuses: [CANDIDATE_STATUS.REJECTED, CANDIDATE_STATUS.TOPCODER_REJECTED],
},
];
@@ -171,6 +171,10 @@ export const ACTION_TYPE = {
UPDATE_CANDIDATE_SUCCESS: "UPDATE_CANDIDATE_SUCCESS",
UPDATE_CANDIDATE_ERROR: "UPDATE_CANDIDATE_ERROR",
+ ADD_INTERVIEW: "ADD_INTERVIEW",
+ ADD_INTERVIEW_PENDING: "ADD_INTERVIEW_PENDING",
+ ADD_INTERVIEW_SUCCESS: "ADD_INTERVIEW_SUCCESS",
+ ADD_INTERVIEW_ERROR: "ADD_INTERVIEW_ERROR",
/*
withAuthentication
*/
diff --git a/src/routes/PositionDetails/actions/index.js b/src/routes/PositionDetails/actions/index.js
index e7fdf31a..9d34101e 100644
--- a/src/routes/PositionDetails/actions/index.js
+++ b/src/routes/PositionDetails/actions/index.js
@@ -1,8 +1,14 @@
/**
* Position Details page actions
*/
-import { getPositionDetails, patchPositionCandidate } from "services/teams";
+import _ from "lodash";
+import {
+ getPositionDetails,
+ patchPositionCandidate,
+ patchCandidateInterview,
+} from "services/teams";
import { ACTION_TYPE } from "constants";
+import { getFakeInterviews } from "utils/helpers";
/**
* Load Team Position details (team job)
@@ -17,6 +23,12 @@ export const loadPosition = (teamId, positionId) => ({
payload: async () => {
const response = await getPositionDetails(teamId, positionId);
+ // inject mock interview data to candidates list
+ for (const candidate of response.data.candidates) {
+ const fakeInterviews = getFakeInterviews(candidate);
+ _.set(candidate, "interviews", fakeInterviews);
+ }
+
return response.data;
},
meta: {
@@ -48,6 +60,25 @@ export const updateCandidate = (candidateId, partialCandidateData) => ({
},
});
+/**
+ * Add interview on server and in Redux store
+ *
+ * @param {string} candidateId position candidate id
+ * @param {object} interviewData data to submit in patch request
+ * @returns {object} an interview object
+ */
+export const addInterview = (candidateId, interviewData) => ({
+ type: ACTION_TYPE.ADD_INTERVIEW,
+ payload: async () => {
+ const response = await patchCandidateInterview(candidateId, interviewData);
+
+ return response.data;
+ },
+ meta: {
+ candidateId,
+ },
+});
+
/**
* Reset position state
*/
diff --git a/src/routes/PositionDetails/components/InterviewConfirmPopup/index.jsx b/src/routes/PositionDetails/components/InterviewConfirmPopup/index.jsx
new file mode 100644
index 00000000..fc287fa4
--- /dev/null
+++ b/src/routes/PositionDetails/components/InterviewConfirmPopup/index.jsx
@@ -0,0 +1,54 @@
+/**
+ * InterviewConfirmPopup
+ *
+ * Popup to Confirm submission of an interview
+ */
+import React from "react";
+import PT from "prop-types";
+import SimpleModal from "components/SimpleModal";
+import "./styles.module.scss";
+
+function InterviewConfirmPopup({ open, onClose }) {
+ return (
+
+
+
+ Please check your email and look for emails from
+ scheduler@topcoder.com .
+
+
+ All interviewers will be able to select availability from there and
+ the system will help you select and book the time.
+
+
+ You may manually select your available times from the email, or click
+ “View More Times” to see
+ expanded options, Additionally, you may click
+ “Overlay My Calendar” to
+ integrate with your calendar and allow the system to schedule based on
+ your calendar availability.
+
+
+ If you have any issues with scheduling, please contact
+ talent@topcoder.com .
+
+
+
+
+ );
+}
+
+InterviewConfirmPopup.propTypes = {
+ open: PT.bool,
+ onClose: PT.func,
+};
+
+export default InterviewConfirmPopup;
diff --git a/src/routes/PositionDetails/components/InterviewConfirmPopup/styles.module.scss b/src/routes/PositionDetails/components/InterviewConfirmPopup/styles.module.scss
new file mode 100644
index 00000000..7d0520e6
--- /dev/null
+++ b/src/routes/PositionDetails/components/InterviewConfirmPopup/styles.module.scss
@@ -0,0 +1,17 @@
+.modal-content {
+ & > p {
+ margin-bottom: 20px;
+ }
+ & * a {
+ color: #0D61BF;
+ }
+}
+
+.highlighted {
+ color: #219174;
+ font-weight: 500;
+}
+
+.video {
+ width: 100%;
+}
\ No newline at end of file
diff --git a/src/routes/PositionDetails/components/InterviewDetailsPopup/index.jsx b/src/routes/PositionDetails/components/InterviewDetailsPopup/index.jsx
new file mode 100644
index 00000000..ed7df2fa
--- /dev/null
+++ b/src/routes/PositionDetails/components/InterviewDetailsPopup/index.jsx
@@ -0,0 +1,237 @@
+/**
+ * InterviewDetailsPopup
+ *
+ * Popup that allows user to schedule an interview
+ * Calls addInterview action
+ */
+import React, { useEffect, useState, useCallback } from "react";
+import { getAuthUserProfile } from "@topcoder/micro-frontends-navbar-app";
+import { Form } from "react-final-form";
+import arrayMutators from "final-form-arrays";
+import { FieldArray } from "react-final-form-arrays";
+import { useDispatch } from "react-redux";
+import { addInterview } from "../../actions";
+import User from "components/User";
+import BaseModal from "components/BaseModal";
+import FormField from "components/FormField";
+import Button from "components/Button";
+import { FORM_FIELD_TYPE } from "constants";
+import "./styles.module.scss";
+import RadioFieldGroup from "components/RadioFieldGroup";
+
+/* Validators for Form */
+
+const validateExists = (value) => {
+ return value ? undefined : "Required";
+};
+
+const validateIsEmail = (value) => {
+ if (!value) return undefined;
+ return /\S+@\S+\.\S+/.test(value) ? undefined : "Please enter valid email";
+};
+
+const validator = (values) => {
+ const errors = {};
+
+ errors.myemail =
+ validateExists(values.myemail) || validateIsEmail(values.myemail);
+ errors.email2 =
+ validateExists(values.email2) || validateIsEmail(values.email2);
+
+ errors.emails = [];
+ if (values.emails) {
+ for (const email of values.emails) {
+ errors.emails.push(validateIsEmail(email));
+ }
+ }
+
+ return errors;
+};
+
+/********************* */
+
+function InterviewDetailsPopup({ open, onClose, candidate, openNext }) {
+ const [isLoading, setIsLoading] = useState(true);
+ const [myEmail, setMyEmail] = useState("");
+ const [myId, setMyId] = useState("");
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ getAuthUserProfile().then((res) => {
+ setMyEmail(res.email || "");
+ setMyId(res.userId);
+ setIsLoading(false);
+ });
+ }, []);
+
+ const onSubmitCallback = useCallback(
+ async (formData) => {
+ const secondaryEmails =
+ formData.emails?.filter(
+ (email) => typeof email === "string" && email.length > 0
+ ) || [];
+ const interviewData = {
+ xaiTemplate: formData.time,
+ attendeesList: [formData.myemail, formData.email2, ...secondaryEmails],
+ round: candidate.interviews.length + 1,
+ createdBy: myId,
+ };
+
+ await dispatch(addInterview(candidate.id, interviewData));
+ },
+ [dispatch, candidate, myId]
+ );
+
+ return isLoading ? null : (
+
+ );
+}
+
+export default InterviewDetailsPopup;
diff --git a/src/routes/PositionDetails/components/InterviewDetailsPopup/styles.module.scss b/src/routes/PositionDetails/components/InterviewDetailsPopup/styles.module.scss
new file mode 100644
index 00000000..bdd9262d
--- /dev/null
+++ b/src/routes/PositionDetails/components/InterviewDetailsPopup/styles.module.scss
@@ -0,0 +1,74 @@
+@import "styles/include";
+
+.user {
+ font-size: 14px;
+ color: #0D61BF;
+}
+
+.top {
+ padding-bottom: 25px;
+ border-bottom: 1px solid #E9E9E9;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.center {
+ padding: 25px 0;
+ border-bottom: 1px solid #E9E9E9;
+}
+
+.center-header {
+ @include font-barlow;
+ font-weight: 600;
+ font-size: 20px;
+ margin: 0 0 10px 0;
+ padding: 0;
+ text-transform: uppercase;
+}
+
+.modal-text {
+ @include font-roboto;
+ font-size: 14px;
+}
+
+.bottom {
+ padding-top: 25px;
+ padding-bottom: 8px;
+}
+
+.add-more {
+ outline: none;
+ background: #fff;
+ margin: 10px 0 0 0;
+ padding: 0;
+ color: blue;
+ border: none;
+ border-radius: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.array-item {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ justify-content: space-between;
+}
+
+.array-input {
+ width: 95%
+}
+
+.remove-item {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ margin-bottom: 10px;
+ font-size: 33px;
+ color: #EF476F;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/src/routes/PositionDetails/components/LatestInterview/index.jsx b/src/routes/PositionDetails/components/LatestInterview/index.jsx
new file mode 100644
index 00000000..6a74cc2c
--- /dev/null
+++ b/src/routes/PositionDetails/components/LatestInterview/index.jsx
@@ -0,0 +1,31 @@
+/**
+ * LastestInterview
+ *
+ * Table item showing candidates latest interview
+ */
+import React from "react";
+import PT from "prop-types";
+import "./styles.module.scss";
+import { formatDate } from "utils/format";
+
+function LatestInterview({ interviews }) {
+ if (!interviews.length) {
+ return
;
+ }
+
+ const latestInterview = interviews[interviews.length - 1];
+
+ return (
+ <>
+ Interview Round {latestInterview.round}
+ {latestInterview.status}
+ {formatDate(latestInterview.startTimestamp)}
+ >
+ );
+}
+
+LatestInterview.propTypes = {
+ interviews: PT.array,
+};
+
+export default LatestInterview;
diff --git a/src/routes/PositionDetails/components/LatestInterview/styles.module.scss b/src/routes/PositionDetails/components/LatestInterview/styles.module.scss
new file mode 100644
index 00000000..ab6df681
--- /dev/null
+++ b/src/routes/PositionDetails/components/LatestInterview/styles.module.scss
@@ -0,0 +1,8 @@
+.small {
+ font-size: 12px;
+}
+
+.strong {
+ font-weight: bold;
+ margin-bottom: 8px;
+}
\ No newline at end of file
diff --git a/src/routes/PositionDetails/components/PositionCandidates/index.jsx b/src/routes/PositionDetails/components/PositionCandidates/index.jsx
index a6d87006..a84f42f7 100644
--- a/src/routes/PositionDetails/components/PositionCandidates/index.jsx
+++ b/src/routes/PositionDetails/components/PositionCandidates/index.jsx
@@ -26,6 +26,12 @@ import { toastr } from "react-redux-toastr";
import { getJobById } from "services/jobs";
import { PERMISSIONS } from "constants/permissions";
import { hasPermission } from "utils/permissions";
+import ActionsMenu from "components/ActionsMenu";
+import LatestInterview from "../LatestInterview";
+import InterviewDetailsPopup from "../InterviewDetailsPopup";
+import PreviousInterviewsPopup from "../PreviousInterviewsPopup";
+import InterviewConfirmPopup from "../InterviewConfirmPopup";
+import SelectCandidatePopup from "../SelectCandidatePopup";
/**
* Generates a function to sort candidates
@@ -59,11 +65,35 @@ const populateSkillsMatched = (position, candidate) => ({
});
const PositionCandidates = ({ position, statusFilterKey, updateCandidate }) => {
+ const [interviewDetailsOpen, setInterviewDetailsOpen] = useState(false);
+ const [prevInterviewsOpen, setPrevInterviewsOpen] = useState(false);
+ const [selectedCandidate, setSelectedCandidate] = useState(null);
+ const [interviewConfirmOpen, setInterviewConfirmOpen] = useState(false);
+ const [selectCandidateOpen, setSelectCandidateOpen] = useState(false);
+ const [isReject, setIsReject] = useState(false);
+
+ const openInterviewDetailsPopup = (candidate) => {
+ setSelectedCandidate(candidate);
+ setInterviewDetailsOpen(true);
+ };
+
+ const openPrevInterviewsPopup = (candidate) => {
+ setSelectedCandidate(candidate);
+ setPrevInterviewsOpen(true);
+ };
+
+ const openSelectCandidatePopup = (candidate, isReject) => {
+ setSelectedCandidate(candidate);
+ isReject ? setIsReject(true) : setIsReject(false);
+ setSelectCandidateOpen(true);
+ };
+
const { candidates } = position;
const [sortBy, setSortBy] = useState(CANDIDATES_SORT_BY.SKILL_MATCHED);
- const statusFilter = useMemo(() =>
- _.find(CANDIDATE_STATUS_FILTERS, { key: statusFilterKey })
- , [statusFilterKey]);
+ const statusFilter = useMemo(
+ () => _.find(CANDIDATE_STATUS_FILTERS, { key: statusFilterKey }),
+ [statusFilterKey]
+ );
const filteredCandidates = useMemo(
() =>
@@ -112,17 +142,19 @@ const PositionCandidates = ({ position, statusFilterKey, updateCandidate }) => {
const markCandidateShortlisted = useCallback(
(candidateId) => {
- updateCandidate(candidateId, {
+ return updateCandidate(candidateId, {
status: CANDIDATE_STATUS.SHORTLIST,
})
.then(() => {
toastr.success("Candidate is marked as interested.");
+ setSelectCandidateOpen(false);
})
.catch((error) => {
toastr.error(
"Failed to mark candidate as interested",
error.toString()
);
+ setSelectCandidateOpen(false);
});
},
[updateCandidate]
@@ -130,120 +162,177 @@ const PositionCandidates = ({ position, statusFilterKey, updateCandidate }) => {
const markCandidateRejected = useCallback(
(candidateId) => {
- updateCandidate(candidateId, {
+ return updateCandidate(candidateId, {
status: CANDIDATE_STATUS.REJECTED,
})
.then(() => {
toastr.success("Candidate is marked as not interested.");
+ setSelectCandidateOpen(false);
})
.catch((error) => {
toastr.error(
"Failed to mark candidate as not interested",
error.toString()
);
+ setSelectCandidateOpen(false);
});
},
[updateCandidate]
);
return (
-
-
- }
- />
+ <>
+
+
+ }
+ />
- {filteredCandidates.length === 0 && (
-
- No {statusFilter.title}
-
- )}
- {filteredCandidates.length > 0 && (
-
- {pageCandidates.map((candidate) => (
-
-
-
-
-
-
- {statusFilterKey === CANDIDATE_STATUS_FILTER_KEY.TO_REVIEW && hasPermission(PERMISSIONS.UPDATE_JOB_CANDIDATE) && (
- <>
- Interested in this candidate?
-
- markCandidateRejected(candidate.id)}
- disabled={candidate.updating}
- >
- No
-
- markCandidateShortlisted(candidate.id)}
- disabled={candidate.updating}
- >
- Yes
-
-
- >
+ {filteredCandidates.length === 0 && (
+
No {statusFilter.title}
+ )}
+ {filteredCandidates.length > 0 && (
+
+ {pageCandidates.map((candidate) => (
+
+
+
+
+
+ {statusFilterKey === CANDIDATE_STATUS_FILTER_KEY.INTERESTED && (
+
+
+
)}
+
+ {statusFilterKey === CANDIDATE_STATUS_FILTER_KEY.TO_REVIEW &&
+ hasPermission(PERMISSIONS.UPDATE_JOB_CANDIDATE) && (
+
+
{
+ openInterviewDetailsPopup(candidate);
+ },
+ },
+ {
+ separator: true,
+ },
+ {
+ label: "Select Candidate",
+ action: () => {
+ openSelectCandidatePopup(candidate);
+ },
+ },
+ {
+ label: "Decline Candidate",
+ action: () => {
+ openSelectCandidatePopup(candidate, true);
+ },
+ },
+ ]}
+ />
+
+ )}
+ {statusFilterKey === CANDIDATE_STATUS_FILTER_KEY.INTERESTED &&
+ hasPermission(PERMISSIONS.UPDATE_JOB_CANDIDATE) && (
+
+ openInterviewDetailsPopup(candidate)}
+ >
+ Schedule Another Interview
+
+ {candidate.interviews.length > 0 && (
+ openPrevInterviewsPopup(candidate)}
+ >
+ View Previous Interviews
+
+ )}
+
+ )}
+
-
- ))}
-
- )}
-
-
-
- Show More
-
- {filteredCandidates.length > 0 && (
-
+ ))}
+
)}
+
+
+
+ Show More
+
+ {filteredCandidates.length > 0 && (
+
+ )}
+
-
+
setPrevInterviewsOpen(false)}
+ candidate={selectedCandidate}
+ />
+ setInterviewDetailsOpen(false)}
+ candidate={selectedCandidate}
+ openNext={() => setInterviewConfirmOpen(true)}
+ />
+ setInterviewConfirmOpen(false)}
+ />
+ setSelectCandidateOpen(false)}
+ />
+ >
);
};
diff --git a/src/routes/PositionDetails/components/PositionCandidates/styles.module.scss b/src/routes/PositionDetails/components/PositionCandidates/styles.module.scss
index 50fb7e2d..11babadb 100644
--- a/src/routes/PositionDetails/components/PositionCandidates/styles.module.scss
+++ b/src/routes/PositionDetails/components/PositionCandidates/styles.module.scss
@@ -50,22 +50,30 @@
justify-content: center;
align-items: flex-start;
flex-direction: column;
- width: 50%;
+ width: 30%;
}
-.cell-action {
+.cell-prev-interviews {
justify-content: center;
align-items: flex-start;
flex-direction: column;
- width: 20%;
+ width: 15%;
+}
+
+.cell-action {
+ justify-content: center;
+ align-items: flex-end;
+ flex-direction: column;
+ width: 25%;
}
.actions {
display: flex;
- margin-top: 16px;
-
+ flex-direction: column;
+ align-items: stretch;
> * + * {
- margin-left: 10px;
+ margin-top: 10px;
+ justify-content: center;
}
}
diff --git a/src/routes/PositionDetails/components/PrevInterviewItem/index.jsx b/src/routes/PositionDetails/components/PrevInterviewItem/index.jsx
new file mode 100644
index 00000000..727d56a0
--- /dev/null
+++ b/src/routes/PositionDetails/components/PrevInterviewItem/index.jsx
@@ -0,0 +1,36 @@
+/**
+ * PrevInterviewItem
+ *
+ * A list item for the PreviousInterviewsPopup
+ */
+import React from "react";
+import PT from "prop-types";
+import { formatDate } from "utils/format";
+import Accordion from "components/Accordion";
+import "./styles.module.scss";
+
+function PrevInterviewItem(props) {
+ const { date, round, emails } = props;
+
+ return (
+
+
+ {emails.map((email) => (
+ {email}
+ ))}
+
+
+ );
+}
+
+PrevInterviewItem.propTypes = {
+ date: PT.string.isRequired,
+ round: PT.number.isRequired,
+ emails: PT.arrayOf(PT.string).isRequired,
+};
+
+export default PrevInterviewItem;
diff --git a/src/routes/PositionDetails/components/PrevInterviewItem/styles.module.scss b/src/routes/PositionDetails/components/PrevInterviewItem/styles.module.scss
new file mode 100644
index 00000000..fc9df5f8
--- /dev/null
+++ b/src/routes/PositionDetails/components/PrevInterviewItem/styles.module.scss
@@ -0,0 +1,3 @@
+.email {
+ margin-bottom: 8px;
+}
\ No newline at end of file
diff --git a/src/routes/PositionDetails/components/PreviousInterviewsPopup/index.jsx b/src/routes/PositionDetails/components/PreviousInterviewsPopup/index.jsx
new file mode 100644
index 00000000..a7e29271
--- /dev/null
+++ b/src/routes/PositionDetails/components/PreviousInterviewsPopup/index.jsx
@@ -0,0 +1,60 @@
+/**
+ * PreviousInterviewsPopup
+ *
+ * Popup listing a user's previous interviews
+ */
+import React from "react";
+import PT from "prop-types";
+import SimpleModal from "components/SimpleModal";
+import User from "components/User";
+import "./styles.module.scss";
+import PrevInterviewItem from "../PrevInterviewItem";
+
+function PreviousInterviewsPopup(props) {
+ const { candidate, open, onClose } = props;
+
+ // sorts interviews and returns list of PrevInterviewItems
+ const showPrevInterviews = (interviews) => {
+ const sortedInterviews = interviews
+ .slice()
+ .sort((a, b) => a.round - b.round);
+
+ return sortedInterviews.map((interview) => (
+
+ ));
+ };
+
+ return (
+
+ {candidate === null ? (
+ ""
+ ) : (
+ <>
+
+
+
+ {showPrevInterviews(candidate.interviews)}
+ >
+ )}
+
+ );
+}
+
+PreviousInterviewsPopup.propTypes = {
+ candidate: PT.object,
+ open: PT.bool,
+ onClose: PT.func,
+};
+
+export default PreviousInterviewsPopup;
diff --git a/src/routes/PositionDetails/components/PreviousInterviewsPopup/styles.module.scss b/src/routes/PositionDetails/components/PreviousInterviewsPopup/styles.module.scss
new file mode 100644
index 00000000..915099c3
--- /dev/null
+++ b/src/routes/PositionDetails/components/PreviousInterviewsPopup/styles.module.scss
@@ -0,0 +1,6 @@
+.user {
+ font-size: 14px;
+ color: #0D61BF;
+ padding-bottom: 25px;
+ border-bottom: 1px solid #E9E9E9;
+}
\ No newline at end of file
diff --git a/src/routes/PositionDetails/components/SelectCandidatePopup/index.jsx b/src/routes/PositionDetails/components/SelectCandidatePopup/index.jsx
new file mode 100644
index 00000000..3ccae975
--- /dev/null
+++ b/src/routes/PositionDetails/components/SelectCandidatePopup/index.jsx
@@ -0,0 +1,90 @@
+/**
+ * SelectCandidatePopup
+ *
+ * Confirmation popup on selecting or rejecting a candidate
+ */
+import React, { useCallback, useState } from "react";
+import PT from "prop-types";
+import BaseModal from "components/BaseModal";
+import Button from "components/Button";
+import User from "components/User";
+import "./styles.module.scss";
+import CenteredSpinner from "components/CenteredSpinner";
+
+const SelectCandidatePopup = ({
+ candidate,
+ isReject,
+ open,
+ closeModal,
+ reject,
+ shortList,
+}) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const confirmSelection = useCallback(async () => {
+ setIsLoading(true);
+ if (isReject) {
+ await reject(candidate.id);
+ } else {
+ await shortList(candidate.id);
+ }
+ setIsLoading(false);
+ }, [isReject, candidate, reject, shortList]);
+
+ return (
+
+ Confirm
+
+ }
+ disabled={isLoading}
+ >
+ {candidate === null ? (
+ ""
+ ) : (
+ <>
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {isReject
+ ? "Are you sure you want to decline the selected candidate?"
+ : "Please confirm your selection of the above candidate"}
+
+ )}
+ >
+ )}
+
+ );
+};
+
+SelectCandidatePopup.propTypes = {
+ candidate: PT.object,
+ open: PT.bool,
+ isReject: PT.bool,
+ shortList: PT.func,
+ reject: PT.func,
+ closeModal: PT.func,
+};
+
+export default SelectCandidatePopup;
diff --git a/src/routes/PositionDetails/components/SelectCandidatePopup/styles.module.scss b/src/routes/PositionDetails/components/SelectCandidatePopup/styles.module.scss
new file mode 100644
index 00000000..af224c4b
--- /dev/null
+++ b/src/routes/PositionDetails/components/SelectCandidatePopup/styles.module.scss
@@ -0,0 +1,7 @@
+.user {
+ font-size: 14px;
+ color: #0D61BF;
+ padding-bottom: 25px;
+ border-bottom: 1px solid #E9E9E9;
+ margin-bottom: 25px;
+}
\ No newline at end of file
diff --git a/src/routes/PositionDetails/reducers/index.js b/src/routes/PositionDetails/reducers/index.js
index 4b01961c..16289269 100644
--- a/src/routes/PositionDetails/reducers/index.js
+++ b/src/routes/PositionDetails/reducers/index.js
@@ -42,6 +42,39 @@ const patchCandidateInState = (state, candidateId, partialCandidateData) => {
});
};
+/**
+ * Patch candidate with new interview without mutating state
+ *
+ * @param {object} state state
+ * @param {string} candidateId candidate id
+ * @param {object} interviewData interview object to add to candidate
+ * @returns {object} new state
+ */
+const patchInterviewInState = (state, candidateId, interviewData) => {
+ const candidateIndex = _.findIndex(_.get(state, "position.candidates"), {
+ id: candidateId,
+ });
+
+ if (candidateIndex === -1) {
+ return state;
+ }
+
+ const updatedCandidate = update(state.position.candidates[candidateIndex], {
+ status: { $set: "interview" },
+ interviews: { $push: [interviewData] },
+ });
+
+ return update(state, {
+ loading: { $set: false },
+ error: { $set: undefined },
+ position: {
+ candidates: {
+ $splice: [[candidateIndex, 1, updatedCandidate]],
+ },
+ },
+ });
+};
+
const reducer = (state = initialState, action) => {
switch (action.type) {
case ACTION_TYPE.RESET_POSITION_STATE:
@@ -87,6 +120,24 @@ const reducer = (state = initialState, action) => {
error: action.payload,
});
+ case ACTION_TYPE.ADD_INTERVIEW_PENDING:
+ return {
+ ...state,
+ loading: true,
+ error: undefined,
+ };
+
+ case ACTION_TYPE.ADD_INTERVIEW_SUCCESS:
+ return patchInterviewInState(state, action.meta.candidateId, {
+ ...action.payload,
+ });
+
+ case ACTION_TYPE.ADD_INTERVIEW_ERROR:
+ return {
+ ...state,
+ loading: false,
+ error: action.payload,
+ };
default:
return state;
}
diff --git a/src/services/teams.js b/src/services/teams.js
index b8053cb6..97133c8f 100644
--- a/src/services/teams.js
+++ b/src/services/teams.js
@@ -3,6 +3,7 @@
*/
import { axiosInstance as axios } from "./requestInterceptor";
import config from "../../config";
+import { generateInterview } from "utils/helpers";
/**
* Get my teams.
@@ -59,6 +60,44 @@ export const patchPositionCandidate = (candidateId, partialCandidateData) => {
);
};
+/**
+ * Patch New Candidate Interview
+ *
+ * @param {string} candidateId interview candidate id
+ * @param {object} interviewData emails and interview length
+ * @returns {Promise} interview object
+ */
+export const patchCandidateInterview = (candidateId, interviewData) => {
+ // endpoint not currently implemented so response is mocked
+ /* return axios.patch(
+ `${config.API.V5}/jobCandidates/${candidateId}/requestInterview`,
+ interviewData
+ ); */
+
+ const { attendeesList, xaiTemplate, createdBy, round } = interviewData;
+
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve({
+ data: generateInterview({
+ attendeesList,
+ xaiTemplate,
+ jobCandidates: candidateId,
+ updatedBy: "",
+ updatedAt: "",
+ startTimestamp: new Date(
+ Date.now() + 1000 * 60 * 60 * 24 * 3
+ ).toString(), // returns the timestamp 3 days from now
+ createdAt: Date(),
+ createdBy,
+ status: "Scheduling",
+ round,
+ }),
+ });
+ }, 2000);
+ });
+};
+
/**
* Get Team Members
*
diff --git a/src/utils/helpers.js b/src/utils/helpers.js
index 53f24cd2..6c498471 100644
--- a/src/utils/helpers.js
+++ b/src/utils/helpers.js
@@ -5,6 +5,8 @@
* If there are multiple methods which could be grouped into a separate file by their meaning they should be extracted from here to not make this file too big.
*/
import _ from "lodash";
+import faker from "faker";
+import { CANDIDATE_STATUS } from "constants";
/**
* Delay code for some milliseconds using promise.
@@ -38,3 +40,67 @@ export const getSelectOptionByValue = (value, selectOptions) => {
return option;
};
+
+/**
+ * Generates a pseudorandom integer between two numbers (inclusive)
+ *
+ * @param {number} lowNum an integer to use as minimun
+ * @param {number} highNum an integer to use as maximum
+ * @returns {number} a psuedorandom number between low and high
+ */
+export const rollDice = (lowNum, highNum) => {
+ const diffPlusOne = highNum - lowNum + 1;
+ return Math.floor(Math.random() * diffPlusOne + lowNum);
+};
+
+/**
+ * Generates a fake interview object
+ *
+ * @param {object} data an optional data object to configure fake interview
+ * @returns {object} a fake interview object
+ */
+export const generateInterview = (data) => ({
+ id: data.id || faker.datatype.uuid(),
+ googleCalendarId: data.googleCalendarId || "",
+ attendeesList: data.attendeesList || [],
+ startTimestamp: data.startTimestamp || faker.date.recent(),
+ custommessage: data.custommessage || "",
+ xaiTemplate: data.xaiTemplate || "30-min-interview",
+ jobCandidates: data.jobCandidates || faker.datatype.uuid(),
+ round: data.round || 1,
+ status: data.status || "Completed",
+ createdBy: data.createdBy || faker.datatype.uuid(),
+ updatedBy: data.updatedBy || faker.datatype.uuid(),
+ createdAt: data.createdAt || faker.date.past(),
+ updatedAt: data.updatedAt || faker.date.past(),
+});
+
+/**
+ * Gets fake interviews to attach to jobCandidates
+ *
+ * @param {object} candidate a jobCandidate object
+ * @returns {object[]} an array of fake interviews
+ */
+export const getFakeInterviews = (candidate) => {
+ // If candidate still to review return empty array
+ if (candidate.status === CANDIDATE_STATUS.OPEN) {
+ return [];
+ }
+
+ // decide how many interviews to return
+ const numInterviews = rollDice(0, 3);
+
+ const interviews = [];
+ for (let i = 0; i < numInterviews; i++) {
+ const numEmails = rollDice(2, 6);
+ const emails = _.times(numEmails, faker.internet.exampleEmail);
+
+ const interview = generateInterview({
+ attendeesList: emails,
+ jobCandidates: candidate.id,
+ round: i + 1,
+ });
+ interviews.push(interview);
+ }
+ return interviews;
+};