Skip to content

Commit

Permalink
(feat) Live feedback history display on the stats page
Browse files Browse the repository at this point in the history
  • Loading branch information
syoopie committed Aug 27, 2024
1 parent d7dd766 commit 2890644
Show file tree
Hide file tree
Showing 14 changed files with 491 additions and 8 deletions.
34 changes: 34 additions & 0 deletions app/controllers/course/statistics/assessments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ def live_feedback_statistics
@all_students)
end

def live_feedback_history
@live_feedbacks = fetch_live_feedbacks
@live_feedback_details_hash = build_live_feedback_details_hash(@live_feedbacks)
@question = Course::Assessment::Question.find(params[:question_id])

Check warning on line 60 in app/controllers/course/statistics/assessments_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/course/statistics/assessments_controller.rb#L58-L60

Added lines #L58 - L60 were not covered by tests
end

private

def assessment_params
Expand Down Expand Up @@ -89,4 +95,32 @@ def create_question_order_hash
[q.question_id, q.weight]
end
end

def fetch_live_feedbacks
Course::Assessment::LiveFeedback.where(assessment_id: assessment_params[:id],

Check warning on line 100 in app/controllers/course/statistics/assessments_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/course/statistics/assessments_controller.rb#L100

Added line #L100 was not covered by tests
creator_course_id: params[:course_user_id],
question_id: params[:question_id]).
order(created_at: :asc).includes(:code, code: :comments)
end

def build_live_feedback_details_hash(live_feedbacks)
live_feedbacks.each_with_object({}) do |lf, hash|
hash[lf.id] = lf.code.map do |code|

Check warning on line 108 in app/controllers/course/statistics/assessments_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/course/statistics/assessments_controller.rb#L107-L108

Added lines #L107 - L108 were not covered by tests
{
code: {

Check warning on line 110 in app/controllers/course/statistics/assessments_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/course/statistics/assessments_controller.rb#L110

Added line #L110 was not covered by tests
id: code.id,
filename: code.filename,
content: code.content
},
comments: code.comments.map do |comment|
{
id: comment.id,

Check warning on line 117 in app/controllers/course/statistics/assessments_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/course/statistics/assessments_controller.rb#L117

Added line #L117 was not covered by tests
line_number: comment.line_number,
comment: comment.comment
}
end
}
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true
json.files @live_feedback_details_hash[live_feedback_id].each do |live_feedback_details|
json.id live_feedback_details[:code][:id]
json.filename live_feedback_details[:code][:filename]
json.content live_feedback_details[:code][:content]
json.language @question.specific.language
json.comments live_feedback_details[:comments].map do |comment|
json.lineNumber comment[:line_number]
json.comment comment[:comment]
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true
json.liveFeedbackHistory do
json.array! @live_feedbacks.map do |live_feedback|
json.id live_feedback.id
json.createdAt live_feedback.created_at&.iso8601
json.partial! 'live_feedback_history_details', live_feedback_id: live_feedback.id
end
end

json.question do
json.id @question.id
json.title @question.title
json.description format_ckeditor_rich_text(@question.description)
end
12 changes: 12 additions & 0 deletions client/app/api/course/Statistics/AssessmentStatistics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LiveFeedbackHistoryState } from 'types/course/assessment/submission/liveFeedback';
import {
AncestorAssessmentStats,
AssessmentLiveFeedbackStatistics,
Expand Down Expand Up @@ -42,4 +43,15 @@ export default class AssessmentStatisticsAPI extends BaseCourseAPI {
`${this.#urlPrefix}/${assessmentId}/live_feedback_statistics`,
);
}

fetchLiveFeedbackHistory(
assessmentId: string | number,
questionId: string | number,
courseUserId: string | number,
): APIResponse<LiveFeedbackHistoryState> {
return this.client.get(
`${this.#urlPrefix}/${assessmentId}/live_feedback_history`,
{ params: { question_id: questionId, course_user_id: courseUserId } },
);
}
}
32 changes: 32 additions & 0 deletions client/app/bundles/course/assessment/operations/liveFeedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { AxiosError } from 'axios';
import { dispatch } from 'store';

