From f482e4ec4a18af7e43782b3154ea7f6bdf264c13 Mon Sep 17 00:00:00 2001 From: Artsiom Aliakseyenka Date: Sun, 27 Aug 2023 12:07:02 +0200 Subject: [PATCH] feat(interview): add new feedback page (#2273) * feat(interview): add new feedback page --- .../data/interviews/technical-screening.tsx | 6 +- client/src/domain/interview.test.ts | 27 +- client/src/domain/interview.ts | 22 +- .../InterviewFeedback/getServerSideProps.ts | 140 ++++++++-- .../pages/InterviewFeedback/index.tsx | 4 +- .../pages/feedback/CustomQuestion.tsx | 44 ++++ .../Interviews/pages/feedback/Feedback.tsx | 62 +++++ .../Interviews/pages/feedback/FormItem.tsx | 84 ++++++ .../Interviews/pages/feedback/NestedRadio.tsx | 53 ++++ .../pages/feedback/QuestionCard.tsx | 29 +++ .../pages/feedback/QuestionList.tsx | 108 ++++++++ .../pages/feedback/QuestionsPicker.tsx | 46 ++++ .../Interviews/pages/feedback/StepContext.tsx | 128 ++++++++++ .../Interviews/pages/feedback/StepForm.tsx | 69 +++++ .../Interviews/pages/feedback/Steps.tsx | 28 ++ .../pages/feedback/StepsContent.tsx | 23 ++ .../Interviews/pages/feedback/StudentInfo.tsx | 70 +++++ .../Interviews/pages/feedback/SubHeader.tsx | 30 +++ .../feedback/feedbackTemplateHandler.test.ts | 239 ++++++++++++++++++ .../pages/feedback/feedbackTemplateHandler.ts | 238 +++++++++++++++++ .../Interviews/pages/feedback/index.ts | 1 + .../course/interview/[type]/feedback.tsx | 4 +- 22 files changed, 1430 insertions(+), 25 deletions(-) create mode 100644 client/src/modules/Interviews/pages/feedback/CustomQuestion.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/Feedback.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/FormItem.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/NestedRadio.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/QuestionCard.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/QuestionList.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/QuestionsPicker.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/StepContext.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/StepForm.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/Steps.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/StepsContent.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/StudentInfo.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/SubHeader.tsx create mode 100644 client/src/modules/Interviews/pages/feedback/feedbackTemplateHandler.test.ts create mode 100644 client/src/modules/Interviews/pages/feedback/feedbackTemplateHandler.ts create mode 100644 client/src/modules/Interviews/pages/feedback/index.ts diff --git a/client/src/data/interviews/technical-screening.tsx b/client/src/data/interviews/technical-screening.tsx index d43ddc8cc4..4b8323e3fb 100644 --- a/client/src/data/interviews/technical-screening.tsx +++ b/client/src/data/interviews/technical-screening.tsx @@ -60,7 +60,6 @@ export type InterviewFeedbackValues = Record { beforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2023-01-01'))); @@ -38,4 +38,29 @@ describe('interview', () => { expect(isStarted).toBe(true); }); }); + + describe('getRating', () => { + test.each([ + [5, 0.5], + [30, 3], + [50, 5], + [100, 5], + ])(`should calculate %s rating based on score %s for legacy feedback`, (score, expected) => { + const rating = getRating(score, 100, 0); + + expect(rating).toBe(expected); + }); + + test.each([ + [5, 0.25], + [30, 1.5], + [50, 2.5], + [90, 4.5], + [100, 5], + ])(`should calculate %s rating based on score %s`, (score, expected) => { + const rating = getRating(score, 100, 1); + + expect(rating).toBe(expected); + }); + }); }); diff --git a/client/src/domain/interview.ts b/client/src/domain/interview.ts index 2221e8369d..f0caa565d8 100644 --- a/client/src/domain/interview.ts +++ b/client/src/domain/interview.ts @@ -1,6 +1,7 @@ import { StageInterviewFeedbackVerdict, InterviewDetails as CommonInterviewDetails } from 'common/models'; import dayjs from 'dayjs'; import between from 'dayjs/plugin/isBetween'; +import { featureToggles } from 'services/features'; dayjs.extend(between); export function friendlyStageInterviewVerdict(value: StageInterviewFeedbackVerdict) { @@ -53,11 +54,12 @@ export function getInterviewFeedbackUrl({ interviewName: string; interviewId: number; }) { - if (!isTechnicalScreening(interviewName)) { - return `/course/interview/${template}/feedback?course=${courseAlias}&githubId=${studentGithubId}&studentId=${studentId}&interviewId=${interviewId}`; + const isScreening = isTechnicalScreening(interviewName); + if (!featureToggles.feedback && isScreening) { + return `/course/mentor/interview-technical-screening?course=${courseAlias}&githubId=${studentGithubId}`; } - - return `/course/mentor/interview-technical-screening?course=${courseAlias}&githubId=${studentGithubId}`; + const type = isScreening ? stageInterviewType : template; + return `/course/interview/${type}/feedback?course=${courseAlias}&githubId=${studentGithubId}&studentId=${studentId}&interviewId=${interviewId}`; } export function isTechnicalScreening(name: string) { @@ -66,3 +68,15 @@ export function isTechnicalScreening(name: string) { export const getInterviewWaitList = (courseAlias: string, interviewId: number) => `/course/mentor/interview-wait-list?course=${courseAlias}&interviewId=${interviewId}`; + +/** calculates the rating based on the interview score. rating scales from [0,5] */ +export function getRating(score: number, maxScore: number, feedbackVersion: number) { + if (!feedbackVersion) { + // In the legacy feedback, the score is a number with limit 50 + const maxScore = 50; + return (score > maxScore ? maxScore : score) / 10; + } + // calculate rating on the scale from 0 to 5 + const rating = (score / maxScore) * 5; + return rating; +} diff --git a/client/src/modules/Interviews/pages/InterviewFeedback/getServerSideProps.ts b/client/src/modules/Interviews/pages/InterviewFeedback/getServerSideProps.ts index d02beaf76a..1d7f36194e 100644 --- a/client/src/modules/Interviews/pages/InterviewFeedback/getServerSideProps.ts +++ b/client/src/modules/Interviews/pages/InterviewFeedback/getServerSideProps.ts @@ -1,42 +1,152 @@ -import { CoursesInterviewsApi } from 'api'; +import { + CoursesTasksApi, + CourseStatsApi, + StudentDto, + StudentsApi, + CoursesInterviewsApi, + InterviewFeedbackDto, +} from 'api'; import { templates } from 'data/interviews'; -import { notAuthorizedResponse, noAccessResponse } from 'modules/Course/data'; -import { GetServerSideProps } from 'next'; +import { getTasksTotalScore } from 'domain/course'; +import { stageInterviewType } from 'domain/interview'; +import { notAuthorizedResponse } from 'modules/Course/data'; +import { GetServerSideProps, GetServerSidePropsContext } from 'next'; +import { ParsedUrlQuery } from 'querystring'; import type { CourseOnlyPageProps } from 'services/models'; import { UserService } from 'services/user'; import { getApiConfiguration } from 'utils/axios'; import { getTokenFromContext } from 'utils/server'; -export type PageProps = CourseOnlyPageProps & { +export type StageFeedbackProps = CourseOnlyPageProps & { + interviewId: number; + student: StudentDto; + courseSummary: { + totalScore: number; + studentsCount: number; + }; + interviewFeedback: InterviewFeedbackDto; + type: typeof stageInterviewType; +}; + +export type FeedbackProps = CourseOnlyPageProps & { interviewTaskId: number; type: keyof typeof templates; githubId: string; }; +export type PageProps = FeedbackProps | StageFeedbackProps; + export const getServerSideProps: GetServerSideProps = async ctx => { try { const alias = ctx.query.course as string; - const type = ctx.params?.type as string; - const githubId = ctx.query.githubId as string; + const type = ctx.params?.type as PageProps['type']; + const token = getTokenFromContext(ctx); const courses = await new UserService(token).getCourses(); const course = courses.find(course => course.alias === alias) ?? null; - if (course == null) { + if (!course || !type) { return notAuthorizedResponse; } - const response = await new CoursesInterviewsApi(getApiConfiguration(token)).getInterviews(course.id, false); - const interview = - response.data.find(interview => (interview.attributes as { template?: string })?.template === type) ?? null; + const pageProps = await (type === stageInterviewType + ? getStageInterviewData({ ctx, courseId: course.id, token }) + : getInterviewData({ ctx, courseId: course.id, token })); + + const props: PageProps = { + ...pageProps, + course, + }; - if (interview == null) { - return notAuthorizedResponse; - } return { - props: { course, interviewTaskId: interview.id, type, githubId }, + props: props, }; } catch (e) { - return noAccessResponse; + return notAuthorizedResponse; } }; + +/** + * Gets stage interview data + */ +async function getStageInterviewData({ + ctx, + token, + courseId, +}: { + ctx: GetServerSidePropsContext; + token: string | undefined; + courseId: number; +}): Promise> { + validateQueryParams(ctx, ['studentId', 'interviewId']); + + const studentId = Number(ctx.query.studentId); + const interviewId = Number(ctx.query.interviewId); + + const axiosConfig = getApiConfiguration(token); + + const [ + { data: student }, + { data: tasks }, + { + data: { studentsActiveCount }, + }, + { data: interviewFeedback }, + ] = await Promise.all([ + new StudentsApi(axiosConfig).getStudent(Number(studentId)), + new CoursesTasksApi(axiosConfig).getCourseTasks(courseId), + new CourseStatsApi(axiosConfig).getCourseStats(courseId), + new CoursesInterviewsApi(axiosConfig).getInterviewFeedback(courseId, interviewId, stageInterviewType), + ]); + if (!student) { + throw new Error('Student not found'); + } + + return { + interviewId, + student, + courseSummary: { + totalScore: getTasksTotalScore(tasks), + studentsCount: studentsActiveCount, + }, + interviewFeedback, + type: stageInterviewType, + }; +} + +/** + * Gets regular interview data + */ +async function getInterviewData({ + ctx, + token, + courseId, +}: { + ctx: GetServerSidePropsContext; + token: string | undefined; + courseId: number; +}): Promise> { + const githubId = ctx.query.githubId as string; + const type = ctx.params?.type as FeedbackProps['type']; + const response = await new CoursesInterviewsApi(getApiConfiguration(token)).getInterviews(courseId, false); + const interview = + response.data.find(interview => (interview.attributes as { template?: string })?.template === type) ?? null; + + if (interview == null) { + throw new Error('Interview not found'); + } + + return { + interviewTaskId: interview.id, + type, + githubId, + }; +} + +function validateQueryParams(ctx: GetServerSidePropsContext, params: string[]) { + for (const param of params) { + if (!ctx.query[param]) { + throw new Error(`Parameter ${param} is not defined`); + } + } +} diff --git a/client/src/modules/Interviews/pages/InterviewFeedback/index.tsx b/client/src/modules/Interviews/pages/InterviewFeedback/index.tsx index c1fa2b634a..1571fad4e9 100644 --- a/client/src/modules/Interviews/pages/InterviewFeedback/index.tsx +++ b/client/src/modules/Interviews/pages/InterviewFeedback/index.tsx @@ -9,7 +9,7 @@ import toString from 'lodash/toString'; import { SessionContext } from 'modules/Course/contexts'; import { Fragment, useContext, useMemo, useState } from 'react'; import { CourseService } from 'services/course'; -import type { PageProps } from './getServerSideProps'; +import type { FeedbackProps } from './getServerSideProps'; type FormAnswer = { questionId: string; @@ -17,7 +17,7 @@ type FormAnswer = { answer: string; }; -export function InterviewFeedback({ course, type, interviewTaskId, githubId }: PageProps) { +export function InterviewFeedback({ course, type, interviewTaskId, githubId }: FeedbackProps) { const courseId = course.id; const session = useContext(SessionContext); diff --git a/client/src/modules/Interviews/pages/feedback/CustomQuestion.tsx b/client/src/modules/Interviews/pages/feedback/CustomQuestion.tsx new file mode 100644 index 0000000000..2f39c4517f --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/CustomQuestion.tsx @@ -0,0 +1,44 @@ +import { useRef } from 'react'; +import { Button, Card, Col, Input, InputRef, Row, Space } from 'antd'; + +type Props = { + cancel: () => void; + save(question: string): void; +}; + +export function CustomQuestion({ cancel, save }: Props) { + const addRef = useRef(null); + + function saveQuestion() { + const value = addRef.current?.input?.value.trim(); + if (!value) { + return; + } + + save(value); + } + + return ( + + { + // prevent form submit + e.preventDefault(); + saveQuestion(); + }} + /> + + + + + + + + + ); +} diff --git a/client/src/modules/Interviews/pages/feedback/Feedback.tsx b/client/src/modules/Interviews/pages/feedback/Feedback.tsx new file mode 100644 index 0000000000..f196910bb1 --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/Feedback.tsx @@ -0,0 +1,62 @@ +import { Divider, Layout } from 'antd'; +import dynamic from 'next/dynamic'; +import { Header } from 'components/Header'; +import { SessionContext } from 'modules/Course/contexts'; +import { useContext } from 'react'; +import { StageFeedbackProps } from '../InterviewFeedback/getServerSideProps'; + +import { Steps } from './Steps'; +import { StudentInfo } from './StudentInfo'; +import { SubHeader } from './SubHeader'; +import { StepContextProvider } from './StepContext'; +import { StepsContent } from './StepsContent'; +import { featureToggles } from 'services/features'; + +const LegacyTechScreening = dynamic(() => import('pages/course/mentor/interview-technical-screening'), { + loading: () =>

Loading...

, +}); + +export function Feedback(props: StageFeedbackProps) { + const session = useContext(SessionContext); + const { student, courseSummary, interviewFeedback, course, interviewId, type } = props; + + const shouldFallbackToLegacy = !featureToggles.feedback || interviewFeedback.version === 0; + + // if the feedback exists and doesn't have a version, it means it was created before the feedback feature was released + // fallback to previous form. Once we migrate old data to new format(Artsiom A.), we may remove this fallback + if (shouldFallbackToLegacy) { + return ; + } + + return ( + +
+ + + + + + + + + + + + + + + ); +} diff --git a/client/src/modules/Interviews/pages/feedback/FormItem.tsx b/client/src/modules/Interviews/pages/feedback/FormItem.tsx new file mode 100644 index 0000000000..d42881e2e3 --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/FormItem.tsx @@ -0,0 +1,84 @@ +import { Form, FormInstance, Input, Radio, Space, Typography, Checkbox } from 'antd'; +import { StepFormItem, RadioOption, FeedbackStepId } from 'data/interviews/technical-screening'; +import { NestedRadio } from './NestedRadio'; +import { InputType } from 'data/interviews'; +import { Fragment } from 'react'; +import { QuestionList } from './QuestionList'; + +const { Item } = Form; +const { Group } = Radio; +const { Text } = Typography; + +export function FormItem({ item, form, stepId }: { item: StepFormItem; form: FormInstance; stepId: FeedbackStepId }) { + switch (item.type) { + case InputType.Radio: + return ( + + + + {item.options.map((option: RadioOption) => { + return ( + + + {option.title} + + + + ); + })} + + + + ); + case InputType.RadioButton: + return ( + + + {item.description && ( + +
{item.description}
+
+ )} + {item.options.map((option: RadioOption) => { + return ( + + {option.title} + + ); + })} +
+
+ ); + case InputType.Checkbox: + return ( + + ({ label: option.title, value: option.id }))} + /> + + ); + case InputType.Input: + return ( + + + + ); + case InputType.TextArea: + return ( + + + + ); + case InputType.Rating: { + return ; + } + default: + return null; + } +} diff --git a/client/src/modules/Interviews/pages/feedback/NestedRadio.tsx b/client/src/modules/Interviews/pages/feedback/NestedRadio.tsx new file mode 100644 index 0000000000..2b32178251 --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/NestedRadio.tsx @@ -0,0 +1,53 @@ +import { Radio, Space, FormInstance, Form } from 'antd'; +import { RadioOption } from 'data/interviews/technical-screening'; +import { useEffect } from 'react'; + +const { Item, useWatch } = Form; +const { Group } = Radio; + +/** + * handles dynamic display if parent is selected and automatic cleanup + */ +export function NestedRadio({ + form, + option, + parentId, +}: { + form: FormInstance; + option: RadioOption; + parentId: string; + stepId: string; +}) { + const parentValue = useWatch(parentId); + + useEffect(() => { + //reset current value in form, if parent value changes + if (parentValue && parentValue !== option.id) { + form.resetFields([option.id]); + } + }, [parentValue, option.id]); + + if (!option.options) { + return null; + } + + return ( + + {() => + form.getFieldValue(parentId) === option.id && ( + + + + {option.options?.map((subOption: any) => ( + + {subOption.title} + + ))} + + + + ) + } + + ); +} diff --git a/client/src/modules/Interviews/pages/feedback/QuestionCard.tsx b/client/src/modules/Interviews/pages/feedback/QuestionCard.tsx new file mode 100644 index 0000000000..4ac8c4c53d --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/QuestionCard.tsx @@ -0,0 +1,29 @@ +import { Card, Col, Form, Rate, Row } from 'antd'; +import { ReactNode } from 'react'; + +type Props = { + content: ReactNode; + fieldName: string[]; + required?: boolean; + tooltips?: string[]; +}; + +/** + * Question requiring a rate answer + */ +export function QuestionCard({ content, fieldName, required, tooltips }: Props) { + return ( + + + + {content} + + + + + + + + + ); +} diff --git a/client/src/modules/Interviews/pages/feedback/QuestionList.tsx b/client/src/modules/Interviews/pages/feedback/QuestionList.tsx new file mode 100644 index 0000000000..981d523412 --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/QuestionList.tsx @@ -0,0 +1,108 @@ +import { Form, Space, Button, Row, Col, Typography } from 'antd'; +import DeleteOutlined from '@ant-design/icons/DeleteOutlined'; +import PlusOutlined from '@ant-design/icons/PlusOutlined'; +import { QuestionCard } from './QuestionCard'; +import { QuestionsPicker } from './QuestionsPicker'; +import { useMemo, useState } from 'react'; +import { QuestionItem, InterviewQuestion, FeedbackStepId } from 'data/interviews/technical-screening'; +import { FormInstance } from 'antd/lib'; +import { CustomQuestion } from './CustomQuestion'; + +const { Text } = Typography; + +type Props = { + question: QuestionItem; + form: FormInstance; + stepId: FeedbackStepId; +}; + +export function QuestionList({ form, question, stepId }: Props) { + const { examples = [], id, required, tooltips } = question; + const [addModeActive, setAddModeActive] = useState(null); + + const formQuestions = Form.useWatch(question.id, { form }); + + // filter out already added questions + const pickerQuestions = useMemo(() => { + return examples.filter( + (question: InterviewQuestion) => formQuestions?.some(({ id }) => id === question.id) !== true, + ); + }, [formQuestions]); + + return ( + <> + + {(fields, { add, remove }) => ( + <> + {fields.map(({ name }) => { + const question: InterviewQuestion = form.getFieldValue(id)[name]; + + return ( + + + {question.topic && ( + + {question.topic} + + )} + {question.title} + + } + /> + {fields.length > 1 && ( + + remove(name)} /> + + )} + + ); + })} + + {addModeActive === 'prepared' && ( + onAddQuestion(questions, add)} + onCancel={() => setAddModeActive(null)} + /> + )} + + {addModeActive === 'custom' && ( + setAddModeActive(null)} save={question => onAddQuestion([question], add)} /> + )} + + + {pickerQuestions.length > 0 && ( + + )} + + + + )} + + + ); + + function onAddQuestion(toAdd: string[], add: (question: InterviewQuestion) => void) { + if (addModeActive === 'prepared') { + examples.filter(({ id }) => toAdd.includes(id)).forEach(question => add(question)); + } else { + toAdd.forEach(question => add({ id: generateId(), title: question })); + } + setAddModeActive(null); + } +} + +function generateId() { + const randomNum = Math.random(); + const id = randomNum.toString(36).substring(2, 11); // Generate a string of 9 characters + return id; +} diff --git a/client/src/modules/Interviews/pages/feedback/QuestionsPicker.tsx b/client/src/modules/Interviews/pages/feedback/QuestionsPicker.tsx new file mode 100644 index 0000000000..402587f4fe --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/QuestionsPicker.tsx @@ -0,0 +1,46 @@ +import { Checkbox, Form, Modal, Row } from 'antd'; +import { InterviewQuestion } from 'data/interviews/technical-screening'; + +const { Item } = Form; + +export function QuestionsPicker({ + questions, + onCancel, + onSave, +}: { + questions: InterviewQuestion[]; + onCancel: () => void; + onSave: (questions: string[]) => void; +}) { + const [form] = Form.useForm(); + + function onFinish(values: { questions: string[] }) { + onSave(values.questions); + } + + return ( + +
+ + + {questions.map(question => ( + + {question.title} + + ))} + + +
+
+ ); +} diff --git a/client/src/modules/Interviews/pages/feedback/StepContext.tsx b/client/src/modules/Interviews/pages/feedback/StepContext.tsx new file mode 100644 index 0000000000..50c909a8b0 --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/StepContext.tsx @@ -0,0 +1,128 @@ +import { CoursesInterviewsApi, InterviewFeedbackDto, ProfileCourseDto } from 'api'; +import { InterviewFeedbackValues, FeedbackStep, Feedback } from 'data/interviews/technical-screening'; +import { createContext, PropsWithChildren, useCallback, useMemo, useState } from 'react'; +import { useLoading } from 'components/useLoading'; +import { message } from 'antd'; +import { useRouter } from 'next/router'; +import { + getDefaultStep, + getFeedbackFromTemplate, + getUpdatedFeedback, + isInterviewRejected, +} from './feedbackTemplateHandler'; + +type ContextProps = { + course: ProfileCourseDto; + interviewId: number; + interviewFeedback: InterviewFeedbackDto; + type: string; + interviewMaxScore: number; +}; + +type StepApi = { + activeStepIndex: number; + steps: FeedbackStep[]; + next: (values: InterviewFeedbackValues) => void; + prev: () => void; + onValuesChange(_: InterviewFeedbackValues, values: InterviewFeedbackValues): void; + loading: boolean; + isFinalStep: boolean; +}; + +export const StepContext = createContext({} as StepApi); + +export function StepContextProvider(props: PropsWithChildren) { + const { interviewFeedback, children, course, interviewId, type, interviewMaxScore } = props; + const router = useRouter(); + const [loading, withLoading] = useLoading(false, error => { + message.error('An unexpected error occurred. Please try later.'); + throw error; + }); + + const [feedback, setFeedback] = useState(() => + getFeedbackFromTemplate(interviewFeedback, interviewMaxScore), + ); + const [activeStepIndex, setActiveIndex] = useState(() => getDefaultStep(feedback)); + const activeStep = feedback.steps[activeStepIndex]; + + const [isFinished, setIsFinished] = useState(() => isInterviewRejected(activeStep.id, activeStep.values)); + const isFinalStep = activeStepIndex === feedback.steps.length - 1 || isFinished; + + const saveFeedback = withLoading(async (values: InterviewFeedbackValues) => { + const { feedbackValues, steps, isCompleted, score, decision, isGoodCandidate } = getUpdatedFeedback({ + feedback, + newValues: values, + activeStepIndex, + interviewMaxScore, + }); + await new CoursesInterviewsApi().createInterviewFeedback(course.id, interviewId, type, { + isCompleted, + score, + decision, + isGoodCandidate, + json: feedbackValues, + version: feedback.version, + }); + + setFeedback({ + isCompleted, + steps, + version: feedback.version, + }); + }); + + const onValuesChange = useCallback( + (_: InterviewFeedbackValues, values: InterviewFeedbackValues) => { + setIsFinished(isInterviewRejected(activeStep.id, values)); + }, + [activeStep.id], + ); + + const next = useCallback( + async (values: InterviewFeedbackValues) => { + try { + await saveFeedback(values); + } catch { + return; + } + if (isFinalStep) { + router.push(`/course/mentor/interviews?course=${course.alias}`); + return; + } + + setActiveIndex(index => { + if (index === feedback.steps.length - 1) { + return index; + } + + return index + 1; + }); + }, + [feedback.steps, isFinalStep, activeStepIndex], + ); + + const prev = useCallback(() => { + setActiveIndex(index => { + if (index === 0) { + return index; + } + + return index - 1; + }); + }, []); + + const api = useMemo( + () => ({ + activeStepIndex, + steps: feedback.steps, + next, + prev, + onValuesChange, + loading, + isFinalStep, + }), + [activeStepIndex, feedback.steps, isFinalStep, loading, onValuesChange], + ); + + return {children}; +} diff --git a/client/src/modules/Interviews/pages/feedback/StepForm.tsx b/client/src/modules/Interviews/pages/feedback/StepForm.tsx new file mode 100644 index 0000000000..5b60deedb6 --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/StepForm.tsx @@ -0,0 +1,69 @@ +import { Form, Space, Typography, Button } from 'antd'; +import { FeedbackStep, InterviewFeedbackValues, StepFormItem } from 'data/interviews/technical-screening'; +import { FormItem } from './FormItem'; +import { InputType } from 'data/interviews'; + +const { Title, Text } = Typography; + +type Values = Record; + +type Props = { + step: FeedbackStep; + back: () => void; + next: (values: Values) => void; + onValuesChange: (changedValues: Values, values: Values) => void; + isLast: boolean; + isFirst: boolean; +}; + +export function StepForm({ step, next, back, isFirst, isLast, onValuesChange }: Props) { + const [form] = Form.useForm(); + + return ( +
form.scrollToField(errorField.name)} + > + + + {step.title} + {step.description} + + {step.items.map((item: StepFormItem) => ( +
+ {item.title} + +
+ ))} + + + {!isFirst ? :
} + {} + + + + ); +} + +function getInitialQuestions(step: FeedbackStep) { + const { items, values } = step; + + if (values) { + return values; + } + + // if values are not yet defined(ie feedback is not yet submitted), initialize dynamic questions with default structure + return items.reduce((acc: InterviewFeedbackValues, item) => { + if (item.type === InputType.Rating) { + acc[item.id] = item.questions; + } + if (item.type === InputType.Input && item.defaultValue) { + acc[item.id] = item.defaultValue; + } + return acc; + }, {}); +} diff --git a/client/src/modules/Interviews/pages/feedback/Steps.tsx b/client/src/modules/Interviews/pages/feedback/Steps.tsx new file mode 100644 index 0000000000..ad4ab61ba5 --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/Steps.tsx @@ -0,0 +1,28 @@ +import { Steps as Stepper } from 'antd'; +import { useContext } from 'react'; +import { StepContext } from './StepContext'; + +export function Steps() { + const { activeStepIndex, steps } = useContext(StepContext); + + return ( + ({ + title: step.title, + description: step.stepperDescription, + status: getStatus(index), + }))} + /> + ); + + function getStatus(index: number) { + if (index === activeStepIndex) { + return 'process'; + } + return steps[index].isCompleted ? 'finish' : 'wait'; + } +} diff --git a/client/src/modules/Interviews/pages/feedback/StepsContent.tsx b/client/src/modules/Interviews/pages/feedback/StepsContent.tsx new file mode 100644 index 0000000000..42d6109427 --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/StepsContent.tsx @@ -0,0 +1,23 @@ +import { useContext } from 'react'; +import { StepContext } from './StepContext'; +import { StepForm } from './StepForm'; +import { Spin } from 'antd'; + +export function StepsContent() { + const { activeStepIndex, steps, next, prev, onValuesChange, loading, isFinalStep } = useContext(StepContext); + const step = steps[activeStepIndex]; + + return ( + + + + ); +} diff --git a/client/src/modules/Interviews/pages/feedback/StudentInfo.tsx b/client/src/modules/Interviews/pages/feedback/StudentInfo.tsx new file mode 100644 index 0000000000..68545e2ce5 --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/StudentInfo.tsx @@ -0,0 +1,70 @@ +import { Col, Row, Typography } from 'antd'; +import GithubFilled from '@ant-design/icons/GithubFilled'; +import { GithubAvatar } from 'components/GithubAvatar'; +import { StudentDto } from 'api'; + +type Props = { + student: StudentDto; + courseSummary: { + totalScore: number; + studentsCount: number; + }; +}; + +const { Text } = Typography; + +export function StudentInfo(props: Props) { + const { student, courseSummary } = props; + const { githubId, name, rank, totalScore } = student; + const hasName = name && name !== '(Empty)'; + const location = [student.cityName, student.countryName].filter(Boolean).join(', '); + + return ( + + + + + + + {hasName && ( + + {name} + + )} + + + {githubId} + + + + + + + + Position + + + {`${rank}/${courseSummary.studentsCount}`} + + + + + Total Score + + + {' '} + {`${totalScore}/${courseSummary.totalScore}`} + + + + + Location + + + {location} + + + + + ); +} diff --git a/client/src/modules/Interviews/pages/feedback/SubHeader.tsx b/client/src/modules/Interviews/pages/feedback/SubHeader.tsx new file mode 100644 index 0000000000..f2d6948f4d --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/SubHeader.tsx @@ -0,0 +1,30 @@ +import { Col, Space, Tag, Typography } from 'antd'; +import ArrowLeftOutlined from '@ant-design/icons/ArrowLeftOutlined'; +import css from 'styled-jsx/css'; +import { useRouter } from 'next/router'; + +type Props = { + isCompleted: boolean; +}; + +export function SubHeader(props: Props) { + const { isCompleted } = props; + const router = useRouter(); + + return ( + + + + Feedback form + {isCompleted ? 'Completed' : 'Uncompleted'} + {containerStyles} + + ); +} + +const { className: containerClassName, styles: containerStyles } = css.resolve` + div { + border-bottom: 1px solid rgba(240, 242, 245, 1); + padding: 24px; + } +`; diff --git a/client/src/modules/Interviews/pages/feedback/feedbackTemplateHandler.test.ts b/client/src/modules/Interviews/pages/feedback/feedbackTemplateHandler.test.ts new file mode 100644 index 0000000000..226bd3d6ae --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/feedbackTemplateHandler.test.ts @@ -0,0 +1,239 @@ +import { + getFeedbackFromTemplate, + getDefaultStep, + isInterviewRejected, + getUpdatedFeedback, +} from './feedbackTemplateHandler'; +import { + Decision, + Feedback, + FeedbackStepId, + InterviewFeedbackValues, + feedbackTemplate, +} from 'data/interviews/technical-screening'; + +describe('getFeedbackFromTemplate', () => { + test('should return default template when no feedback exists', () => { + const interviewFeedback = { json: undefined, isCompleted: false, maxScore: 100 }; + + const feedback = getFeedbackFromTemplate(interviewFeedback, 100); + + expect(feedback.steps.length).toBe(feedbackTemplate.steps.length); + expect(feedback.isCompleted).toBe(false); + expect(feedback.version).toBe(feedbackTemplate.version); + }); + + test('should merge feedback data with template', () => { + const interviewFeedback = { + json: { + steps: { + [FeedbackStepId.Introduction]: { + isCompleted: true, + values: { interviewResult: 'completed' }, + }, + [FeedbackStepId.Theory]: { + isCompleted: true, + values: { questions: [{ value: 3 }] }, + }, + }, + }, + version: 1, + isCompleted: true, + maxScore: 100, + }; + + const feedback = getFeedbackFromTemplate(interviewFeedback, 100); + + expect(feedback.steps.length).toBe(feedbackTemplate.steps.length); + expect(feedback.isCompleted).toBe(true); + expect(feedback.version).toBe(1); + + expect(feedback.steps[0].isCompleted).toBe(true); + expect(feedback.steps[0].values?.interviewResult).toBe('completed'); + expect(feedback.steps[1].isCompleted).toBe(true); + expect(feedback.steps[1].values).toEqual({ questions: [{ value: 3 }], score: 30 }); + + expect(feedback.steps[2].isCompleted).toBe(undefined); + }); +}); + +describe('getDefaultStep', () => { + test('should return the first incomplete step', () => { + const feedback = { + steps: [ + { id: FeedbackStepId.Introduction, isCompleted: true }, + { id: FeedbackStepId.Theory, isCompleted: false }, + { id: FeedbackStepId.Practice, isCompleted: false }, + { id: FeedbackStepId.Decision, isCompleted: false }, + ], + version: 1, + isCompleted: false, + }; + const defaultStep = getDefaultStep(feedback as Feedback); + expect(defaultStep).toBe(1); + }); + + test('should return the final step if all steps are completed', () => { + const feedback = { + steps: [ + { id: FeedbackStepId.Introduction, isCompleted: true }, + { id: FeedbackStepId.Theory, isCompleted: true }, + { id: FeedbackStepId.Practice, isCompleted: true }, + { id: FeedbackStepId.Decision, isCompleted: true }, + ], + isCompleted: true, + }; + const defaultStep = getDefaultStep(feedback as Feedback); + expect(defaultStep).toBe(3); + }); + + test('should return intro step if interview is not conducted', () => { + const feedback = { + steps: [ + { id: FeedbackStepId.Introduction, isCompleted: true, values: { interviewResult: 'missed' } }, + { id: FeedbackStepId.Theory, isCompleted: false }, + { id: FeedbackStepId.Practice, isCompleted: false }, + { id: FeedbackStepId.Decision, isCompleted: false }, + ], + isCompleted: false, + }; + const defaultStep = getDefaultStep(feedback as Feedback); + expect(defaultStep).toBe(0); + }); +}); + +describe('isInterviewRejected', () => { + test('should return true if interview is rejected on the intro step', () => { + const stepValues = { interviewResult: 'missed' }; + const isRejected = isInterviewRejected(FeedbackStepId.Introduction, stepValues); + + expect(isRejected).toBe(true); + }); + + test('should return false if not rejected on intro step', () => { + const stepValues = { interviewResult: 'completed' }; + const isRejected = isInterviewRejected(FeedbackStepId.Introduction, stepValues); + + expect(isRejected).toBe(false); + }); + + test('should return false if not a intro step', () => { + const stepValues = {}; + const isRejected = isInterviewRejected(FeedbackStepId.Theory, stepValues); + + expect(isRejected).toBe(false); + }); +}); + +describe('getUpdatedFeedback', () => { + test('should mark active index as completed and return new feedback', () => { + const feedback = { + version: 1, + isCompleted: false, + steps: [ + { id: FeedbackStepId.Introduction, isCompleted: true, items: [], values: { interviewResult: 'completed' } }, + { id: FeedbackStepId.Theory, isCompleted: false, items: [] }, + { id: FeedbackStepId.Practice, isCompleted: false, items: [] }, + { id: FeedbackStepId.Decision, isCompleted: false, items: [] }, + ], + } as unknown as Feedback; + const newValues: InterviewFeedbackValues = { questions: [{ id: '1', title: 'test', value: 3 }] }; + const activeStepIndex = 1; + + const updatedFeedback = getUpdatedFeedback({ feedback, newValues, activeStepIndex, interviewMaxScore: 100 }); + + expect(updatedFeedback.steps[0].isCompleted).toBe(true); + expect(updatedFeedback.steps[1].isCompleted).toBe(true); + expect(updatedFeedback.steps[1].values?.questions).toEqual([{ id: '1', title: 'test', value: 3 }]); + expect(updatedFeedback.feedbackValues).toEqual({ + steps: { + decision: { + isCompleted: false, + values: undefined, + }, + intro: { + isCompleted: true, + values: { interviewResult: 'completed' }, + }, + practice: { + isCompleted: false, + values: undefined, + }, + theory: { + isCompleted: true, + values: { + questions: [ + { + id: '1', + title: 'test', + value: 3, + }, + ], + score: 30, + }, + }, + }, + }); + expect(updatedFeedback.steps[2].isCompleted).toBe(false); + expect(updatedFeedback.steps[2].values).toBeUndefined(); + expect(updatedFeedback.steps[3].isCompleted).toBe(false); + expect(updatedFeedback.steps[3].values).toBeUndefined(); + expect(updatedFeedback.isCompleted).toBe(false); + expect(updatedFeedback.score).toBeUndefined(); + expect(updatedFeedback.decision).toBeUndefined(); + expect(updatedFeedback.isGoodCandidate).toBeUndefined(); + }); + + test('should mark all steps as completed if interview is completed', () => { + const feedback = { + version: '1.0', + isCompleted: false, + steps: [ + { id: FeedbackStepId.Introduction, isCompleted: true, items: [], values: { interviewResult: 'completed' } }, + { id: FeedbackStepId.Theory, isCompleted: true, items: [], values: { questions: [{ value: 3 }] } }, + { id: FeedbackStepId.Practice, isCompleted: true, items: [], values: { questions: [{ value: 4 }] } }, + { id: FeedbackStepId.Decision, isCompleted: false, items: [] }, + ], + } as unknown as Feedback; + const newValues = { decision: Decision.Yes, isGoodCandidate: ['true'], finalScore: 8 }; + const activeStepIndex = 3; + const interviewMaxScore = 10; + const updatedFeedback = getUpdatedFeedback({ feedback, newValues, activeStepIndex, interviewMaxScore }); + + expect(updatedFeedback.steps.every(step => step.isCompleted)).toBe(true); + expect(updatedFeedback.decision).toBe(Decision.Yes); + expect(updatedFeedback.isGoodCandidate).toBeTruthy(); + expect(updatedFeedback.isCompleted).toBeTruthy(); + expect(updatedFeedback.score).toBe(8); + expect(updatedFeedback.feedbackValues).toEqual({ + steps: { + decision: { + isCompleted: true, + values: { + decision: Decision.Yes, + isGoodCandidate: ['true'], + finalScore: 8, + }, + }, + intro: { + isCompleted: true, + values: { interviewResult: 'completed' }, + }, + practice: { + isCompleted: true, + values: { + questions: [{ value: 4 }], + score: 4, + }, + }, + theory: { + isCompleted: true, + values: { + questions: [{ value: 3 }], + score: 3, + }, + }, + }, + }); + }); +}); diff --git a/client/src/modules/Interviews/pages/feedback/feedbackTemplateHandler.ts b/client/src/modules/Interviews/pages/feedback/feedbackTemplateHandler.ts new file mode 100644 index 0000000000..df1a9a92a2 --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/feedbackTemplateHandler.ts @@ -0,0 +1,238 @@ +import { InterviewFeedbackDto } from 'api'; +import { + Feedback, + FeedbackStep, + FeedbackStepId, + InterviewFeedbackStepData, + InterviewFeedbackValues, + InterviewQuestion, + feedbackTemplate, +} from 'data/interviews/technical-screening'; + +type FeedbackData = { + steps: Record; +}; + +/** + * Based on existing feedback data returns default template or merges data with its version template + */ +export function getFeedbackFromTemplate(interviewFeedback: InterviewFeedbackDto, interviewMaxScore: number): Feedback { + // no feedback yet, return all steps based on latest version + if (!interviewFeedback.json) { + return { + steps: feedbackTemplate.steps.map(step => ({ ...step, isCompleted: false })), + isCompleted: false, + version: feedbackTemplate.version, + }; + } + + const { isCompleted, json, version } = interviewFeedback; + + return mergeFeedbackValuesToTemplate( + { + version, + isCompleted, + steps: feedbackTemplate.steps, + } as Feedback, + json as FeedbackData, + interviewMaxScore, + ); +} + +/** + * Looks for either first incomplete step or the final one + */ +export function getDefaultStep(feedback: Feedback) { + for (let i = 0; i < feedback.steps.length; i++) { + const { isCompleted, id, values } = feedback.steps[i]; + + if (!isCompleted || isInterviewRejected(id, values)) { + return i; + } + } + + return feedback.isCompleted ? feedback.steps.length - 1 : 0; +} + +/** + * checks whether the step contains rejection value + */ +export function isInterviewRejected(stepId: FeedbackStepId, stepValues: InterviewFeedbackValues = {}) { + return stepId === FeedbackStepId.Introduction && stepValues.interviewResult === 'missed'; +} + +/** + * Merges save feedback data with template + */ +function mergeFeedbackValuesToTemplate(feedback: Feedback, data: FeedbackData, interviewMaxScore: number): Feedback { + const { steps } = data; + + const mergedFeedback = { + ...feedback, + steps: feedback.steps.map(step => { + const stepData = steps[step.id]; + const result = { + ...step, + ...stepData, + }; + const ratedSteps = [FeedbackStepId.Theory, FeedbackStepId.Practice]; + + if (ratedSteps.includes(result.id) && result.values) { + result.values.score = calculateStepScore(result, interviewMaxScore / ratedSteps.length); + } + + return result; + }), + }; + + return applyDefaultFinalScore(mergedFeedback, interviewMaxScore); +} + +function applyDefaultFinalScore(mergedFeedback: Feedback, interviewMaxScore: number) { + return { + ...mergedFeedback, + steps: mergedFeedback.steps.map(step => { + if (step.id !== FeedbackStepId.Decision) { + return step; + } + return { + ...step, + items: step.items.map(item => { + if (item.id === 'finalScore') { + return { + ...item, + defaultValue: calculateFinalScore(mergedFeedback.steps), + max: interviewMaxScore, + }; + } + return item; + }), + }; + }), + }; +} + +export function getUpdatedFeedback({ + activeStepIndex, + feedback, + interviewMaxScore, + newValues, +}: { + feedback: Feedback; + newValues: InterviewFeedbackValues; + activeStepIndex: number; + interviewMaxScore: number; +}) { + const { steps } = feedback; + const isRejected = isInterviewRejected(steps[activeStepIndex].id, newValues); + + const feedbackValues = { + steps: generateFeedbackValues(steps, activeStepIndex, newValues, isRejected), + }; + const newFeedback = mergeFeedbackValuesToTemplate(feedback, feedbackValues, interviewMaxScore); + + return { + steps: newFeedback.steps, + feedbackValues, + isCompleted: isInterviewCompleted(newFeedback), + ...getInterviewSummary(newFeedback), + }; +} + +function generateFeedbackValues( + steps: FeedbackStep[], + activeStepIndex: number, + newValues: InterviewFeedbackValues, + isRejected: boolean, +): Record { + return steps.reduce((stepMap, step, index) => { + if (index === activeStepIndex) { + stepMap[step.id] = { + isCompleted: true, + values: newValues, + }; + return stepMap; + } + + // if is rejected, all steps after the current one should be marked as not completed and values should be removed + stepMap[step.id] = { + values: isRejected ? undefined : step.values, + isCompleted: isRejected ? false : step.isCompleted, + }; + return stepMap; + }, {} as Record); +} + +/** + * Calculates rating/decision & isGoodCandidate using latest feedback state + */ +function getInterviewSummary(feedback: Feedback) { + const { steps } = feedback; + const decision = steps.find(step => step.id === FeedbackStepId.Decision); + + return { + score: (decision?.values?.finalScore as number) ?? undefined, + decision: getDecision(), + isGoodCandidate: getIsGoodCandidate(), + }; + + function getIsGoodCandidate() { + if (decision?.values?.isGoodCandidate == undefined) { + return; + } + + return (decision.values.isGoodCandidate as string[]).includes('true'); + } + + function getDecision() { + const introduction = steps.find(step => step.id === FeedbackStepId.Introduction); + const isInterviewConducted = !isInterviewRejected(FeedbackStepId.Introduction, introduction?.values); + + if (!isInterviewConducted) { + // if the interview was missed, return the reason + return introduction?.values?.['missed'] as string; + } + + return decision?.values?.decision as string; + } +} + +function isInterviewCompleted(feedback: Feedback) { + const { steps } = feedback; + const introduction = feedback.steps.find(step => step.id === FeedbackStepId.Introduction); + return ( + (introduction && isInterviewRejected(introduction.id, introduction.values)) || steps.every(step => step.isCompleted) + ); +} + +function calculateFinalScore(steps: Feedback['steps']) { + const theory = (steps.find(step => step.id === FeedbackStepId.Theory)?.values?.score as number | undefined) ?? 0; + const practice = (steps.find(step => step.id === FeedbackStepId.Practice)?.values?.score as number | undefined) ?? 0; + + return theory + practice; +} + +/** + * Calculates current step score based on questions values + */ +function calculateStepScore(step: FeedbackStep, interviewMaxScore: number) { + const { values = {} } = step; + const questions = values.questions as unknown as InterviewQuestion[] | undefined; + + if (!questions) { + return 0; + } + const scorePerQuestion = interviewMaxScore / questions.length; + + return Math.round( + questions.reduce((score, question) => { + if (!question.value) { + return score; + } + const maxQuestionRating = 5; + const proportion = question.value / maxQuestionRating; + const questionScore = proportion * scorePerQuestion; + return score + questionScore; + }, 0), + ); +} diff --git a/client/src/modules/Interviews/pages/feedback/index.ts b/client/src/modules/Interviews/pages/feedback/index.ts new file mode 100644 index 0000000000..330f593df2 --- /dev/null +++ b/client/src/modules/Interviews/pages/feedback/index.ts @@ -0,0 +1 @@ +export { Feedback } from './Feedback'; diff --git a/client/src/pages/course/interview/[type]/feedback.tsx b/client/src/pages/course/interview/[type]/feedback.tsx index 55f5fc1dc0..00c3d38d1e 100644 --- a/client/src/pages/course/interview/[type]/feedback.tsx +++ b/client/src/pages/course/interview/[type]/feedback.tsx @@ -1,4 +1,6 @@ +import { stageInterviewType } from 'domain/interview'; import { SessionProvider } from 'modules/Course/contexts'; +import { Feedback } from 'modules/Interviews/pages/feedback'; import { InterviewFeedback } from 'modules/Interviews/pages/InterviewFeedback'; import { getServerSideProps, PageProps } from 'modules/Interviews/pages/InterviewFeedback/getServerSideProps'; import { CourseRole } from 'services/models'; @@ -8,7 +10,7 @@ export { getServerSideProps }; export default function (props: PageProps) { return ( - + {props.type === stageInterviewType ? : } ); }