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 ( +
+ + {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 ( + + ); +} + +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}
+
+ +
+
+); + +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 : ( +
+ {({ + handleSubmit, + form: { + mutators: { push }, + reset, + }, + submitting, + hasValidationErrors, + }) => ( + { + reset(); + onClose(); + }} + title="Schedule an Interview" + button={ + + } + disabled={submitting} + > +
+
+
+ {candidate === null ? ( + "" + ) : ( + + )} +
+ +
+
+

Attendees:

+

+ Please provide email addresses for all parties you would like + involved with the interview. +

+ + + + + {({ fields }) => { + return fields.map((name, index) => ( +
+
+ +
+ fields.remove(index)} + styleName="remove-item" + > + × + +
+ )); + }} +
+
+
+

+ Selecting “Begin Scheduling” will initiate emails to all + attendees to coordinate availabiltiy. Please check your email to + input your availability. +

+
+
+
+ )} +
+ ); +} + +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) => ( -
-
- -
-
- - {candidate.resume && ( - - - Download Resume - - )} -
-
- {statusFilterKey === CANDIDATE_STATUS_FILTER_KEY.TO_REVIEW && hasPermission(PERMISSIONS.UPDATE_JOB_CANDIDATE) && ( - <> - Interested in this candidate? -
- - -
- + {filteredCandidates.length === 0 && ( +
No {statusFilter.title}
+ )} + {filteredCandidates.length > 0 && ( +
+ {pageCandidates.map((candidate) => ( +
+
+ +
+
+ + {candidate.resume && ( + + + Download Resume + + )} +
+ {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) && ( +
+ + {candidate.interviews.length > 0 && ( + + )} +
+ )} +
-
- ))} -
- )} - -
- - {filteredCandidates.length > 0 && ( - + ))} +
)} + +
+ + {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; +};