import CourseAPI from 'api/course';

import { liveFeedbackActions as actions } from '../reducers/liveFeedback';

export const fetchLiveFeedbackHistory = async (
assessmentId: number,
questionId: number,
courseUserId: number,
): Promise<void> => {
try {
const response =
await CourseAPI.statistics.assessment.fetchLiveFeedbackHistory(
assessmentId,
questionId,
courseUserId,
);

const data = response.data;
dispatch(
actions.initialize({
liveFeedbackHistory: data.liveFeedbackHistory,
question: data.question,
}),
);
} catch (error) {
if (error instanceof AxiosError) throw error.response?.data?.errors;
throw error;
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { FC, useRef, useState } from 'react';
import ReactAce from 'react-ace';
import { defineMessages } from 'react-intl';
import { Box, Card, CardContent, Drawer, Typography } from '@mui/material';
import { LiveFeedbackCodeAndComments } from 'types/course/assessment/submission/liveFeedback';

import EditorField from 'lib/components/core/fields/EditorField';
import useTranslation from 'lib/hooks/useTranslation';

const translations = defineMessages({
liveFeedbackName: {
id: 'course.assessment.liveFeedback.comments',
defaultMessage: 'Live Feedback',
},
comments: {
id: 'course.assessment.liveFeedback.comments',
defaultMessage: 'Comments',
},
lineHeader: {
id: 'course.assessment.liveFeedback.lineHeader',
defaultMessage: 'Line {lineNumber}',
},
});

interface Props {
file: LiveFeedbackCodeAndComments;
}

const LiveFeedbackDetails: FC<Props> = (props) => {
const { t } = useTranslation();
const { file } = props;

const languageMap = {
JavaScript: 'javascript',
'Python 2.7': 'python',
'Python 3.4': 'python',
'Python 3.5': 'python',
'Python 3.6': 'python',
'C/C++': 'c_cpp',
'Python 3.7': 'python',
'Java 8': 'java',
'Java 11': 'java',
'Python 3.9': 'python',
'Python 3.10': 'python',
'Python 3.12': 'python',
'Java 17': 'java',
};

const startingLineNum = Math.min(
...file.comments.map((comment) => comment.lineNumber),
);

const [selectedLine, setSelectedLine] = useState<number>(startingLineNum);
const editorRef = useRef<ReactAce | null>(null);

const handleCursorChange = (selection): void => {
const currentLine = selection.getCursor().row + 1; // Ace editor uses 0-index, so add 1
setSelectedLine(currentLine);
};

const handleCommentClick = (lineNumber: number): void => {
setSelectedLine(lineNumber);
if (editorRef.current) {
editorRef.current.editor.focus();
editorRef.current.editor.gotoLine(lineNumber, 0, true);
}
};

return (
<div className="relative" id={`file-${file.id}`}>
<Box marginRight="315px">
<EditorField
ref={editorRef}
cursorStart={startingLineNum - 1}
disabled
height="450px"
language={languageMap[file.language]}
onCursorChange={handleCursorChange}
value={file.content}
/>
</Box>
<Drawer
anchor="right"
open
PaperProps={{
style: {
position: 'absolute',
width: '315px',
border: 0,
backgroundColor: 'transparent',
},
}}
variant="persistent"
>
<div className="p-2">
{file.comments.map((comment) => (
<Card
key={`file-${file.id}-comment-${comment.lineNumber}`}
className={`mb-1 border border-solid border-gray-400 rounded-lg shadow-none cursor-pointer ${
selectedLine === comment.lineNumber ? 'bg-yellow-100' : ''
}`}
onClick={() => {
handleCommentClick(comment.lineNumber);
}}
>
<Typography
className="ml-1"
fontWeight="bold"
variant="subtitle1"
>
{t(translations.lineHeader, {
lineNumber: comment.lineNumber,
})}
</Typography>
<CardContent className="px-1 pt-0 last:pb-1">
<Typography variant="body2">{comment.comment}</Typography>
</CardContent>
</Card>
))}
</div>
</Drawer>
</div>
);
};

export default LiveFeedbackDetails;
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { FC, useState } from 'react';
import { defineMessages } from 'react-intl';
import { Slider, Typography } from '@mui/material';

import Accordion from 'lib/components/core/layouts/Accordion';
import { useAppSelector } from 'lib/hooks/store';
import useTranslation from 'lib/hooks/useTranslation';
import { formatLongDateTime } from 'lib/moment';

import {
getLiveFeedbackHistory,
getLiveFeedbadkQuestionInfo,
} from '../selectors';

import LiveFeedbackDetails from './LiveFeedbackDetails';

const translations = defineMessages({
questionTitle: {
id: 'course.assessment.liveFeedback.questionTitle',
defaultMessage: 'Question {index}',
},
});

interface Props {
questionNumber: number;
}

const LiveFeedbackHistoryPage: FC<Props> = (props) => {
const { t } = useTranslation();
const { questionNumber } = props;
const allLiveFeedbackHistory = useAppSelector(getLiveFeedbackHistory).filter(
(liveFeedbackHistory) => {
// Remove live feedbacks that have no comments
return liveFeedbackHistory.files.some((file) => file.comments.length > 0);
},
);
const question = useAppSelector(getLiveFeedbadkQuestionInfo);

const [displayedIndex, setDisplayedIndex] = useState(
allLiveFeedbackHistory.length - 1,
);
const sliderMarks = allLiveFeedbackHistory.map((liveFeedbackHistory, idx) => {
return {
value: idx,
label:
idx === 0 || idx === allLiveFeedbackHistory.length - 1
? formatLongDateTime(liveFeedbackHistory.createdAt)
: '',
};
});

return (
<>
<div className="pb-2">
<Accordion
defaultExpanded={false}
title={t(translations.questionTitle, {
index: questionNumber,
})}
>
<div className="ml-4 mt-4">
<Typography variant="body1">{question.title}</Typography>
<Typography
dangerouslySetInnerHTML={{
__html: question.description,
}}
variant="body2"
/>
</div>
</Accordion>
</div>

{allLiveFeedbackHistory.length > 1 && (
<div className="w-[calc(100%_-_17rem)] mx-auto">
<Slider
defaultValue={allLiveFeedbackHistory.length - 1}
marks={sliderMarks}
max={allLiveFeedbackHistory.length - 1}
min={0}
onChange={(_, value) => {
setDisplayedIndex(Array.isArray(value) ? value[0] : value);
}}
step={null}
valueLabelDisplay="off"
/>
</div>
)}
{allLiveFeedbackHistory[displayedIndex].files.map((file) => {
return <LiveFeedbackDetails key={file.id} file={file} />;
})}
</>
);
};

export default LiveFeedbackHistoryPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { FC } from 'react';
import { useParams } from 'react-router-dom';

import { fetchLiveFeedbackHistory } from 'course/assessment/operations/liveFeedback';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import Preload from 'lib/components/wrappers/Preload';

import LiveFeedbackHistoryPage from './LiveFeedbackHistoryPage';

interface Props {
questionNumber: number;
questionId: number;
courseUserId: number;
}

const LiveFeedbackHistoryIndex: FC<Props> = (props): JSX.Element => {
const { questionNumber, questionId, courseUserId } = props;
const { assessmentId } = useParams();
const parsedAssessmentId = parseInt(assessmentId!, 10);

const fetchLiveFeedbackHistoryDetails = (): Promise<void> =>
fetchLiveFeedbackHistory(parsedAssessmentId, questionId, courseUserId);

return (
<Preload
render={<LoadingIndicator />}
while={fetchLiveFeedbackHistoryDetails}
>
{(): JSX.Element => (
<LiveFeedbackHistoryPage questionNumber={questionNumber} />
)}
</Preload>
);
};

export default LiveFeedbackHistoryIndex;
Loading

0 comments on commit 2890644

Please sign in to comment.