diff --git a/APIClients/InterviewGroupAPIClient.ts b/APIClients/InterviewGroupAPIClient.ts new file mode 100644 index 00000000..e40f2226 --- /dev/null +++ b/APIClients/InterviewGroupAPIClient.ts @@ -0,0 +1,71 @@ +import { fetchGraphql } from "@utils/makegqlrequest"; +import { queries, mutations } from "graphql/queries"; + +const InterviewGroupStatus = { + READY_TO_INTERVIEW: "Ready to Interview", + INVITES_SENT: "Invites Sent", + AVAILABILITY_PENDING: "Availability Pending", +} as const; + +type InterviewGroup = { + schedulingLink: string | null; +}; + +type Applicant = { + firstName: string; + lastName: string; +}; + +type Interviewer = { + id: string; + firstName: string; + lastName: string; + email: string; + profilePictureFileId: string | null; +}; + +const InterviewGroupAPIClient = { + updateSchedulingLink: async ( + id: string, + schedulingLink: string, + ): Promise => { + return fetchGraphql(mutations.updateInterviewGroup, { + id, + status: InterviewGroupStatus.READY_TO_INTERVIEW, + schedulingLink, + }) + .then((result) => result.data.updateInterviewGroup.schedulingLink) + .catch((e: Error) => { + throw new Error( + `Failed to update scheduling link. Cause: ${e.message}`, + ); + }); + }, + getInterviewGroup: async (id: string): Promise => { + return fetchGraphql(queries.getInterviewGroup, { id }) + .then((result) => result.data.getInterviewGroup) + .catch((e: Error) => { + throw new Error(`Failed to fetch interview group. Cause: ${e.message}`); + }); + }, + getInterviewersByGroupId: async (groupId: string): Promise => { + return fetchGraphql(queries.getInterviewersByGroupId, { groupId }) + .then((result) => result.data.getInterviewersByGroupId) + .catch((e: Error) => { + throw new Error(`Failed to fetch interviewers. Cause: ${e.message}`); + }); + }, + getInterviewedApplicantsByGroupId: async ( + groupId: string, + ): Promise => { + return fetchGraphql(queries.getInterviewedApplicantsByGroupId, { groupId }) + .then((result) => result.data.getInterviewedApplicantsByGroupId) + .catch((e: Error) => { + throw new Error( + `Failed to fetch interviewed applicants. Cause: ${e.message}`, + ); + }); + }, +}; + +export default InterviewGroupAPIClient; diff --git a/components/common/SplitPageLayout.tsx b/components/common/SplitPageLayout.tsx index f0e7d5a3..dd0b5902 100644 --- a/components/common/SplitPageLayout.tsx +++ b/components/common/SplitPageLayout.tsx @@ -29,8 +29,8 @@ export const SplitPanelLayout = ({ const hasWidthOverride = leftWidth || rightWidth; const gridStyle = hasWidthOverride ? { - gridTemplateColumns: `${leftWidth ? `${leftWidth}px` : "1fr"} ${ - rightWidth ? `${rightWidth}px` : "1fr" + gridTemplateColumns: `${leftWidth ? `${leftWidth}fr` : "1fr"} ${ + rightWidth ? `${rightWidth}fr` : "1fr" }`, } : undefined; diff --git a/components/icons/edit.icon.tsx b/components/icons/edit.icon.tsx new file mode 100644 index 00000000..0e0f3b59 --- /dev/null +++ b/components/icons/edit.icon.tsx @@ -0,0 +1,17 @@ +export const EditIcon = () => ( + + + +); diff --git a/graphql/queries.ts b/graphql/queries.ts index 06067ebe..88b49c85 100644 --- a/graphql/queries.ts +++ b/graphql/queries.ts @@ -23,6 +23,13 @@ export const mutations = { refresh(refreshToken: $refreshToken) } `, + updateInterviewGroup: ` + mutation UpdateInterviewGroup($id: ID!, $status: String!, $schedulingLink: String!) { + updateInterviewGroup(id: $id, status: $status, schedulingLink: $schedulingLink) { + schedulingLink + } + } + `, changeRating: ` mutation changeRating($id: Int!, $ratingToBeChanged: String!, $newValue: Int!) { changeRating(id: $id, ratingToBeChanged: $ratingToBeChanged, newValue: $newValue) { @@ -47,4 +54,30 @@ export const queries = { isAuthorizedByRole(accessToken: $accessToken, roles: $roles) } `, + getInterviewGroup: ` + query GetInterviewGroup($id: ID!) { + getInterviewGroup(id: $id) { + schedulingLink + } + } + `, + getInterviewersByGroupId: ` + query GetInterviewersByGroupId($groupId: ID!) { + getInterviewersByGroupId(groupId: $groupId) { + id + firstName + lastName + email + profilePictureFileId + } + } + `, + getInterviewedApplicantsByGroupId: ` + query GetInterviewedApplicantsByGroupId($groupId: ID!) { + getInterviewedApplicantsByGroupId(groupId: $groupId) { + firstName + lastName + } + } + `, }; diff --git a/pages/interview-groups/index.tsx b/pages/interview-groups/index.tsx new file mode 100644 index 00000000..6943663c --- /dev/null +++ b/pages/interview-groups/index.tsx @@ -0,0 +1,354 @@ +import { ReactElement, useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import Link from "next/link"; +import InterviewGroupAPIClient from "APIClients/InterviewGroupAPIClient"; +import { + SplitPanelLayout, + PanelLayout, +} from "@components/common/SplitPageLayout"; +import ProtectedRoute from "@components/context/ProtectedRoute"; +import RecruitmentPlatformThemeProvider from "@components/recruitmentPlatformCommon/RecruitmentPlatformThemeProvider"; +import { LinkIcon } from "@components/icons/link.icon"; +import { EditIcon } from "@components/icons/edit.icon"; +import { LongLeftIcon } from "@components/icons/long-left.icon"; +import { InterviewHeader } from "@components/interview/layout"; +import { useAuthenticatedUser } from "@components/context/AuthUserContext"; +import { NextPageWithLayout } from "../_app"; + +const CalendlyLinkSubmitted = ({ + linkInput, + onLinkChange, + isEditing, + onEdit, + onResubmit, +}: { + linkInput: string; + onLinkChange: (value: string) => void; + isEditing: boolean; + onEdit: () => void; + onResubmit: () => void; +}) => ( +
+
+ + + +
+
+
+
+

+ Link submitted! +

+

+ If you would like to re-submit your Calendly link press the edit + icon below. +

+
+ {isEditing ? ( + onLinkChange(e.target.value)} + className="w-full border border-[#7D7D7D] rounded-[5px] py-[10px] px-5 text-sm text-charcoal-400 font-normal leading-[1.43] outline-none focus:border-blue" + /> + ) : ( +
+ + {linkInput} + + +
+ )} +
+
+ +
+
+
+); + +const CalendlyLinkForm = ({ + linkInput, + onLinkChange, + onSubmit, +}: { + linkInput: string; + onLinkChange: (value: string) => void; + onSubmit: () => void; +}) => ( +
+
+
+

+ Paste Calendly link below +

+

+ Paste your completed Calendly link here below. This link will be used + by Admins to send out to your interviewees. +

+
+ onLinkChange(e.target.value)} + className="w-full border border-[#7D7D7D] rounded-[5px] py-[10px] px-5 text-sm text-charcoal-400 font-normal leading-[1.43] outline-none focus:border-blue" + /> +
+
+ +
+
+); + +const InterviewGroupContent = () => { + const router = useRouter(); + const currentUser = useAuthenticatedUser(); + const [applicants, setApplicants] = useState< + { firstName: string; lastName: string }[] + >([]); + const [partner, setPartner] = useState<{ + firstName: string; + lastName: string; + email: string; + profilePictureFileId: string | null; + } | null>(null); + const [linkInput, setLinkInput] = useState(""); + const [isSubmitted, setIsSubmitted] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + const interviewGroupId = + router.isReady && typeof router.query.interviewGroupId === "string" + ? router.query.interviewGroupId + : null; + + // TODO: handle error state (e.g. show inline error message if a fetch fails) + // // once the codebase has an error handler + useEffect(() => { + if (!interviewGroupId) return; + InterviewGroupAPIClient.getInterviewGroup(interviewGroupId).then( + (group) => { + const link = group.schedulingLink ?? ""; + setLinkInput(link); + setIsSubmitted(!!link); + }, + ); + InterviewGroupAPIClient.getInterviewedApplicantsByGroupId( + interviewGroupId, + ).then(setApplicants); + InterviewGroupAPIClient.getInterviewersByGroupId(interviewGroupId).then( + (interviewers) => { + // TODO: once error handling is added (see above TODO), handle the case + // where currentUser is not found in interviewers (i.e. unauthorized access to this group) + const found_partner = + interviewers.find((i) => i.id !== currentUser?.id) ?? null; + setPartner(found_partner); + }, + ); + }, [interviewGroupId, currentUser?.id]); + + if (!router.isReady) return null; + + const applicantNames = applicants + .map((a) => `${a.firstName} ${a.lastName}`) + .join(", "); + + return ( + +
+ {/* Header: back button, title, subtitle */} +
+ + + + Back to home + + +

+ Interview Pairing +

+

+ Review & coordinate your interviews with your partner +

+
+ {/* Content sections */} +
+ {/* Section 1: Interview Partner */} +
+
+

+ 1. Your interview partner: +

+

+ Contact your interview partner, email is provided below. +

+
+
+
+ {/* TODO: render profile picture using profilePictureFileId once a file URL resolver endpoint is available */} +
+
+ + {partner ? `${partner.firstName} ${partner.lastName}` : "—"} + + + Interviewing: {applicantNames} + +
+
+ + {partner?.email ?? "—"} + +
+
+ + {/* Section 2: Calendly Coordination */} +
+
+

+ 2. Coordinate schedule availability +

+

+ Coordinate availabilities with your partner on Calendly to set + up schedule times. +

+
+ + Open Calendly + + +
+ + {/* Section 3: Paste Calendly Link */} + {isSubmitted ? ( + setIsEditing(true)} + onResubmit={() => { + if (!interviewGroupId) return; + InterviewGroupAPIClient.updateSchedulingLink( + interviewGroupId, + linkInput, + ).then((link) => { + if (link) { + setLinkInput(link); + setIsEditing(false); + } + }); + }} + /> + ) : ( + { + if (!interviewGroupId) return; + InterviewGroupAPIClient.updateSchedulingLink( + interviewGroupId, + linkInput, + ).then((link) => { + if (link) { + setLinkInput(link); + setIsSubmitted(true); + } + }); + }} + /> + )} +
+
+ + ); +}; + +const InterviewGroupPage: NextPageWithLayout = () => { + return ; +}; + +InterviewGroupPage.getLayout = (page: ReactElement) => ( + + + } + > +
+
+ blueprint +

+ Application Review +

+ +
+
+ {page} +
+
+
+); + +export default InterviewGroupPage;