Skip to content

Commit

Permalink
feat(interview): add new feedback page (#2273)
Browse files Browse the repository at this point in the history
* feat(interview): add new feedback page
  • Loading branch information
aaliakseyenka authored Aug 27, 2023
1 parent 5f32cfe commit f482e4e
Show file tree
Hide file tree
Showing 22 changed files with 1,430 additions and 25 deletions.
6 changes: 4 additions & 2 deletions client/src/data/interviews/technical-screening.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ export type InterviewFeedbackValues = Record<string, string[] | string | number
* since the questions are dynamic(user can add/remove) we also store the submitted questions(the section of theory & practice are stored in db to persist the selected questions by the interviewer)
*/
export type InterviewFeedbackStepData = {
id: FeedbackStepId;
isCompleted: boolean;
values?: InterviewFeedbackValues;
};
Expand Down Expand Up @@ -118,6 +117,8 @@ interface InputItem extends Field {
description?: string;
inputType: 'number' | 'text';
defaultValue?: string | number;
min?: number;
max?: number;
}

interface TextItem extends Field {
Expand Down Expand Up @@ -200,7 +201,7 @@ export const introduction: Step = {
title: 'Did the student show up for the interview?',
required: true,
options: [
{ id: 'done', title: "Yes, it's ok." },
{ id: 'completed', title: "Yes, it's ok." },
{
id: 'missed',
title: 'No, interview is failed.',
Expand Down Expand Up @@ -433,6 +434,7 @@ const mentorDecision: Step = {
description: 'We calculated average score based on your marks, but you can adjust the final score',
inputType: 'number',
required: true,
min: 0,
},
{
id: 'isGoodCandidate',
Expand Down
27 changes: 26 additions & 1 deletion client/src/domain/interview.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isInterviewRegistrationInProgress, isInterviewStarted } from './interview';
import { getRating, isInterviewRegistrationInProgress, isInterviewStarted } from './interview';

describe('interview', () => {
beforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2023-01-01')));
Expand Down Expand Up @@ -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);
});
});
});
22 changes: 18 additions & 4 deletions client/src/domain/interview.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<PageProps> = 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<ParsedUrlQuery>;
token: string | undefined;
courseId: number;
}): Promise<Omit<StageFeedbackProps, keyof CourseOnlyPageProps>> {
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<ParsedUrlQuery>;
token: string | undefined;
courseId: number;
}): Promise<Omit<FeedbackProps, keyof CourseOnlyPageProps>> {
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<ParsedUrlQuery>, params: string[]) {
for (const param of params) {
if (!ctx.query[param]) {
throw new Error(`Parameter ${param} is not defined`);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ 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;
questionText: string;
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);

Expand Down
44 changes: 44 additions & 0 deletions client/src/modules/Interviews/pages/feedback/CustomQuestion.tsx
Original file line number Diff line number Diff line change
@@ -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<InputRef>(null);

function saveQuestion() {
const value = addRef.current?.input?.value.trim();
if (!value) {
return;
}

save(value);
}

return (
<Card bodyStyle={{ padding: '12px 24px' }} style={{ marginBottom: 16 }}>
<Input
placeholder="Enter your question"
ref={addRef}
autoFocus
onPressEnter={e => {
// prevent form submit
e.preventDefault();
saveQuestion();
}}
/>
<Row style={{ marginTop: 15 }}>
<Col flex={1} />
<Space>
<Button onClick={cancel}>Cancel</Button>
<Button type="primary" onClick={saveQuestion}>
Save
</Button>
</Space>
</Row>
</Card>
);
}
Loading

0 comments on commit f482e4e

Please sign in to comment.