From a03ecf8c35d94aac7dfb17e6ece2c0527f8c3862 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 16 Jan 2024 12:54:35 +0800 Subject: [PATCH 01/45] change SubmissionDoughnut to use BarChart --- .../statistics/assessments_controller.rb | 2 +- .../assessments/assessment.json.jbuilder | 7 +- .../AssessmentStatistics/StatisticsPanel.jsx | 44 +++++----- .../SubmissionDoughnut.jsx | 86 ------------------ .../SubmissionStatusChart.tsx | 88 +++++++++++++++++++ .../pages/SubmissionsIndex/index.jsx | 4 +- client/app/bundles/course/assessment/types.ts | 18 ++++ client/app/bundles/course/group/types.ts | 9 +- client/locales/en.json | 8 +- client/locales/zh.json | 8 +- 10 files changed, 144 insertions(+), 130 deletions(-) delete mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionDoughnut.jsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx create mode 100644 client/app/bundles/course/assessment/types.ts diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index d42edf2e38..521a52b14d 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -37,7 +37,7 @@ def compute_submission_records(submissions) end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at grade = submission.grade - [submitter_course_user, submission.submitted_at, end_at, grade] + [submitter_course_user, submission.workflow_state, submission.submitted_at, end_at, grade] end.compact end end diff --git a/app/views/course/statistics/assessments/assessment.json.jbuilder b/app/views/course/statistics/assessments/assessment.json.jbuilder index 291b6e66fd..5e19941d0c 100644 --- a/app/views/course/statistics/assessments/assessment.json.jbuilder +++ b/app/views/course/statistics/assessments/assessment.json.jbuilder @@ -16,9 +16,10 @@ json.submissions @submission_records do |record| json.isPhantom record[0].phantom? end - json.submittedAt record[1]&.iso8601 - json.endAt record[2]&.iso8601 - json.grade record[3] + json.workflowState record[1] + json.submittedAt record[2]&.iso8601 + json.endAt record[3]&.iso8601 + json.grade record[4] end json.allStudents @all_students do |student| diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsPanel.jsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsPanel.jsx index e862454c55..da5bbc2716 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsPanel.jsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsPanel.jsx @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; import { courseUserShape, submissionRecordsShape } from '../../propTypes'; import GradeViolinChart from './GradeViolinChart'; -import SubmissionDoughnut from './SubmissionDoughnut'; +import SubmissionStatusChart from './SubmissionStatusChart'; import SubmissionTimeAndGradeChart from './SubmissionTimeAndGradeChart'; const translations = defineMessages({ @@ -40,28 +40,26 @@ CardTitle.propTypes = { }; const StatisticsPanel = ({ submissions, allStudents, intl }) => ( -
-
- - - - {intl.formatMessage(translations.submissionStatuses)} - - - - - - - - {intl.formatMessage(translations.gradeDistribution)} - - - - -
+
+ + + + {intl.formatMessage(translations.submissionStatuses)} + + + + + + + + {intl.formatMessage(translations.gradeDistribution)} + + + + diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionDoughnut.jsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionDoughnut.jsx deleted file mode 100644 index f0013d44df..0000000000 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionDoughnut.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { defineMessages, injectIntl } from 'react-intl'; -import PropTypes from 'prop-types'; -import { - GREEN_CHART_BACKGROUND, - GREEN_CHART_BORDER, - ORANGE_CHART_BACKGROUND, - ORANGE_CHART_BORDER, - RED_CHART_BACKGROUND, - RED_CHART_BORDER, -} from 'theme/colors'; - -import DoughnutChart from 'lib/components/core/charts/DoughnutChart'; - -import { courseUserShape, submissionRecordsShape } from '../../propTypes'; - -const translations = defineMessages({ - datasetLabel: { - id: 'course.assessment.statistics.submissionDoughnut.datasetLabel', - defaultMessage: 'Student Submission Statuses', - }, - submitted: { - id: 'course.assessment.statistics.submissionDoughnut.submitted', - defaultMessage: 'Submitted', - }, - attempting: { - id: 'course.assessment.statistics.submissionDoughnut.attempting', - defaultMessage: 'Attempting', - }, - unattempted: { - id: 'course.assessment.statistics.submissionDoughnut.unattempted', - defaultMessage: 'Unattempted', - }, -}); - -const styles = { - root: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - }, -}; - -const SubmissionDoughnut = ({ submissions, allStudents, intl }) => { - const numSubmitted = submissions.filter((s) => s.submittedAt != null).length; - const numAttempting = submissions.length - numSubmitted; - const numUnattempted = allStudents.length - submissions.length; - - const data = { - labels: [ - intl.formatMessage(translations.submitted), - intl.formatMessage(translations.attempting), - intl.formatMessage(translations.unattempted), - ], - datasets: [ - { - label: intl.formatMessage(translations.datasetLabel), - data: [numSubmitted, numAttempting, numUnattempted], - backgroundColor: [ - GREEN_CHART_BACKGROUND, - ORANGE_CHART_BACKGROUND, - RED_CHART_BACKGROUND, - ], - borderColor: [ - GREEN_CHART_BORDER, - ORANGE_CHART_BORDER, - RED_CHART_BORDER, - ], - borderWidth: 1, - }, - ], - }; - - return ( -
- -
- ); -}; - -SubmissionDoughnut.propTypes = { - submissions: PropTypes.arrayOf(submissionRecordsShape).isRequired, - allStudents: PropTypes.arrayOf(courseUserShape).isRequired, - intl: PropTypes.object.isRequired, -}; - -export default injectIntl(SubmissionDoughnut); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx new file mode 100644 index 0000000000..d06aa113a0 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx @@ -0,0 +1,88 @@ +import { defineMessages, FormattedMessage } from 'react-intl'; +import palette from 'theme/palette'; + +import { workflowStates } from 'course/assessment/submission/constants'; +import { SubmissionRecordShape } from 'course/assessment/types'; +import BarChart from 'lib/components/core/BarChart'; + +const translations = defineMessages({ + datasetLabel: { + id: 'course.assessment.statistics.SubmissionStatusChart.datasetLabel', + defaultMessage: 'Student Submission Statuses', + }, + published: { + id: 'course.assessment.statistics.SubmissionStatusChart.published', + defaultMessage: 'Graded', + }, + graded: { + id: 'course.assessment.statistics.SubmissionStatusChart.graded', + defaultMessage: 'Graded, unpublished', + }, + submitted: { + id: 'course.assessment.statistics.SubmissionStatusChart.submitted', + defaultMessage: 'Submitted', + }, + attempting: { + id: 'course.assessment.statistics.SubmissionStatusChart.attempting', + defaultMessage: 'Attempting', + }, + unattempted: { + id: 'course.assessment.statistics.SubmissionStatusChart.unattempted', + defaultMessage: 'Not Started', + }, +}); + +interface Props { + submissions: SubmissionRecordShape[]; + numStudents: number; +} + +const SubmissionStatusChart = (props: Props): JSX.Element => { + const { submissions, numStudents } = props; + + const numUnstarted = numStudents - submissions.length; + const numAttempting = submissions.filter( + (s) => s.workflowState === workflowStates.Attempting, + ).length; + const numSubmitted = submissions.filter( + (s) => s.workflowState === workflowStates.Submitted, + ).length; + const numGraded = submissions.filter( + (s) => s.workflowState === workflowStates.Graded, + ).length; + const numPublished = submissions.filter( + (s) => s.workflowState === workflowStates.Published, + ).length; + + const data = [ + { + color: palette.submissionStatus[workflowStates.Unstarted], + count: numUnstarted, + label: , + }, + { + color: palette.submissionStatus[workflowStates.Attempting], + count: numAttempting, + label: , + }, + { + color: palette.submissionStatus[workflowStates.Submitted], + count: numSubmitted, + label: , + }, + { + color: palette.submissionStatus[workflowStates.Graded], + count: numGraded, + label: , + }, + { + color: palette.submissionStatus[workflowStates.Published], + count: numPublished, + label: , + }, + ]; + + return ; +}; + +export default SubmissionStatusChart; diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/index.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/index.jsx index c2595287f1..900baf37d0 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/index.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/index.jsx @@ -112,7 +112,7 @@ class VisibleSubmissionsIndex extends Component { ); } - renderBarChart = (submissionBarChart) => { + renderBarChart = (SubmissionStatusChart) => { const { includePhantoms } = this.state; const workflowStatesArray = Object.values(workflowStates); @@ -120,7 +120,7 @@ class VisibleSubmissionsIndex extends Component { (counts, w) => ({ ...counts, [w]: 0 }), {}, ); - const submissionStateCounts = submissionBarChart.reduce( + const submissionStateCounts = SubmissionStatusChart.reduce( (counts, submission) => { if (includePhantoms || !submission.courseUser.phantom) { return { diff --git a/client/app/bundles/course/assessment/types.ts b/client/app/bundles/course/assessment/types.ts new file mode 100644 index 0000000000..9cc2f1179f --- /dev/null +++ b/client/app/bundles/course/assessment/types.ts @@ -0,0 +1,18 @@ +import { WorkflowState } from 'types/course/assessment/submission/submission'; +import { CourseUserRoles } from 'types/course/courseUsers'; + +export interface CourseUserShape { + id: number; + name: string; + role: CourseUserRoles; + isPhantom: boolean; +} + +export interface SubmissionRecordShape { + courseUser: CourseUserShape; + workflowState: WorkflowState; + submittedAt: string; + endAt: string; + grade: number; + dayDifference: number; +} diff --git a/client/app/bundles/course/group/types.ts b/client/app/bundles/course/group/types.ts index 195f12db83..fca7bf3d69 100644 --- a/client/app/bundles/course/group/types.ts +++ b/client/app/bundles/course/group/types.ts @@ -1,11 +1,6 @@ import { CourseUserRoles } from 'types/course/courseUsers'; -interface GroupCourseUser { - id: number; - name: string; - role: CourseUserRoles; - isPhantom: boolean; -} +import { CourseUserShape } from 'course/assessment/types'; interface GroupMember { id: number; @@ -46,7 +41,7 @@ export interface GroupsFetchState { export interface GroupsManageState { isManagingGroups: boolean; hasFetchUserError: boolean; - courseUsers: GroupCourseUser[]; + courseUsers: CourseUserShape[]; selectedGroupId: number; modifiedGroups: Group[]; isUpdating: boolean; diff --git a/client/locales/en.json b/client/locales/en.json index 6a52dde435..03fce5bbb6 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -2390,16 +2390,16 @@ "course.assessment.statistics.statistics": { "defaultMessage": "Statistics" }, - "course.assessment.statistics.submissionDoughnut.attempting": { + "course.assessment.statistics.SubmissionStatusChart.attempting": { "defaultMessage": "Attempting" }, - "course.assessment.statistics.submissionDoughnut.datasetLabel": { + "course.assessment.statistics.SubmissionStatusChart.datasetLabel": { "defaultMessage": "Student Submission Statuses" }, - "course.assessment.statistics.submissionDoughnut.submitted": { + "course.assessment.statistics.SubmissionStatusChart.submitted": { "defaultMessage": "Submitted" }, - "course.assessment.statistics.submissionDoughnut.unattempted": { + "course.assessment.statistics.SubmissionStatusChart.unattempted": { "defaultMessage": "Unattempted" }, "course.assessment.statistics.submissionStatuses": { diff --git a/client/locales/zh.json b/client/locales/zh.json index 364b48a024..fad78fd1ce 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -2363,16 +2363,16 @@ "course.assessment.statistics.statistics": { "defaultMessage": "统计数据" }, - "course.assessment.statistics.submissionDoughnut.attempting": { + "course.assessment.statistics.SubmissionStatusChart.attempting": { "defaultMessage": "尝试" }, - "course.assessment.statistics.submissionDoughnut.datasetLabel": { + "course.assessment.statistics.SubmissionStatusChart.datasetLabel": { "defaultMessage": "学生提交状态" }, - "course.assessment.statistics.submissionDoughnut.submitted": { + "course.assessment.statistics.SubmissionStatusChart.submitted": { "defaultMessage": "已提交" }, - "course.assessment.statistics.submissionDoughnut.unattempted": { + "course.assessment.statistics.SubmissionStatusChart.unattempted": { "defaultMessage": "未尝试" }, "course.assessment.statistics.submissionStatuses": { From 032071c98a7b8f53c009f19ee9a6e269b3e16c5f Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 16 Jan 2024 17:59:13 +0800 Subject: [PATCH 02/45] refactor AssessmentStatistics into tsx, add Tab --- .../AssessmentStatistics/AncestorOptions.jsx | 98 ++++++++ .../AssessmentStatistics/AncestorSelect.jsx | 148 ------------ .../AssessmentStatistics/AncestorSelect.tsx | 54 +++++ .../AncestorStatistics.tsx | 58 +++++ ...atisticsPanel.jsx => StatisticsCharts.jsx} | 6 +- .../StatisticsChartsPanel.tsx | 119 ++++++++++ .../pages/AssessmentStatistics/index.jsx | 212 ------------------ .../pages/AssessmentStatistics/index.tsx | 111 +++++++++ .../pages/AssessmentStatistics/selectors.ts | 10 + client/app/bundles/course/assessment/types.ts | 6 + 10 files changed, 459 insertions(+), 363 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.jsx delete mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.jsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorStatistics.tsx rename client/app/bundles/course/assessment/pages/AssessmentStatistics/{StatisticsPanel.jsx => StatisticsCharts.jsx} (93%) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsChartsPanel.tsx delete mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/index.jsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.jsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.jsx new file mode 100644 index 0000000000..92761ea964 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.jsx @@ -0,0 +1,98 @@ +import { Fragment } from 'react'; +import { injectIntl } from 'react-intl'; +import ArrowBack from '@mui/icons-material/ArrowBack'; +import { Card, CardContent, Chip, Typography } from '@mui/material'; +import PropTypes from 'prop-types'; + +import { ancestorShape } from '../../propTypes'; + +const translations = { + title: { + id: 'course.assessment.statistics.ancestorSelect.title', + defaultMessage: 'Duplication History', + }, + subtitle: { + id: 'course.assessment.statistics.ancestorSelect.subtitle', + defaultMessage: 'Compare against past versions of this assessment:', + }, + current: { + id: 'course.assessment.statistics.ancestorSelect.current', + defaultMessage: 'Current', + }, + fromCourse: { + id: 'course.assessment.statistics.ancestorSelect.fromCourse', + defaultMessage: 'From {courseTitle}', + }, +}; + +const AncestorOptions = ({ + assessmentId, + ancestors, + selectedAncestorId, + setSelectedAncestorId, + intl, +}) => ( +
+ + {intl.formatMessage(translations.title)} + + + {intl.formatMessage(translations.subtitle)} + +
+ {ancestors.map((ancestor, index) => ( + + setSelectedAncestorId(ancestor.id)} + > + + + {ancestor.title} + + + {intl.formatMessage(translations.fromCourse, { + courseTitle: ancestor.courseTitle, + })} + + {ancestor.id === assessmentId ? ( + + ) : null} + + + {index !== ancestors.length - 1 && } + + ))} +
+
+); + +AncestorOptions.propTypes = { + assessmentId: PropTypes.number.isRequired, + ancestors: PropTypes.arrayOf(ancestorShape).isRequired, + selectedAncestorId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + setSelectedAncestorId: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, +}; + +export default injectIntl(AncestorOptions); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.jsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.jsx deleted file mode 100644 index 3eebf5e537..0000000000 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.jsx +++ /dev/null @@ -1,148 +0,0 @@ -import { Fragment } from 'react'; -import { injectIntl } from 'react-intl'; -import ArrowBack from '@mui/icons-material/ArrowBack'; -import { Card, CardContent, Chip, Typography } from '@mui/material'; -import { green } from '@mui/material/colors'; -import PropTypes from 'prop-types'; - -import { ancestorShape } from '../../propTypes'; - -const translations = { - title: { - id: 'course.assessment.statistics.ancestorSelect.title', - defaultMessage: 'Duplication History', - }, - subtitle: { - id: 'course.assessment.statistics.ancestorSelect.subtitle', - defaultMessage: 'Compare against past versions of this assessment:', - }, - current: { - id: 'course.assessment.statistics.ancestorSelect.current', - defaultMessage: 'Current', - }, - fromCourse: { - id: 'course.assessment.statistics.ancestorSelect.fromCourse', - defaultMessage: 'From {courseTitle}', - }, -}; - -const defaultAncestorStyles = { - height: '100%', - width: '300px', - margin: '0 1rem', -}; - -const styles = { - root: { - marginTop: '2rem', - }, - scrollRoot: { - width: '100%', - overflowX: 'scroll', - height: '200px', - padding: '1rem 0', - backgroundColor: '#F5F5F5', - margin: '1rem 0 2rem 0', - display: 'flex', - alignItems: 'center', - }, - currentAssessment: { - ...defaultAncestorStyles, - cursor: 'not-allowed', - }, - ancestor: { - ...defaultAncestorStyles, - cursor: 'pointer', - }, - selectedAncestor: { - ...defaultAncestorStyles, - backgroundColor: green[50], - cursor: 'pointer', - }, - subtitle: { - color: 'grey', - }, - arrow: { - fontSize: '1rem', - }, -}; - -const AncestorSelect = ({ - assessmentId, - ancestors, - selectedAncestorId, - setSelectedAncestorId, - intl, -}) => { - const getStyles = (id) => { - if (id === assessmentId) { - return styles.currentAssessment; - } - if (id === selectedAncestorId) { - return styles.selectedAncestor; - } - return styles.ancestor; - }; - - return ( -
- - {intl.formatMessage(translations.title)} - - - {intl.formatMessage(translations.subtitle)} - -
- {ancestors.map((ancestor, index) => ( - - setSelectedAncestorId(ancestor.id)} - style={getStyles(ancestor.id)} - > - - - {ancestor.title} - - - {intl.formatMessage(translations.fromCourse, { - courseTitle: ancestor.courseTitle, - })} - - {ancestor.id === assessmentId ? ( - - ) : null} - - - {index !== ancestors.length - 1 && } - - ))} -
-
- ); -}; - -AncestorSelect.propTypes = { - assessmentId: PropTypes.number.isRequired, - ancestors: PropTypes.arrayOf(ancestorShape).isRequired, - selectedAncestorId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - setSelectedAncestorId: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, -}; - -export default injectIntl(AncestorSelect); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx new file mode 100644 index 0000000000..ae4d5217e9 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx @@ -0,0 +1,54 @@ +import { defineMessages, FormattedMessage } from 'react-intl'; + +import { AncestorShape } from 'course/assessment/types'; +import ErrorCard from 'lib/components/core/ErrorCard'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; + +import AncestorOptions from './AncestorOptions'; + +const translations = defineMessages({ + fetchAncestorsFailure: { + id: 'course.assessment.statistics.ancestorFail', + defaultMessage: 'Failed to fetch past iterations of this assessment.', + }, +}); + +interface AncestorSelectProps { + ancestors: AncestorShape[]; + fetchAncestorSubmissions: (id: number) => void; + isErrorAncestors: boolean; + isFetchingAncestors: boolean; + parsedAssessmentId: number; + selectedAncestorId: number; +} + +const AncestorSelect = (props: AncestorSelectProps): JSX.Element => { + const { + ancestors, + isFetchingAncestors, + isErrorAncestors, + parsedAssessmentId, + selectedAncestorId, + fetchAncestorSubmissions, + } = props; + if (isFetchingAncestors) { + return ; + } + if (isErrorAncestors) { + return ( + } + /> + ); + } + return ( + + ); +}; + +export default AncestorSelect; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorStatistics.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorStatistics.tsx new file mode 100644 index 0000000000..436872c81a --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorStatistics.tsx @@ -0,0 +1,58 @@ +import { defineMessages, FormattedMessage } from 'react-intl'; + +import { + CourseUserShape, + SubmissionRecordShape, +} from 'course/assessment/types'; +import ErrorCard from 'lib/components/core/ErrorCard'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; + +import StatisticsCharts from './StatisticsCharts'; + +const translations = defineMessages({ + fetchAncestorStatisticsFailure: { + id: 'course.assessment.statistics.ancestorStatisticsFail', + defaultMessage: "Failed to fetch ancestor's statistics.", + }, +}); + +interface AncestorStatisticsProps { + ancestorAllStudents: CourseUserShape[]; + ancestorSubmissions: SubmissionRecordShape[]; + isErrorAncestorStatistics: boolean; + isFetchingAncestorStatistics: boolean; + currentAssessmentSelected: boolean; +} + +const AncestorStatistics = (props: AncestorStatisticsProps): JSX.Element => { + const { + ancestorAllStudents, + ancestorSubmissions, + isErrorAncestorStatistics, + isFetchingAncestorStatistics, + currentAssessmentSelected, + } = props; + if (currentAssessmentSelected) { + return <> ; + } + if (isFetchingAncestorStatistics) { + return ; + } + if (isErrorAncestorStatistics) { + return ( + + } + /> + ); + } + return ( + + ); +}; + +export default AncestorStatistics; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsPanel.jsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.jsx similarity index 93% rename from client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsPanel.jsx rename to client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.jsx index da5bbc2716..7e8e7e6732 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsPanel.jsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.jsx @@ -39,7 +39,7 @@ CardTitle.propTypes = { children: PropTypes.element.isRequired, }; -const StatisticsPanel = ({ submissions, allStudents, intl }) => ( +const StatisticsCharts = ({ submissions, allStudents, intl }) => (
@@ -72,10 +72,10 @@ const StatisticsPanel = ({ submissions, allStudents, intl }) => (
); -StatisticsPanel.propTypes = { +StatisticsCharts.propTypes = { submissions: PropTypes.arrayOf(submissionRecordsShape).isRequired, allStudents: PropTypes.arrayOf(courseUserShape).isRequired, intl: PropTypes.object.isRequired, }; -export default injectIntl(StatisticsPanel); +export default injectIntl(StatisticsCharts); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsChartsPanel.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsChartsPanel.tsx new file mode 100644 index 0000000000..c21e584953 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsChartsPanel.tsx @@ -0,0 +1,119 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages, FormattedMessage } from 'react-intl'; +import { useParams } from 'react-router-dom'; + +import { + fetchAncestors, + fetchAncestorStatistics, + fetchStatistics, +} from 'course/assessment/operations'; +import ErrorCard from 'lib/components/core/ErrorCard'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import AncestorSelect from './AncestorSelect'; +import AncestorStatistics from './AncestorStatistics'; +import { getStatisticsPage } from './selectors'; +import StatisticsCharts from './StatisticsCharts'; + +const translations = defineMessages({ + fetchFailure: { + id: 'course.assessment.statistics.fail', + defaultMessage: 'Failed to fetch statistics.', + }, + fetchAncestorsFailure: { + id: 'course.assessment.statistics.ancestorFail', + defaultMessage: 'Failed to fetch past iterations of this assessment.', + }, + fetchAncestorStatisticsFailure: { + id: 'course.assessment.statistics.ancestorStatisticsFail', + defaultMessage: "Failed to fetch ancestor's statistics.", + }, +}); + +const StatisticsChartsPanel: FC = () => { + const { t } = useTranslation(); + const { assessmentId } = useParams(); + const dispatch = useAppDispatch(); + + const parsedAssessmentId = parseInt(assessmentId!, 10); + + const [selectedAncestorId, setSelectedAncestorId] = + useState(parsedAssessmentId); + const statisticsPage = useAppSelector(getStatisticsPage); + + useEffect(() => { + if (assessmentId) { + dispatch( + fetchStatistics(parsedAssessmentId, t(translations.fetchFailure)), + ); + } + }, [assessmentId]); + + useEffect(() => { + if (assessmentId) { + dispatch( + fetchAncestors( + parsedAssessmentId, + t(translations.fetchAncestorsFailure), + ), + ); + } + }, [assessmentId]); + + if (statisticsPage.isFetching) { + return ; + } + + if (statisticsPage.isError) { + return ( + } + /> + ); + } + + const fetchAncestorSubmissions = (id: number): void => { + if (id === selectedAncestorId) { + return; + } + dispatch( + fetchAncestorStatistics( + id, + t(translations.fetchAncestorStatisticsFailure), + ), + ); + setSelectedAncestorId(id); + }; + + return ( + <> + + +
+ +
+ + ); +}; + +export default StatisticsChartsPanel; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.jsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.jsx deleted file mode 100644 index 10be153672..0000000000 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.jsx +++ /dev/null @@ -1,212 +0,0 @@ -import { useEffect, useState } from 'react'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { useParams } from 'react-router-dom'; -import PropTypes from 'prop-types'; - -import ErrorCard from 'lib/components/core/ErrorCard'; -import Page from 'lib/components/core/layouts/Page'; -import LoadingIndicator from 'lib/components/core/LoadingIndicator'; - -import { - fetchAncestors, - fetchAncestorStatistics, - fetchStatistics, -} from '../../operations'; -import { - ancestorShape, - assessmentShape, - courseUserShape, - submissionRecordsShape, -} from '../../propTypes'; - -import AncestorSelect from './AncestorSelect'; -import StatisticsPanel from './StatisticsPanel'; - -const translations = defineMessages({ - statistics: { - id: 'course.assessment.statistics.statistics', - defaultMessage: 'Statistics', - }, - header: { - id: 'course.assessment.statistics.header', - defaultMessage: 'Statistics for {title}', - }, - fetchFailure: { - id: 'course.assessment.statistics.fail', - defaultMessage: 'Failed to fetch statistics.', - }, - fetchAncestorsFailure: { - id: 'course.assessment.statistics.ancestorFail', - defaultMessage: 'Failed to fetch past iterations of this assessment.', - }, - fetchAncestorStatisticsFailure: { - id: 'course.assessment.statistics.ancestorStatisticsFail', - defaultMessage: "Failed to fetch ancestor's statistics.", - }, -}); - -const styles = { - ancestorStatistics: { - marginBottom: '2rem', - }, -}; - -const AssessmentStatisticsPage = ({ - intl, - isFetching, - isError, - isFetchingAncestors, - isErrorAncestors, - isFetchingAncestorStatistics, - isErrorAncestorStatistics, - dispatch, - assessment, - submissions, - allStudents, - ancestors, - ancestorAssessment, - ancestorSubmissions, - ancestorAllStudents, -}) => { - const { assessmentId } = useParams(); - const [selectedAncestorId, setSelectedAncestorId] = useState(null); - - useEffect(() => { - if (assessmentId) { - dispatch( - fetchStatistics( - assessmentId, - intl.formatMessage(translations.fetchFailure), - ), - ); - } - }, [assessmentId]); - - useEffect(() => { - if (assessmentId) { - dispatch( - fetchAncestors( - assessmentId, - intl.formatMessage(translations.fetchAncestorsFailure), - ), - ); - } - }, [assessmentId]); - - if (isFetching) { - return ; - } - - if (isError) { - return ( - } - /> - ); - } - - const fetchAncestorSubmissions = (id) => { - if (id === assessmentId || id === selectedAncestorId) { - return; - } - dispatch( - fetchAncestorStatistics( - id, - intl.formatMessage(translations.fetchAncestorStatisticsFailure), - ), - ); - setSelectedAncestorId(id); - }; - - const renderAncestorSelect = () => { - if (isFetchingAncestors) { - return ; - } - if (isErrorAncestors) { - return ( - } - /> - ); - } - return ( - - ); - }; - - const renderAncestorStatistics = () => { - if (selectedAncestorId == null) { - return <> ; - } - if (isFetchingAncestorStatistics) { - return ; - } - if (isErrorAncestorStatistics) { - return ( - - } - /> - ); - } - return ( - - ); - }; - - return ( - - - {renderAncestorSelect()} -
{renderAncestorStatistics()}
-
- ); -}; - -AssessmentStatisticsPage.propTypes = { - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - isFetching: PropTypes.bool.isRequired, - isError: PropTypes.bool.isRequired, - isFetchingAncestors: PropTypes.bool.isRequired, - isErrorAncestors: PropTypes.bool.isRequired, - isFetchingAncestorStatistics: PropTypes.bool.isRequired, - isErrorAncestorStatistics: PropTypes.bool.isRequired, - - assessment: assessmentShape.isRequired, - submissions: PropTypes.arrayOf(submissionRecordsShape).isRequired, - allStudents: PropTypes.arrayOf(courseUserShape).isRequired, - ancestors: PropTypes.arrayOf(ancestorShape).isRequired, - - ancestorAssessment: assessmentShape, - ancestorSubmissions: PropTypes.arrayOf(submissionRecordsShape), - ancestorAllStudents: PropTypes.arrayOf(courseUserShape), -}; - -const handle = translations.statistics; - -export default Object.assign( - connect(({ assessments }) => assessments.statisticsPage)( - injectIntl(AssessmentStatisticsPage), - ), - { handle }, -); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx new file mode 100644 index 0000000000..6e0fbd4a5b --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx @@ -0,0 +1,111 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { Box, Tab, Tabs } from '@mui/material'; +import { tabsStyle } from 'theme/mui-style'; + +import { fetchStatistics } from 'course/assessment/operations'; +import Page from 'lib/components/core/layouts/Page'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { getStatisticsPage } from './selectors'; +import StatisticsChartsPanel from './StatisticsChartsPanel'; + +const translations = defineMessages({ + statistics: { + id: 'course.assessment.statistics.statistics', + defaultMessage: 'Statistics', + }, + header: { + id: 'course.assessment.statistics.header', + defaultMessage: 'Statistics for {title}', + }, + fetchFailure: { + id: 'course.assessment.statistics.fail', + defaultMessage: 'Failed to fetch statistics.', + }, + fetchAncestorsFailure: { + id: 'course.assessment.statistics.ancestorFail', + defaultMessage: 'Failed to fetch past iterations of this assessment.', + }, + fetchAncestorStatisticsFailure: { + id: 'course.assessment.statistics.ancestorStatisticsFail', + defaultMessage: "Failed to fetch ancestor's statistics.", + }, + chart: { + id: 'course.assessment.statistics.chart', + defaultMessage: 'Chart', + }, + table: { + id: 'course.assessment.statistics.table', + defaultMessage: 'Table', + }, +}); + +const AssessmentStatisticsPage: FC = () => { + const { t } = useTranslation(); + const [tabValue, setTabValue] = useState('table'); + const { assessmentId } = useParams(); + const parsedAssessmentId = parseInt(assessmentId!, 10); + const dispatch = useAppDispatch(); + + const statisticsPage = useAppSelector(getStatisticsPage); + + useEffect(() => { + if (assessmentId) { + dispatch( + fetchStatistics(parsedAssessmentId, t(translations.fetchFailure)), + ); + } + }, [assessmentId]); + + const tabComponentMapping = { + chart: , + table: , + }; + + return ( + + <> + + { + setTabValue(value); + }} + scrollButtons="auto" + sx={tabsStyle} + TabIndicatorProps={{ color: 'primary', style: { height: 5 } }} + value={tabValue} + variant="scrollable" + > + + + + + + {tabComponentMapping[tabValue]} + + + ); +}; + +const handle = translations.statistics; + +export default Object.assign(AssessmentStatisticsPage, { handle }); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts new file mode 100644 index 0000000000..c9f4101726 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts @@ -0,0 +1,10 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { AppState } from 'store'; + +function getLocalState(state: AppState) { + return state.assessments.statisticsPage; +} + +export function getStatisticsPage(state: AppState) { + return getLocalState(state); +} diff --git a/client/app/bundles/course/assessment/types.ts b/client/app/bundles/course/assessment/types.ts index 9cc2f1179f..58a7ceaba7 100644 --- a/client/app/bundles/course/assessment/types.ts +++ b/client/app/bundles/course/assessment/types.ts @@ -16,3 +16,9 @@ export interface SubmissionRecordShape { grade: number; dayDifference: number; } + +export interface AncestorShape { + id: number | string; + title: string; + courseTitle: string; +} From 3e086973087cb89487ce855093763d9a69e2b657 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Fri, 19 Jan 2024 01:35:23 +0800 Subject: [PATCH 03/45] design API to fetch mark per question statistics --- .../statistics/assessments_controller.rb | 38 +++++++++++++++++++ .../marks_per_question.json.jbuilder | 31 +++++++++++++++ .../course/Statistics/AssessmentStatistics.ts | 13 ++++++- .../bundles/course/assessment/operations.ts | 11 ++++++ .../course/statistics/assessmentStatistics.ts | 28 +++++++++++++- config/routes.rb | 1 + 6 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 app/views/course/statistics/assessments/marks_per_question.json.jbuilder diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 521a52b14d..dd1252eae3 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true class Course::Statistics::AssessmentsController < Course::Statistics::Controller + include Course::UsersHelper + def assessment @assessment = Course::Assessment.where(id: assessment_params[:id]). calculated(:maximum_grade). @@ -24,6 +26,31 @@ def ancestors end end + def marks_per_question + authorize!(:read_statistics, current_course) + + @assessment = Course::Assessment.where(id: assessment_params[:id]). + preload(course: :course_users).first + submissions = Course::Assessment::Submission.preload(:answers, creator: :course_users). + where(assessment_id: assessment_params[:id]). + calculated(:grade, :grader_ids) + @course_users = current_course.course_users.students.order_alphabetically + @course_submission_hash = @course_users.to_h do |course_user| + [course_user, nil] + end + @course_users_hash = @course_users.to_h do |course_user| + [course_user.user_id, course_user] + end + @question_order_hash = @assessment.question_assessments.to_h do |q| + [q.question_id, q.weight] + end + @question_maximum_grade_hash = @assessment.questions.to_h do |q| + [q.id, q.maximum_grade] + end + + filter_only_student_submission(submissions) + end + private def assessment_params @@ -40,4 +67,15 @@ def compute_submission_records(submissions) [submitter_course_user, submission.workflow_state, submission.submitted_at, end_at, grade] end.compact end + + def filter_only_student_submission(submissions) + submissions.map do |submission| + submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first + next unless submitter_course_user&.student? + + answers = submission.answers.select(&:current_answer).sort_by { |answer| @question_order_hash[answer.question_id] } + + @course_submission_hash[submitter_course_user] = [submission, answers] + end + end end diff --git a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder new file mode 100644 index 0000000000..c9f1a34371 --- /dev/null +++ b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder @@ -0,0 +1,31 @@ +# frozen_string_literal: true +json.questionCount @question_order_hash.size +json.maximumGrade @question_maximum_grade_hash.values.sum +json.submissions @course_submission_hash.each do |course_user, (submission, answers)| + json.id course_user.id + json.name course_user.name + json.role course_user.role + json.isPhantom course_user.phantom? + + json.groups course_user.groups do |group| + json.name group.name + end + + if !submission.nil? && !answers.nil? + json.totalGrade submission.grade + json.workflowState submission.workflow_state + + if submission.workflow_state == 'published' + # the graders are all the same regardless of question, so we just pick the first one + grader = @course_users_hash[submission.grader_ids.first] + json.graderId grader&.id || 0 + json.grader grader&.name || 'System' + end + + json.answers answers.each do |answer| + json.id answer.id + json.grade answer.grade + json.maximumGrade @question_maximum_grade_hash[answer.question_id] + end + end +end diff --git a/client/app/api/course/Statistics/AssessmentStatistics.ts b/client/app/api/course/Statistics/AssessmentStatistics.ts index 1390aacb99..29d7c2812e 100644 --- a/client/app/api/course/Statistics/AssessmentStatistics.ts +++ b/client/app/api/course/Statistics/AssessmentStatistics.ts @@ -1,6 +1,7 @@ import { AssessmentAncestors, - AssessmentStatistis, + AssessmentMarksPerQuestionStats, + AssessmentStatistics, } from 'types/course/statistics/assessmentStatistics'; import { APIResponse } from 'api/types'; @@ -20,7 +21,7 @@ export default class AssessmentStatisticsAPI extends BaseCourseAPI { */ fetchStatistics( assessmentId: string | number, - ): APIResponse { + ): APIResponse { return this.client.get(`${this.#urlPrefix}/${assessmentId}`); } @@ -32,4 +33,12 @@ export default class AssessmentStatisticsAPI extends BaseCourseAPI { ): APIResponse { return this.client.get(`${this.#urlPrefix}/${assessmentId}/ancestors`); } + + fetchMarksPerQuestionStats( + assessmentId: string | number, + ): APIResponse { + return this.client.get( + `${this.#urlPrefix}/${assessmentId}/marks_per_question`, + ); + } } diff --git a/client/app/bundles/course/assessment/operations.ts b/client/app/bundles/course/assessment/operations.ts index 49aeaef2bd..e2abebd4bc 100644 --- a/client/app/bundles/course/assessment/operations.ts +++ b/client/app/bundles/course/assessment/operations.ts @@ -11,6 +11,7 @@ import { import { MonitoringRequestData } from 'types/course/assessment/monitoring'; import { McqMrqListData } from 'types/course/assessment/question/multiple-responses'; import { QuestionDuplicationResult } from 'types/course/assessment/questions'; +import { AssessmentMarksPerQuestionStats } from 'types/course/statistics/assessmentStatistics'; import CourseAPI from 'api/course'; import { JustRedirect } from 'api/types'; @@ -313,6 +314,16 @@ export function fetchAncestorStatistics( }; } +export const fetchStudentMarkPerQuestion = async ( + assessmentId: string | number, +): Promise => { + const response = + await CourseAPI.statistics.assessment.fetchMarksPerQuestionStats( + assessmentId, + ); + return response.data; +}; + export const fetchMonitoringData = async (): Promise => { const response = await CourseAPI.assessment.assessments.fetchMonitoringData(); return response.data; diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 9c3b1671c5..f83ded5c95 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -28,7 +28,7 @@ interface Student { isPhantom: boolean; } -export interface AssessmentStatistis { +export interface AssessmentStatistics { assessment: Assessment; submissions: Submission[]; allStudents: Student[]; @@ -43,3 +43,29 @@ export interface AssessmentAncestor { export interface AssessmentAncestors { assessments: AssessmentAncestor[]; } + +export interface AnswerGradeStats { + id: number; + grade: number; + maximumGrade: number; +} + +export interface SubmissionStats { + id: number; + name: string; + role: string; + isPhantom: boolean; + grader?: string; + graderId?: number; + groups?: { name: string }[]; + groupCategoryId?: number; + totalGrade?: number | null; + workflowState?: string; + answers?: AnswerGradeStats[]; +} + +export interface AssessmentMarksPerQuestionStats { + maximumGrade: number; + questionCount: number; + submissions: SubmissionStats[]; +} diff --git a/config/routes.rb b/config/routes.rb index ca15348e62..9e01e2f578 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -438,6 +438,7 @@ get 'user/:user_id/learning_rate_records' => 'users#learning_rate_records' get 'assessment/:id' => 'assessments#assessment' get 'assessment/:id/ancestors' => 'assessments#ancestors' + get 'assessment/:id/marks_per_question' => 'assessments#marks_per_question' end scope module: :video do From 00df536d2a533c72c87a399dade826cff44a7142 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Fri, 19 Jan 2024 01:36:25 +0800 Subject: [PATCH 04/45] table for question level student statistics --- .../StatisticsChartsPanel.tsx | 5 - .../StatisticsTablePanel.tsx | 26 ++ .../StudentMarksPerQuestionTable.tsx | 266 ++++++++++++++++++ .../pages/AssessmentStatistics/index.tsx | 3 +- 4 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsTablePanel.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsChartsPanel.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsChartsPanel.tsx index c21e584953..1d9eeedf06 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsChartsPanel.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsChartsPanel.tsx @@ -48,11 +48,6 @@ const StatisticsChartsPanel: FC = () => { dispatch( fetchStatistics(parsedAssessmentId, t(translations.fetchFailure)), ); - } - }, [assessmentId]); - - useEffect(() => { - if (assessmentId) { dispatch( fetchAncestors( parsedAssessmentId, diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsTablePanel.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsTablePanel.tsx new file mode 100644 index 0000000000..9630c83cff --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsTablePanel.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react'; +import { useParams } from 'react-router-dom'; +import { AssessmentMarksPerQuestionStats } from 'types/course/statistics/assessmentStatistics'; + +import { fetchStudentMarkPerQuestion } from 'course/assessment/operations'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; + +import StudentMarksPerQuestionTable from './StudentMarksPerQuestionTable'; + +const StatisticsTablePanel: FC = () => { + const { assessmentId } = useParams(); + + const fetchStudentMarks = (): Promise => + fetchStudentMarkPerQuestion(assessmentId!); + + return ( + } while={fetchStudentMarks}> + {(data): JSX.Element => { + return ; + }} + + ); +}; + +export default StatisticsTablePanel; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx new file mode 100644 index 0000000000..51238a966d --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -0,0 +1,266 @@ +import { FC, ReactNode } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { Box, Chip } from '@mui/material'; +import palette from 'theme/palette'; +import { + AssessmentMarksPerQuestionStats, + SubmissionStats, +} from 'types/course/statistics/assessmentStatistics'; + +import { workflowStates } from 'course/assessment/submission/constants'; +import Link from 'lib/components/core/Link'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + data: AssessmentMarksPerQuestionStats; +} + +const translations = defineMessages({ + name: { + id: 'course.assessment.statistics.name', + defaultMessage: 'Name', + }, + group: { + id: 'course.assessment.statistics.group', + defaultMessage: 'Group', + }, + totalGrade: { + id: 'course.assessment.statistics.totalGrade', + defaultMessage: 'Total', + }, + grader: { + id: 'course.assessment.statistics.grader', + defaultMessage: 'Grader', + }, + searchText: { + id: 'course.assessment.statistics.searchText', + defaultMessage: 'Search by Group or Grader Name', + }, + answers: { + id: 'course.assessment.statistics.answers', + defaultMessage: 'Answers', + }, + questionIndex: { + id: 'course.assessment.statistics.questionIndex', + defaultMessage: 'Q{index}', + }, + questionDisplayTitle: { + id: 'course.assessment.statistics.questionDisplayTitle', + defaultMessage: 'Q{index} for {student}', + }, + noSubmission: { + id: 'course.assessment.statistics.noSubmission', + defaultMessage: 'No Submission yet', + }, + workflowState: { + id: 'course.assessment.statistics.workflowState', + defaultMessage: 'Status', + }, +}); + +const statusTranslations = { + attempting: 'Attempting', + submitted: 'Submitted', + graded: 'Graded, unpublished', + published: 'Graded', + unstarted: 'Not Started', +}; + +const StudentMarksPerQuestionTable: FC = (props) => { + const { t } = useTranslation(); + const { courseId } = useParams(); + const { data } = props; + + // if (!data || data.length === 0) { + // return ; + // } + + // calculate the gradient of the color in each grade cell + // 1. we compute the distance between the grade and the mid-grade (half the maximum) + // 2. then, we compute the fraction of it -> range becomes [0,1] + // 3. then we convert it into range [0,3] so that the shades will become [100, 200, 300] + const calculateColorGradientLevel = ( + grade: number, + halfMaxGrade: number, + ): number => { + return Math.max( + Math.round((Math.abs(grade - halfMaxGrade) / halfMaxGrade) * 5) * 100, + 50, + ); + }; + + // the case where the grade is null is handled separately inside the column + // (refer to the definition of answerColumns below) + const renderNonNullGradeCell = ( + grade: number | null, + maxGrade: number | null, + ): ReactNode => { + if (!grade || !maxGrade) { + return null; + } + + const colorGradientLevel = calculateColorGradientLevel(grade, maxGrade / 2); + const className = + grade >= maxGrade / 2 + ? `bg-green-${colorGradientLevel} p-[1rem]` + : `bg-red-${colorGradientLevel} p-[1rem]`; + return ( +
+ {grade} +
+ ); + }; + + // the customised sorting for grades to ensure null always is less than any non-null grade + const sortNullableGrade = ( + grade1: number | null, + grade2: number | null, + ): number => { + if (!grade1 && !grade2) { + return 0; + } + if (!grade1) { + return -1; + } + if (!grade2) { + return 1; + } + return grade1 - grade2; + }; + + const answerColumns: ColumnTemplate[] = Array.from( + { length: data.questionCount }, + (_, index) => { + return { + searchProps: { + getValue: (datum) => datum.answers?.[index]?.grade?.toString() ?? '', + }, + title: t(translations.questionIndex, { index: index + 1 }), + cell: (datum): ReactNode => { + return datum.answers?.[index].grade + ? renderNonNullGradeCell( + datum.answers?.[index].grade ?? null, + datum.answers?.[index].maximumGrade ?? null, + ) + : null; + }, + sortable: true, + className: 'text-right', + sortProps: { + sort: (datum1, datum2): number => { + return sortNullableGrade( + datum1.answers?.[index].grade ?? null, + datum2.answers?.[index].grade ?? null, + ); + }, + }, + }; + }, + ); + + const columns: ColumnTemplate[] = [ + { + of: 'name', + title: t(translations.name), + sortable: true, + cell: (datum) => ( + {datum.name} + ), + }, + { + of: 'groups', + title: t(translations.group), + sortable: true, + searchable: true, + searchProps: { + getValue: (datum) => + datum.groups ? datum.groups.map((g) => g.name).join(', ') : '', + }, + cell: (datum) => + datum.groups ? datum.groups.map((g) => g.name).join(', ') : '', + }, + { + of: 'workflowState', + title: t(translations.workflowState), + sortable: true, + cell: (datum) => ( + + ), + className: 'center', + }, + { + searchProps: { + getValue: (datum) => datum.totalGrade?.toString() ?? '', + }, + title: t(translations.totalGrade), + sortable: true, + cell: (datum): ReactNode => + datum.totalGrade + ? renderNonNullGradeCell(datum.totalGrade ?? null, data.maximumGrade) + : null, + className: 'text-right', + sortProps: { + sort: (datum1, datum2): number => { + return sortNullableGrade( + datum1.totalGrade ?? null, + datum2.totalGrade ?? null, + ); + }, + }, + }, + { + of: 'grader', + title: t(translations.grader), + sortable: true, + searchable: true, + cell: (datum): JSX.Element | string => { + if (datum.grader && datum.graderId !== 0) { + return ( + + {datum.grader} + + ); + } + return datum.grader ?? ''; + }, + }, + ]; + + columns.splice(3, 0, ...answerColumns); + + return ( + + `data_${datum.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` + } + getRowEqualityData={(datum): SubmissionStats => datum} + getRowId={(datum): string => datum.id.toString()} + indexing={{ indices: true }} + pagination={{ + rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], + showAllRows: true, + }} + search={{ searchPlaceholder: t(translations.searchText) }} + toolbar={{ show: true }} + /> + ); +}; + +export default StudentMarksPerQuestionTable; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx index 6e0fbd4a5b..23269f1592 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx @@ -11,6 +11,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import { getStatisticsPage } from './selectors'; import StatisticsChartsPanel from './StatisticsChartsPanel'; +import StatisticsTablePanel from './StatisticsTablePanel'; const translations = defineMessages({ statistics: { @@ -62,7 +63,7 @@ const AssessmentStatisticsPage: FC = () => { const tabComponentMapping = { chart: , - table: , + table: , }; return ( From e918ec7ddaa685043bb4754c3d0d70b2fc4e1ab7 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Fri, 19 Jan 2024 01:48:16 +0800 Subject: [PATCH 05/45] question statistics table can be downloaded into csv --- .../statistics/assessments_controller.rb | 53 ++++++++++++------- .../marks_per_question.json.jbuilder | 6 +-- .../StudentMarksPerQuestionTable.tsx | 19 +++++-- 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index dd1252eae3..3b175ed31e 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -31,24 +31,14 @@ def marks_per_question @assessment = Course::Assessment.where(id: assessment_params[:id]). preload(course: :course_users).first - submissions = Course::Assessment::Submission.preload(:answers, creator: :course_users). - where(assessment_id: assessment_params[:id]). - calculated(:grade, :grader_ids) + @submissions = Course::Assessment::Submission.preload(:answers, creator: :course_users). + where(assessment_id: assessment_params[:id]). + calculated(:grade, :grader_ids) @course_users = current_course.course_users.students.order_alphabetically - @course_submission_hash = @course_users.to_h do |course_user| - [course_user, nil] - end - @course_users_hash = @course_users.to_h do |course_user| - [course_user.user_id, course_user] - end - @question_order_hash = @assessment.question_assessments.to_h do |q| - [q.question_id, q.weight] - end - @question_maximum_grade_hash = @assessment.questions.to_h do |q| - [q.id, q.maximum_grade] - end - filter_only_student_submission(submissions) + create_user_id_to_course_user_hash + create_question_related_hash + create_student_submissions_hash end private @@ -57,6 +47,15 @@ def assessment_params params.permit(:id) end + def create_question_related_hash + @question_order_hash = @assessment.question_assessments.to_h do |q| + [q.question_id, q.weight] + end + @question_maximum_grade_hash = @assessment.questions.to_h do |q| + [q.id, q.maximum_grade] + end + end + def compute_submission_records(submissions) submissions.map do |submission| submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first @@ -68,14 +67,28 @@ def compute_submission_records(submissions) end.compact end - def filter_only_student_submission(submissions) - submissions.map do |submission| + def create_user_id_to_course_user_hash + @user_id_to_course_user_hash = @course_users.to_h do |course_user| + [course_user.user_id, course_user] + end + end + + def create_student_submissions_hash + # initialisation + @student_submissions_hash = @course_users.to_h do |course_user| + [course_user, nil] + end + + # populate the student submissions hash + @submissions.map do |submission| submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first next unless submitter_course_user&.student? - answers = submission.answers.select(&:current_answer).sort_by { |answer| @question_order_hash[answer.question_id] } + answers = submission.answers. + select(&:current_answer). + sort_by { |answer| @question_order_hash[answer.question_id] } - @course_submission_hash[submitter_course_user] = [submission, answers] + @student_submissions_hash[submitter_course_user] = [submission, answers] end end end diff --git a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder index c9f1a34371..38a1341083 100644 --- a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder +++ b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.questionCount @question_order_hash.size json.maximumGrade @question_maximum_grade_hash.values.sum -json.submissions @course_submission_hash.each do |course_user, (submission, answers)| +json.submissions @student_submissions_hash.each do |course_user, (submission, answers)| json.id course_user.id json.name course_user.name json.role course_user.role @@ -17,11 +17,11 @@ json.submissions @course_submission_hash.each do |course_user, (submission, answ if submission.workflow_state == 'published' # the graders are all the same regardless of question, so we just pick the first one - grader = @course_users_hash[submission.grader_ids.first] + grader = @user_id_to_course_user_hash[submission.grader_ids.first] json.graderId grader&.id || 0 json.grader grader&.name || 'System' end - + json.answers answers.each do |answer| json.id answer.id json.grade answer.grade diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 51238a966d..93ca7b6b3a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -12,8 +12,11 @@ import { workflowStates } from 'course/assessment/submission/constants'; import Link from 'lib/components/core/Link'; import Table, { ColumnTemplate } from 'lib/components/table'; import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; +import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; +import { getStatisticsPage } from './selectors'; + interface Props { data: AssessmentMarksPerQuestionStats; } @@ -59,6 +62,10 @@ const translations = defineMessages({ id: 'course.assessment.statistics.workflowState', defaultMessage: 'Status', }, + filename: { + id: 'course.assessment.statistics.filename', + defaultMessage: 'Question-level Statistics for {assessment}', + }, }); const statusTranslations = { @@ -74,9 +81,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { const { courseId } = useParams(); const { data } = props; - // if (!data || data.length === 0) { - // return ; - // } + const assessment = useAppSelector(getStatisticsPage).assessment; // calculate the gradient of the color in each grade cell // 1. we compute the distance between the grade and the mid-grade (half the maximum) @@ -148,6 +153,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { : null; }, sortable: true, + csvDownloadable: true, className: 'text-right', sortProps: { sort: (datum1, datum2): number => { @@ -169,6 +175,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { cell: (datum) => ( {datum.name} ), + csvDownloadable: true, }, { of: 'groups', @@ -181,6 +188,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { }, cell: (datum) => datum.groups ? datum.groups.map((g) => g.name).join(', ') : '', + csvDownloadable: true, }, { of: 'workflowState', @@ -222,6 +230,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { ); }, }, + csvDownloadable: true, }, { of: 'grader', @@ -238,6 +247,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { } return datum.grader ?? ''; }, + csvDownloadable: true, }, ]; @@ -246,6 +256,9 @@ const StudentMarksPerQuestionTable: FC = (props) => { return (
`data_${datum.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` From 23820248cf153b78293a8695dfc6a0c1ab43b3f1 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Fri, 19 Jan 2024 15:43:27 +0800 Subject: [PATCH 06/45] resolve grader and tailwind issue, refactor controller - when grader_ids does not exist within submission - className cannot be defined dynamically within defining component - reduce the cyclomatic complexity of marks_per_question API --- .../statistics/assessments_controller.rb | 9 +++++-- .../marks_per_question.json.jbuilder | 2 +- .../ColorGradationLevel.ts | 17 ++++++++++++ .../StudentMarksPerQuestionTable.tsx | 26 ++++++++++++------- 4 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/ColorGradationLevel.ts diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 3b175ed31e..5111a6651f 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -74,12 +74,17 @@ def create_user_id_to_course_user_hash end def create_student_submissions_hash - # initialisation + initialise_student_submissions_hash + populate_student_submissions_hash + end + + def initialise_student_submissions_hash @student_submissions_hash = @course_users.to_h do |course_user| [course_user, nil] end + end - # populate the student submissions hash + def populate_student_submissions_hash @submissions.map do |submission| submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first next unless submitter_course_user&.student? diff --git a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder index 38a1341083..c0d11f9491 100644 --- a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder +++ b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder @@ -15,7 +15,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.totalGrade submission.grade json.workflowState submission.workflow_state - if submission.workflow_state == 'published' + if submission.workflow_state == 'published' && submission.grader_ids # the graders are all the same regardless of question, so we just pick the first one grader = @user_id_to_course_user_hash[submission.grader_ids.first] json.graderId grader&.id || 0 diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/ColorGradationLevel.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/ColorGradationLevel.ts new file mode 100644 index 0000000000..2673254e7b --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/ColorGradationLevel.ts @@ -0,0 +1,17 @@ +export const lowerGradeBackgroundColorClassName = { + 0: 'bg-red-50', + 100: 'bg-red-100', + 200: 'bg-red-200', + 300: 'bg-red-300', + 400: 'bg-red-400', + 500: 'bg-red-500', +}; + +export const higherGradeBackgroundColorClassName = { + 0: 'bg-green-50', + 100: 'bg-green-100', + 200: 'bg-green-200', + 300: 'bg-green-300', + 400: 'bg-green-400', + 500: 'bg-green-500', +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 93ca7b6b3a..d80f40e004 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -15,6 +15,10 @@ import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; +import { + higherGradeBackgroundColorClassName, + lowerGradeBackgroundColorClassName, +} from './ColorGradationLevel'; import { getStatisticsPage } from './selectors'; interface Props { @@ -91,9 +95,8 @@ const StudentMarksPerQuestionTable: FC = (props) => { grade: number, halfMaxGrade: number, ): number => { - return Math.max( - Math.round((Math.abs(grade - halfMaxGrade) / halfMaxGrade) * 5) * 100, - 50, + return ( + Math.round((Math.abs(grade - halfMaxGrade) / halfMaxGrade) * 5) * 100 ); }; @@ -107,11 +110,15 @@ const StudentMarksPerQuestionTable: FC = (props) => { return null; } - const colorGradientLevel = calculateColorGradientLevel(grade, maxGrade / 2); - const className = - grade >= maxGrade / 2 - ? `bg-green-${colorGradientLevel} p-[1rem]` - : `bg-red-${colorGradientLevel} p-[1rem]`; + const gradientLevel = calculateColorGradientLevel(grade, maxGrade / 2); + let className = ''; + + if (grade >= maxGrade / 2) { + className = `${higherGradeBackgroundColorClassName[gradientLevel]} p-[1rem]`; + } else { + className = `${lowerGradeBackgroundColorClassName[gradientLevel]} p-[1rem]`; + } + return (
{grade} @@ -211,6 +218,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { ), className: 'center', }, + ...answerColumns, { searchProps: { getValue: (datum) => datum.totalGrade?.toString() ?? '', @@ -251,8 +259,6 @@ const StudentMarksPerQuestionTable: FC = (props) => { }, ]; - columns.splice(3, 0, ...answerColumns); - return (
Date: Wed, 24 Jan 2024 00:42:15 +0800 Subject: [PATCH 07/45] refactor controller and pages for stats - move most functions in controller to concern - function to get mark cell classname out from component definition --- .../course/statistics/submissions_concern.rb | 46 +++++++++++++ .../statistics/assessments_controller.rb | 67 +++++-------------- .../assessments/assessment.json.jbuilder | 20 +++--- .../marks_per_question.json.jbuilder | 2 +- .../ColorGradationLevel.ts | 25 ++++++- .../StudentMarksPerQuestionTable.tsx | 42 +++--------- .../pages/AssessmentStatistics/selectors.ts | 9 +-- 7 files changed, 110 insertions(+), 101 deletions(-) create mode 100644 app/controllers/concerns/course/statistics/submissions_concern.rb diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb new file mode 100644 index 0000000000..40225c5692 --- /dev/null +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true +module Course::Statistics::SubmissionsConcern + private + + def initialize_student_hash(students) + students.to_h { |student| [student, nil] } + end + + def student_submission_end_time_hash(submissions, students) + student_hash = initialize_student_hash(students) + + populate_with_submission_and_end_time(student_hash, submissions) + student_hash + end + + def student_submission_marks_hash(submissions, students) + student_hash = initialize_student_hash(students) + + populate_with_submission_and_ordered_answer(student_hash, submissions) + student_hash + end + + def populate_with_submission_and_end_time(student_hash, submissions) + submissions.map do |submission| + submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first + next unless submitter_course_user&.student? + + end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at + + student_hash[submitter_course_user] = [submission, end_at] + end + end + + def populate_with_submission_and_ordered_answer(student_hash, submissions) + submissions.map do |submission| + submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first + next unless submitter_course_user&.student? + + answers = submission.answers. + select(&:current_answer). + sort_by { |answer| @question_order_hash[answer.question_id] } + + student_hash[submitter_course_user] = [submission, answers] + end + end +end diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 5111a6651f..5830e63d6a 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class Course::Statistics::AssessmentsController < Course::Statistics::Controller include Course::UsersHelper + include Course::Statistics::SubmissionsConcern + + before_action :load_course_user_students, except: [:ancestors] def assessment @assessment = Course::Assessment.where(id: assessment_params[:id]). @@ -11,8 +14,10 @@ def assessment submissions = Course::Assessment::Submission.preload(creator: :course_users). where(assessment_id: assessment_params[:id]). calculated(:grade) - @submission_records = compute_submission_records(submissions) - @all_students = @assessment.course.course_users.students + + # we do not need the nil value for this hash, since we aim only + # to display the statistics charts + @student_submissions_hash = student_submission_end_time_hash(submissions, @all_students).compact end def ancestors @@ -27,18 +32,15 @@ def ancestors end def marks_per_question - authorize!(:read_statistics, current_course) - @assessment = Course::Assessment.where(id: assessment_params[:id]). preload(course: :course_users).first - @submissions = Course::Assessment::Submission.preload(:answers, creator: :course_users). - where(assessment_id: assessment_params[:id]). - calculated(:grade, :grader_ids) - @course_users = current_course.course_users.students.order_alphabetically + submissions = Course::Assessment::Submission.preload(:answers, creator: :course_users). + where(assessment_id: assessment_params[:id]). + calculated(:grade, :grader_ids) + @course_users_hash = preload_course_users_hash(current_course) - create_user_id_to_course_user_hash create_question_related_hash - create_student_submissions_hash + @student_submissions_hash = student_submission_marks_hash(submissions, @all_students) end private @@ -47,6 +49,10 @@ def assessment_params params.permit(:id) end + def load_course_user_students + @all_students = current_course.course_users.students + end + def create_question_related_hash @question_order_hash = @assessment.question_assessments.to_h do |q| [q.question_id, q.weight] @@ -55,45 +61,4 @@ def create_question_related_hash [q.id, q.maximum_grade] end end - - def compute_submission_records(submissions) - submissions.map do |submission| - submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first - next unless submitter_course_user&.student? - - end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at - grade = submission.grade - [submitter_course_user, submission.workflow_state, submission.submitted_at, end_at, grade] - end.compact - end - - def create_user_id_to_course_user_hash - @user_id_to_course_user_hash = @course_users.to_h do |course_user| - [course_user.user_id, course_user] - end - end - - def create_student_submissions_hash - initialise_student_submissions_hash - populate_student_submissions_hash - end - - def initialise_student_submissions_hash - @student_submissions_hash = @course_users.to_h do |course_user| - [course_user, nil] - end - end - - def populate_student_submissions_hash - @submissions.map do |submission| - submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first - next unless submitter_course_user&.student? - - answers = submission.answers. - select(&:current_answer). - sort_by { |answer| @question_order_hash[answer.question_id] } - - @student_submissions_hash[submitter_course_user] = [submission, answers] - end - end end diff --git a/app/views/course/statistics/assessments/assessment.json.jbuilder b/app/views/course/statistics/assessments/assessment.json.jbuilder index 5e19941d0c..3430c71c6c 100644 --- a/app/views/course/statistics/assessments/assessment.json.jbuilder +++ b/app/views/course/statistics/assessments/assessment.json.jbuilder @@ -8,21 +8,21 @@ json.assessment do json.url course_assessment_path(current_course, @assessment) end -json.submissions @submission_records do |record| +json.submissions @student_submissions_hash.each do |course_user, (submission, end_at)| json.courseUser do - json.id record[0].id - json.name record[0].name - json.role record[0].role - json.isPhantom record[0].phantom? + json.id course_user.id + json.name course_user.name + json.role course_user.role + json.isPhantom course_user.phantom? end - json.workflowState record[1] - json.submittedAt record[2]&.iso8601 - json.endAt record[3]&.iso8601 - json.grade record[4] + json.workflowState submission.workflow_state + json.submittedAt submission.submitted_at&.iso8601 + json.endAt end_at&.iso8601 + json.grade submission.grade end -json.allStudents @all_students do |student| +json.allStudents @all_students.each do |student| json.id student.id json.name student.name json.role student.role diff --git a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder index c0d11f9491..a12ce5fa21 100644 --- a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder +++ b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder @@ -17,7 +17,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an if submission.workflow_state == 'published' && submission.grader_ids # the graders are all the same regardless of question, so we just pick the first one - grader = @user_id_to_course_user_hash[submission.grader_ids.first] + grader = @course_users_hash[submission.grader_ids.first] json.graderId grader&.id || 0 json.grader grader&.name || 'System' end diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/ColorGradationLevel.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/ColorGradationLevel.ts index 2673254e7b..bd2edca0d3 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/ColorGradationLevel.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/ColorGradationLevel.ts @@ -1,4 +1,4 @@ -export const lowerGradeBackgroundColorClassName = { +const lowerGradeBackgroundColorClassName = { 0: 'bg-red-50', 100: 'bg-red-100', 200: 'bg-red-200', @@ -7,7 +7,7 @@ export const lowerGradeBackgroundColorClassName = { 500: 'bg-red-500', }; -export const higherGradeBackgroundColorClassName = { +const higherGradeBackgroundColorClassName = { 0: 'bg-green-50', 100: 'bg-green-100', 200: 'bg-green-200', @@ -15,3 +15,24 @@ export const higherGradeBackgroundColorClassName = { 400: 'bg-green-400', 500: 'bg-green-500', }; + +// calculate the gradient of the color in each grade cell +// 1. we compute the distance between the grade and the mid-grade (half the maximum) +// 2. then, we compute the fraction of it -> range becomes [0,1] +// 3. then we convert it into range [0,3] so that the shades will become [100, 200, 300] +const calculateColorGradientLevel = ( + grade: number, + halfMaxGrade: number, +): number => { + return Math.round((Math.abs(grade - halfMaxGrade) / halfMaxGrade) * 5) * 100; +}; + +export const getClassNameForMarkCell = ( + grade: number, + maxGrade: number, +): string => { + const gradientLevel = calculateColorGradientLevel(grade, maxGrade / 2); + return grade >= maxGrade / 2 + ? `${higherGradeBackgroundColorClassName[gradientLevel]} p-[1rem]` + : `${lowerGradeBackgroundColorClassName[gradientLevel]} p-[1rem]`; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index d80f40e004..d34b92a499 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -15,10 +15,7 @@ import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import { - higherGradeBackgroundColorClassName, - lowerGradeBackgroundColorClassName, -} from './ColorGradationLevel'; +import { getClassNameForMarkCell } from './ColorGradationLevel'; import { getStatisticsPage } from './selectors'; interface Props { @@ -86,19 +83,9 @@ const StudentMarksPerQuestionTable: FC = (props) => { const { data } = props; const assessment = useAppSelector(getStatisticsPage).assessment; - - // calculate the gradient of the color in each grade cell - // 1. we compute the distance between the grade and the mid-grade (half the maximum) - // 2. then, we compute the fraction of it -> range becomes [0,1] - // 3. then we convert it into range [0,3] so that the shades will become [100, 200, 300] - const calculateColorGradientLevel = ( - grade: number, - halfMaxGrade: number, - ): number => { - return ( - Math.round((Math.abs(grade - halfMaxGrade) / halfMaxGrade) * 5) * 100 - ); - }; + const sortedSubmission = data.submissions.sort((datum1, datum2) => + datum1.name.localeCompare(datum2.name), + ); // the case where the grade is null is handled separately inside the column // (refer to the definition of answerColumns below) @@ -110,15 +97,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { return null; } - const gradientLevel = calculateColorGradientLevel(grade, maxGrade / 2); - let className = ''; - - if (grade >= maxGrade / 2) { - className = `${higherGradeBackgroundColorClassName[gradientLevel]} p-[1rem]`; - } else { - className = `${lowerGradeBackgroundColorClassName[gradientLevel]} p-[1rem]`; - } - + const className = getClassNameForMarkCell(grade, maxGrade); return (
{grade} @@ -174,6 +153,9 @@ const StudentMarksPerQuestionTable: FC = (props) => { }, ); + const jointGroupsName = (datum: SubmissionStats): string => + datum.groups ? datum.groups.map((g) => g.name).join(', ') : ''; + const columns: ColumnTemplate[] = [ { of: 'name', @@ -190,11 +172,9 @@ const StudentMarksPerQuestionTable: FC = (props) => { sortable: true, searchable: true, searchProps: { - getValue: (datum) => - datum.groups ? datum.groups.map((g) => g.name).join(', ') : '', + getValue: (datum) => jointGroupsName(datum), }, - cell: (datum) => - datum.groups ? datum.groups.map((g) => g.name).join(', ') : '', + cell: (datum) => jointGroupsName(datum), csvDownloadable: true, }, { @@ -265,7 +245,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { csvDownload={{ filename: t(translations.filename, { assessment: assessment?.title }), }} - data={data.submissions} + data={sortedSubmission} getRowClassName={(datum): string => `data_${datum.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` } diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts index c9f4101726..04c5460904 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts @@ -1,10 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ +// TODO: define store for statistics page (in next PR) import { AppState } from 'store'; -function getLocalState(state: AppState) { - return state.assessments.statisticsPage; -} +const getLocalState = (state: AppState) => state.assessments.statisticsPage; -export function getStatisticsPage(state: AppState) { - return getLocalState(state); -} +export const getStatisticsPage = (state: AppState) => getLocalState(state); From 086b71d9af9640dc646223e03b64c79f62599f1f Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 24 Jan 2024 10:59:55 +0800 Subject: [PATCH 08/45] remove redux usage from marks table --- .../assessments/marks_per_question.json.jbuilder | 1 + .../AssessmentStatistics/StudentMarksPerQuestionTable.tsx | 7 +++---- client/app/types/course/statistics/assessmentStatistics.ts | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder index a12ce5fa21..ef31a86fc9 100644 --- a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder +++ b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder @@ -1,6 +1,7 @@ # frozen_string_literal: true json.questionCount @question_order_hash.size json.maximumGrade @question_maximum_grade_hash.values.sum +json.assessmentTitle @assessment.title json.submissions @student_submissions_hash.each do |course_user, (submission, answers)| json.id course_user.id json.name course_user.name diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index d34b92a499..c9a89fabdb 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -12,11 +12,9 @@ import { workflowStates } from 'course/assessment/submission/constants'; import Link from 'lib/components/core/Link'; import Table, { ColumnTemplate } from 'lib/components/table'; import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; -import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import { getClassNameForMarkCell } from './ColorGradationLevel'; -import { getStatisticsPage } from './selectors'; interface Props { data: AssessmentMarksPerQuestionStats; @@ -82,7 +80,6 @@ const StudentMarksPerQuestionTable: FC = (props) => { const { courseId } = useParams(); const { data } = props; - const assessment = useAppSelector(getStatisticsPage).assessment; const sortedSubmission = data.submissions.sort((datum1, datum2) => datum1.name.localeCompare(datum2.name), ); @@ -243,7 +240,9 @@ const StudentMarksPerQuestionTable: FC = (props) => {
diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index f83ded5c95..f73c90d80a 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -67,5 +67,6 @@ export interface SubmissionStats { export interface AssessmentMarksPerQuestionStats { maximumGrade: number; questionCount: number; + assessmentTitle: string; submissions: SubmissionStats[]; } From 609b2b5a8470bfeee5a272bb5b77f9759142ba81 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 16 Jan 2024 12:54:35 +0800 Subject: [PATCH 09/45] change SubmissionDoughnut to use BarChart --- .../SubmissionBarChart.tsx | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionBarChart.tsx diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionBarChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionBarChart.tsx new file mode 100644 index 0000000000..ac5598054d --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionBarChart.tsx @@ -0,0 +1,89 @@ +import { defineMessages, FormattedMessage } from 'react-intl'; +import palette from 'theme/palette'; + +import { workflowStates } from 'course/assessment/submission/constants'; +import { SubmissionRecordShape } from 'course/assessment/types'; +import BarChart from 'lib/components/core/BarChart'; + +const translations = defineMessages({ + datasetLabel: { + id: 'course.assessment.statistics.SubmissionBarChart.datasetLabel', + defaultMessage: 'Student Submission Statuses', + }, + published: { + id: 'course.assessment.statistics.SubmissionBarChart.published', + defaultMessage: 'Graded', + }, + graded: { + id: 'course.assessment.statistics.SubmissionBarChart.graded', + defaultMessage: 'Graded, unpublished', + }, + submitted: { + id: 'course.assessment.statistics.SubmissionBarChart.submitted', + defaultMessage: 'Submitted', + }, + attempting: { + id: 'course.assessment.statistics.SubmissionBarChart.attempting', + defaultMessage: 'Attempting', + }, + unattempted: { + id: 'course.assessment.statistics.SubmissionBarChart.unattempted', + defaultMessage: 'Not Started', + }, +}); + +interface Props { + submissions: SubmissionRecordShape[]; + numStudents: number; +} + +// need to refactor!! +const SubmissionBarChart = (props: Props): JSX.Element => { + const { submissions, numStudents } = props; + + const numUnstarted = numStudents - submissions.length; + const numAttempting = submissions.filter( + (s) => s.workflowState === workflowStates.Attempting, + ).length; + const numSubmitted = submissions.filter( + (s) => s.workflowState === workflowStates.Submitted, + ).length; + const numGraded = submissions.filter( + (s) => s.workflowState === workflowStates.Graded, + ).length; + const numPublished = submissions.filter( + (s) => s.workflowState === workflowStates.Published, + ).length; + + const data = [ + { + color: palette.submissionStatus[workflowStates.Unstarted], + count: numUnstarted, + label: , + }, + { + color: palette.submissionStatus[workflowStates.Attempting], + count: numAttempting, + label: , + }, + { + color: palette.submissionStatus[workflowStates.Submitted], + count: numSubmitted, + label: , + }, + { + color: palette.submissionStatus[workflowStates.Graded], + count: numGraded, + label: , + }, + { + color: palette.submissionStatus[workflowStates.Published], + count: numPublished, + label: , + }, + ]; + + return ; +}; + +export default SubmissionBarChart; From 069d90064ec4d62f02cd8a5e3e3514465b63afb4 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Sat, 20 Jan 2024 00:28:58 +0800 Subject: [PATCH 10/45] refactor charts and ancestors - all codes into typescript - ancestors in chronological order --- .../statistics/assessments_controller.rb | 2 +- .../AssessmentStatistics/AncestorOptions.jsx | 98 ------------------- .../AssessmentStatistics/AncestorOptions.tsx | 85 ++++++++++++++++ .../AssessmentStatistics/AncestorSelect.tsx | 2 +- .../GradeDistributionChart.tsx | 69 +++++++++++++ .../AssessmentStatistics/GradeViolinChart.jsx | 86 ---------------- .../AssessmentStatistics/StatisticsCharts.jsx | 81 --------------- .../AssessmentStatistics/StatisticsCharts.tsx | 73 ++++++++++++++ ...rt.jsx => SubmissionTimeAndGradeChart.tsx} | 55 +++++------ client/app/bundles/course/assessment/types.ts | 2 +- 10 files changed, 254 insertions(+), 299 deletions(-) delete mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.jsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx delete mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeViolinChart.jsx delete mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.jsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx rename client/app/bundles/course/assessment/pages/AssessmentStatistics/{SubmissionTimeAndGradeChart.jsx => SubmissionTimeAndGradeChart.tsx} (64%) diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 5830e63d6a..2938afa6ed 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -27,7 +27,7 @@ def ancestors @assessment = @assessment.duplication_traceable.source break unless can?(:read_ancestor, @assessment) - @assessments << @assessment + @assessments.unshift(@assessment) end end diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.jsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.jsx deleted file mode 100644 index 92761ea964..0000000000 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Fragment } from 'react'; -import { injectIntl } from 'react-intl'; -import ArrowBack from '@mui/icons-material/ArrowBack'; -import { Card, CardContent, Chip, Typography } from '@mui/material'; -import PropTypes from 'prop-types'; - -import { ancestorShape } from '../../propTypes'; - -const translations = { - title: { - id: 'course.assessment.statistics.ancestorSelect.title', - defaultMessage: 'Duplication History', - }, - subtitle: { - id: 'course.assessment.statistics.ancestorSelect.subtitle', - defaultMessage: 'Compare against past versions of this assessment:', - }, - current: { - id: 'course.assessment.statistics.ancestorSelect.current', - defaultMessage: 'Current', - }, - fromCourse: { - id: 'course.assessment.statistics.ancestorSelect.fromCourse', - defaultMessage: 'From {courseTitle}', - }, -}; - -const AncestorOptions = ({ - assessmentId, - ancestors, - selectedAncestorId, - setSelectedAncestorId, - intl, -}) => ( -
- - {intl.formatMessage(translations.title)} - - - {intl.formatMessage(translations.subtitle)} - -
- {ancestors.map((ancestor, index) => ( - - setSelectedAncestorId(ancestor.id)} - > - - - {ancestor.title} - - - {intl.formatMessage(translations.fromCourse, { - courseTitle: ancestor.courseTitle, - })} - - {ancestor.id === assessmentId ? ( - - ) : null} - - - {index !== ancestors.length - 1 && } - - ))} -
-
-); - -AncestorOptions.propTypes = { - assessmentId: PropTypes.number.isRequired, - ancestors: PropTypes.arrayOf(ancestorShape).isRequired, - selectedAncestorId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - setSelectedAncestorId: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, -}; - -export default injectIntl(AncestorOptions); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx new file mode 100644 index 0000000000..e451961ac8 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx @@ -0,0 +1,85 @@ +import { FC, Fragment } from 'react'; +import { defineMessages } from 'react-intl'; +import { ArrowForward } from '@mui/icons-material'; +import { Card, CardContent, Chip, Typography } from '@mui/material'; + +import { AncestorShape } from 'course/assessment/types'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + title: { + id: 'course.assessment.statistics.ancestorSelect.title', + defaultMessage: 'Duplication History', + }, + subtitle: { + id: 'course.assessment.statistics.ancestorSelect.subtitle', + defaultMessage: 'Compare against past versions of this assessment:', + }, + current: { + id: 'course.assessment.statistics.ancestorSelect.current', + defaultMessage: 'Current', + }, + fromCourse: { + id: 'course.assessment.statistics.ancestorSelect.fromCourse', + defaultMessage: 'From {courseTitle}', + }, +}); + +interface Props { + assessmentId: number; + ancestors: AncestorShape[]; + selectedAncestorId: number; + fetchAncestorSubmissions: (id: number) => void; +} + +const AncestorOptions: FC = (props) => { + const { t } = useTranslation(); + const { + assessmentId, + ancestors, + selectedAncestorId, + fetchAncestorSubmissions, + } = props; + + return ( +
+ + {t(translations.title)} + + + {t(translations.subtitle)} + +
+ {ancestors.map((ancestor, index) => ( + + fetchAncestorSubmissions(ancestor.id)} + > + + + {ancestor.title} + + + {t(translations.fromCourse, { + courseTitle: ancestor.courseTitle, + })} + + {ancestor.id === assessmentId ? ( + + ) : null} + + + {index !== ancestors.length - 1 && } + + ))} +
+
+ ); +}; + +export default AncestorOptions; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx index ae4d5217e9..fd16258153 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx @@ -45,8 +45,8 @@ const AncestorSelect = (props: AncestorSelectProps): JSX.Element => { ); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx new file mode 100644 index 0000000000..f91af56545 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx @@ -0,0 +1,69 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { GREEN_CHART_BACKGROUND, GREEN_CHART_BORDER } from 'theme/colors'; + +import { SubmissionRecordShape } from 'course/assessment/types'; +import ViolinChart from 'lib/components/core/charts/ViolinChart'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + yAxisLabel: { + id: 'course.assessment.statistics.gradeViolin.yAxisLabel', + defaultMessage: 'Submissions', + }, + xAxisLabel: { + id: 'course.assessment.statistics.gradeViolin.xAxisLabel', + defaultMessage: 'Grades', + }, + datasetLabel: { + id: 'course.assessment.statistics.gradeViolin.datasetLabel', + defaultMessage: 'Distribution', + }, +}); + +interface Props { + submissions: SubmissionRecordShape[]; +} + +const GradeDistributionChart: FC = (props) => { + const { t } = useTranslation(); + const { submissions } = props; + + const grades = submissions?.filter((s) => s.grade)?.map((s) => s.grade) ?? []; + const data = { + labels: [t(translations.yAxisLabel)], + datasets: [ + { + label: t(translations.datasetLabel), + backgroundColor: GREEN_CHART_BACKGROUND, + borderColor: GREEN_CHART_BORDER, + borderWidth: 1, + data: [grades], + }, + ], + }; + + const options = { + indexAxis: 'y', + responsive: true, + legend: { + position: 'top', + }, + scales: { + x: { + title: { + display: true, + text: t(translations.xAxisLabel), + }, + }, + }, + }; + + return ( +
+ +
+ ); +}; + +export default GradeDistributionChart; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeViolinChart.jsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeViolinChart.jsx deleted file mode 100644 index 5460d2708b..0000000000 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeViolinChart.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useMemo } from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import PropTypes from 'prop-types'; -import { PURPLE_CHART_BACKGROUND, PURPLE_CHART_BORDER } from 'theme/colors'; - -import ViolinChart from 'lib/components/core/charts/ViolinChart'; - -import { submissionRecordsShape } from '../../propTypes'; - -const translations = defineMessages({ - yAxisLabel: { - id: 'course.assessment.statistics.gradeViolin.yAxisLabel', - defaultMessage: 'Submissions', - }, - xAxisLabel: { - id: 'course.assessment.statistics.gradeViolin.xAxisLabel', - defaultMessage: 'Grades', - }, - datasetLabel: { - id: 'course.assessment.statistics.gradeViolin.datasetLabel', - defaultMessage: 'Distribution', - }, -}); - -const styles = { - root: { - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - }, -}; - -const DEFAULT_OPTIONS = { - indexAxis: 'y', - responsive: true, - legend: { - position: 'top', - }, -}; - -const GradeViolinChart = ({ submissions, intl }) => { - const grades = - submissions?.filter((s) => s.grade != null)?.map((s) => s.grade) ?? []; - - const data = { - labels: [intl.formatMessage(translations.yAxisLabel)], - datasets: [ - { - label: intl.formatMessage(translations.datasetLabel), - backgroundColor: PURPLE_CHART_BACKGROUND, - borderColor: PURPLE_CHART_BORDER, - borderWidth: 1, - data: [grades], - }, - ], - }; - - const options = useMemo( - () => ({ - ...DEFAULT_OPTIONS, - scales: { - x: { - title: { - display: true, - text: intl.formatMessage(translations.xAxisLabel), - }, - }, - }, - }), - [intl], - ); - - return ( -
- -
- ); -}; - -GradeViolinChart.propTypes = { - submissions: PropTypes.arrayOf(submissionRecordsShape).isRequired, - intl: PropTypes.object.isRequired, -}; - -export default injectIntl(GradeViolinChart); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.jsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.jsx deleted file mode 100644 index 7e8e7e6732..0000000000 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import { defineMessages, injectIntl } from 'react-intl'; -import { Card, CardContent, Typography } from '@mui/material'; -import PropTypes from 'prop-types'; - -import { courseUserShape, submissionRecordsShape } from '../../propTypes'; - -import GradeViolinChart from './GradeViolinChart'; -import SubmissionStatusChart from './SubmissionStatusChart'; -import SubmissionTimeAndGradeChart from './SubmissionTimeAndGradeChart'; - -const translations = defineMessages({ - submissionStatuses: { - id: 'course.assessment.statistics.submissionStatuses', - defaultMessage: 'Submission Statuses', - }, - gradeDistribution: { - id: 'course.assessment.statistics.gradeDistribution', - defaultMessage: 'Grade Distribution', - }, - submissionTimeAndGrade: { - id: 'course.assessment.statistics.submissionTimeAndGrade', - defaultMessage: 'Submission Time and Grade', - }, -}); - -const CardTitle = ({ children }) => ( - - {children} - -); - -CardTitle.propTypes = { - children: PropTypes.element.isRequired, -}; - -const StatisticsCharts = ({ submissions, allStudents, intl }) => ( -
- - - - {intl.formatMessage(translations.submissionStatuses)} - - - - - - - - {intl.formatMessage(translations.gradeDistribution)} - - - - - - - - {intl.formatMessage(translations.submissionTimeAndGrade)} - - - - - {/* TODO: Add section on hardest questions */} -
-); - -StatisticsCharts.propTypes = { - submissions: PropTypes.arrayOf(submissionRecordsShape).isRequired, - allStudents: PropTypes.arrayOf(courseUserShape).isRequired, - intl: PropTypes.object.isRequired, -}; - -export default injectIntl(StatisticsCharts); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx new file mode 100644 index 0000000000..1536f1c1b3 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx @@ -0,0 +1,73 @@ +import { FC, ReactNode } from 'react'; +import { defineMessages } from 'react-intl'; +import { Card, CardContent, Typography } from '@mui/material'; + +import { + CourseUserShape, + SubmissionRecordShape, +} from 'course/assessment/types'; +import useTranslation from 'lib/hooks/useTranslation'; + +import GradeDistributionChart from './GradeDistributionChart'; +import SubmissionBarChart from './SubmissionBarChart'; +import SubmissionTimeAndGradeChart from './SubmissionTimeAndGradeChart'; + +const translations = defineMessages({ + submissionStatuses: { + id: 'course.assessment.statistics.submissionStatuses', + defaultMessage: 'Submission Statuses', + }, + gradeDistribution: { + id: 'course.assessment.statistics.gradeDistribution', + defaultMessage: 'Grade Distribution', + }, + submissionTimeAndGrade: { + id: 'course.assessment.statistics.submissionTimeAndGrade', + defaultMessage: 'Submission Time and Grade', + }, +}); + +interface Props { + submissions: SubmissionRecordShape[]; + allStudents: CourseUserShape[]; +} + +const CardTitle: FC<{ children: ReactNode }> = ({ children }) => ( + + {children} + +); + +const StatisticsCharts: FC = (props) => { + const { t } = useTranslation(); + const { submissions, allStudents } = props; + + return ( +
+ + + {t(translations.submissionStatuses)} + + + + + + {t(translations.gradeDistribution)} + + + + + + {t(translations.submissionTimeAndGrade)} + + + + {/* TODO: Add section on hardest questions */} +
+ ); +}; + +export default StatisticsCharts; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.jsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx similarity index 64% rename from client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.jsx rename to client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx index 3247380b72..04c636359a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.jsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx @@ -1,5 +1,5 @@ -import { defineMessages, injectIntl } from 'react-intl'; -import PropTypes from 'prop-types'; +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; import { BLUE_CHART_BACKGROUND, BLUE_CHART_BORDER, @@ -7,9 +7,9 @@ import { ORANGE_CHART_BORDER, } from 'theme/colors'; +import { SubmissionRecordShape } from 'course/assessment/types'; import GeneralChart from 'lib/components/core/charts/GeneralChart'; - -import { submissionRecordsShape } from '../../propTypes'; +import useTranslation from 'lib/hooks/useTranslation'; import { processSubmissionsIntoChartData } from './utils'; @@ -32,15 +32,13 @@ const translations = defineMessages({ }, }); -const styles = { - root: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - }, -}; +interface Props { + submissions: SubmissionRecordShape[]; +} -const SubmissionTimeAndGradeChart = ({ submissions, intl }) => { +const SubmissionTimeAndGradeChart: FC = (props) => { + const { t } = useTranslation(); + const { submissions } = props; const { labels, lineData, barData } = processSubmissionsIntoChartData(submissions); @@ -48,8 +46,8 @@ const SubmissionTimeAndGradeChart = ({ submissions, intl }) => { labels, datasets: [ { - type: 'line', - label: intl.formatMessage(translations.lineDatasetLabel), + type: 'line' as const, + label: t(translations.lineDatasetLabel), backgroundColor: ORANGE_CHART_BACKGROUND, borderColor: ORANGE_CHART_BORDER, borderWidth: 2, @@ -58,8 +56,8 @@ const SubmissionTimeAndGradeChart = ({ submissions, intl }) => { yAxisID: 'A', }, { - type: 'bar', - label: intl.formatMessage(translations.barDatasetLabel), + type: 'bar' as const, + label: t(translations.barDatasetLabel), backgroundColor: BLUE_CHART_BACKGROUND, borderColor: BLUE_CHART_BORDER, borderWidth: 1, @@ -74,20 +72,20 @@ const SubmissionTimeAndGradeChart = ({ submissions, intl }) => { const options = { scales: { A: { - type: 'linear', - position: 'right', + type: 'linear' as const, + position: 'right' as const, title: { display: true, - text: intl.formatMessage(translations.lineDatasetLabel), + text: t(translations.lineDatasetLabel), color: ORANGE_CHART_BORDER, }, }, B: { - type: 'linear', - position: 'left', + type: 'linear' as const, + position: 'left' as const, title: { display: true, - text: intl.formatMessage(translations.barDatasetLabel), + text: t(translations.barDatasetLabel), color: BLUE_CHART_BORDER, }, }, @@ -95,23 +93,18 @@ const SubmissionTimeAndGradeChart = ({ submissions, intl }) => { title: { display: true, text: hasEndAt - ? intl.formatMessage(translations.xAxisLabelWithDeadline) - : intl.formatMessage(translations.xAxisLabelWithoutDeadline), + ? t(translations.xAxisLabelWithDeadline) + : t(translations.xAxisLabelWithoutDeadline), }, }, }, }; return ( -
+
); }; -SubmissionTimeAndGradeChart.propTypes = { - submissions: PropTypes.arrayOf(submissionRecordsShape).isRequired, - intl: PropTypes.object.isRequired, -}; - -export default injectIntl(SubmissionTimeAndGradeChart); +export default SubmissionTimeAndGradeChart; diff --git a/client/app/bundles/course/assessment/types.ts b/client/app/bundles/course/assessment/types.ts index 58a7ceaba7..e9027a89ae 100644 --- a/client/app/bundles/course/assessment/types.ts +++ b/client/app/bundles/course/assessment/types.ts @@ -18,7 +18,7 @@ export interface SubmissionRecordShape { } export interface AncestorShape { - id: number | string; + id: number; title: string; courseTitle: string; } From 997574c105407a8681c8e1c7931a5fd743a37362 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Sun, 21 Jan 2024 00:46:40 +0800 Subject: [PATCH 11/45] refactor assessment statistics page - split all chart components into different tabs - Change tab names to be more intuitive --- .../AssessmentStatistics/AncestorOptions.tsx | 64 ++++++++----------- ...l.tsx => DuplicationHistoryStatistics.tsx} | 9 +-- .../pages/AssessmentStatistics/index.tsx | 59 ++++++++++++++--- 3 files changed, 81 insertions(+), 51 deletions(-) rename client/app/bundles/course/assessment/pages/AssessmentStatistics/{StatisticsChartsPanel.tsx => DuplicationHistoryStatistics.tsx} (92%) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx index e451961ac8..b847cc6727 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx @@ -42,42 +42,34 @@ const AncestorOptions: FC = (props) => { } = props; return ( -
- - {t(translations.title)} - - - {t(translations.subtitle)} - -
- {ancestors.map((ancestor, index) => ( - - fetchAncestorSubmissions(ancestor.id)} - > - - - {ancestor.title} - - - {t(translations.fromCourse, { - courseTitle: ancestor.courseTitle, - })} - - {ancestor.id === assessmentId ? ( - - ) : null} - - - {index !== ancestors.length - 1 && } - - ))} -
+
+ {ancestors.map((ancestor, index) => ( + + fetchAncestorSubmissions(ancestor.id)} + > + + + {ancestor.title} + + + {t(translations.fromCourse, { + courseTitle: ancestor.courseTitle, + })} + + {ancestor.id === assessmentId ? ( + + ) : null} + + + {index !== ancestors.length - 1 && } + + ))}
); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsChartsPanel.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx similarity index 92% rename from client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsChartsPanel.tsx rename to client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx index 1d9eeedf06..eaa60e6225 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsChartsPanel.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx @@ -15,7 +15,6 @@ import useTranslation from 'lib/hooks/useTranslation'; import AncestorSelect from './AncestorSelect'; import AncestorStatistics from './AncestorStatistics'; import { getStatisticsPage } from './selectors'; -import StatisticsCharts from './StatisticsCharts'; const translations = defineMessages({ fetchFailure: { @@ -32,7 +31,7 @@ const translations = defineMessages({ }, }); -const StatisticsChartsPanel: FC = () => { +const DuplicationHistoryStatistics: FC = () => { const { t } = useTranslation(); const { assessmentId } = useParams(); const dispatch = useAppDispatch(); @@ -84,10 +83,6 @@ const StatisticsChartsPanel: FC = () => { return ( <> - { ); }; -export default StatisticsChartsPanel; +export default DuplicationHistoryStatistics; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx index 23269f1592..5b91128bda 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx @@ -5,13 +5,17 @@ import { Box, Tab, Tabs } from '@mui/material'; import { tabsStyle } from 'theme/mui-style'; import { fetchStatistics } from 'course/assessment/operations'; +import { SubmissionRecordShape } from 'course/assessment/types'; import Page from 'lib/components/core/layouts/Page'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; +import DuplicationHistoryStatistics from './DuplicationHistoryStatistics'; +import GradeDistributionChart from './GradeDistributionChart'; import { getStatisticsPage } from './selectors'; -import StatisticsChartsPanel from './StatisticsChartsPanel'; import StatisticsTablePanel from './StatisticsTablePanel'; +import SubmissionBarChart from './SubmissionBarChart'; +import SubmissionTimeAndGradeChart from './SubmissionTimeAndGradeChart'; const translations = defineMessages({ statistics: { @@ -34,14 +38,26 @@ const translations = defineMessages({ id: 'course.assessment.statistics.ancestorStatisticsFail', defaultMessage: "Failed to fetch ancestor's statistics.", }, - chart: { - id: 'course.assessment.statistics.chart', - defaultMessage: 'Chart', + duplicationHistory: { + id: 'course.assessment.statistics.duplicationHistory', + defaultMessage: 'Duplication History', }, table: { id: 'course.assessment.statistics.table', defaultMessage: 'Table', }, + submissionStatus: { + id: 'course.assessment.statistics.submissionStatus', + defaultMessage: 'Submission Status', + }, + gradeDistribution: { + id: 'course.assessment.statistics.gradeDistribution', + defaultMessage: 'Grade Distribution', + }, + submissionTimeAndGrade: { + id: 'course.assessment.statistics.submissionTimeAndGrade', + defaultMessage: 'Submission Time and Grade', + }, }); const AssessmentStatisticsPage: FC = () => { @@ -52,6 +68,8 @@ const AssessmentStatisticsPage: FC = () => { const dispatch = useAppDispatch(); const statisticsPage = useAppSelector(getStatisticsPage); + const submissions = statisticsPage.submissions as SubmissionRecordShape[]; + const numStudents = statisticsPage.allStudents.length; useEffect(() => { if (assessmentId) { @@ -62,8 +80,15 @@ const AssessmentStatisticsPage: FC = () => { }, [assessmentId]); const tabComponentMapping = { - chart: , table: , + duplicationHistory: , + submissionStatus: ( + + ), + gradeDistribution: , + submissionTimeAndGrade: ( + + ), }; return ( @@ -94,9 +119,27 @@ const AssessmentStatisticsPage: FC = () => { /> + + + From 603163aaa48254521a5f5cd43ed6cc30dc04a6bb Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 24 Jan 2024 16:26:49 +0800 Subject: [PATCH 12/45] improve assessment statistics page UI - add toggle for phantom students inclusion setting - display submission status chart on top instead of separate tabs --- .../AssessmentStatistics/StatisticsCharts.tsx | 4 +- ...el.tsx => StudentMarksPerQuestionPage.tsx} | 18 +++- .../SubmissionBarChart.tsx | 89 --------------- .../pages/AssessmentStatistics/index.tsx | 101 +++++++++++------- 4 files changed, 80 insertions(+), 132 deletions(-) rename client/app/bundles/course/assessment/pages/AssessmentStatistics/{StatisticsTablePanel.tsx => StudentMarksPerQuestionPage.tsx} (59%) delete mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionBarChart.tsx diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx index 1536f1c1b3..25d87e9166 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx @@ -9,7 +9,7 @@ import { import useTranslation from 'lib/hooks/useTranslation'; import GradeDistributionChart from './GradeDistributionChart'; -import SubmissionBarChart from './SubmissionBarChart'; +import SubmissionStatusChart from './SubmissionStatusChart'; import SubmissionTimeAndGradeChart from './SubmissionTimeAndGradeChart'; const translations = defineMessages({ @@ -47,7 +47,7 @@ const StatisticsCharts: FC = (props) => { {t(translations.submissionStatuses)} - diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsTablePanel.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx similarity index 59% rename from client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsTablePanel.tsx rename to client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx index 9630c83cff..3cb8aa22b5 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsTablePanel.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx @@ -8,7 +8,12 @@ import Preload from 'lib/components/wrappers/Preload'; import StudentMarksPerQuestionTable from './StudentMarksPerQuestionTable'; -const StatisticsTablePanel: FC = () => { +interface Props { + includePhantom: boolean; +} + +const StudentMarksPerQuestionPage: FC = (props) => { + const { includePhantom } = props; const { assessmentId } = useParams(); const fetchStudentMarks = (): Promise => @@ -17,10 +22,17 @@ const StatisticsTablePanel: FC = () => { return ( } while={fetchStudentMarks}> {(data): JSX.Element => { - return ; + const noPhantomStudentSubmissionsData = { + ...data, + submissions: data.submissions.filter((datum) => !datum.isPhantom), + }; + const displayedData = includePhantom + ? data + : noPhantomStudentSubmissionsData; + return ; }} ); }; -export default StatisticsTablePanel; +export default StudentMarksPerQuestionPage; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionBarChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionBarChart.tsx deleted file mode 100644 index ac5598054d..0000000000 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionBarChart.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { defineMessages, FormattedMessage } from 'react-intl'; -import palette from 'theme/palette'; - -import { workflowStates } from 'course/assessment/submission/constants'; -import { SubmissionRecordShape } from 'course/assessment/types'; -import BarChart from 'lib/components/core/BarChart'; - -const translations = defineMessages({ - datasetLabel: { - id: 'course.assessment.statistics.SubmissionBarChart.datasetLabel', - defaultMessage: 'Student Submission Statuses', - }, - published: { - id: 'course.assessment.statistics.SubmissionBarChart.published', - defaultMessage: 'Graded', - }, - graded: { - id: 'course.assessment.statistics.SubmissionBarChart.graded', - defaultMessage: 'Graded, unpublished', - }, - submitted: { - id: 'course.assessment.statistics.SubmissionBarChart.submitted', - defaultMessage: 'Submitted', - }, - attempting: { - id: 'course.assessment.statistics.SubmissionBarChart.attempting', - defaultMessage: 'Attempting', - }, - unattempted: { - id: 'course.assessment.statistics.SubmissionBarChart.unattempted', - defaultMessage: 'Not Started', - }, -}); - -interface Props { - submissions: SubmissionRecordShape[]; - numStudents: number; -} - -// need to refactor!! -const SubmissionBarChart = (props: Props): JSX.Element => { - const { submissions, numStudents } = props; - - const numUnstarted = numStudents - submissions.length; - const numAttempting = submissions.filter( - (s) => s.workflowState === workflowStates.Attempting, - ).length; - const numSubmitted = submissions.filter( - (s) => s.workflowState === workflowStates.Submitted, - ).length; - const numGraded = submissions.filter( - (s) => s.workflowState === workflowStates.Graded, - ).length; - const numPublished = submissions.filter( - (s) => s.workflowState === workflowStates.Published, - ).length; - - const data = [ - { - color: palette.submissionStatus[workflowStates.Unstarted], - count: numUnstarted, - label: , - }, - { - color: palette.submissionStatus[workflowStates.Attempting], - count: numAttempting, - label: , - }, - { - color: palette.submissionStatus[workflowStates.Submitted], - count: numSubmitted, - label: , - }, - { - color: palette.submissionStatus[workflowStates.Graded], - count: numGraded, - label: , - }, - { - color: palette.submissionStatus[workflowStates.Published], - count: numPublished, - label: , - }, - ]; - - return ; -}; - -export default SubmissionBarChart; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx index 5b91128bda..c3f499e6cc 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx @@ -1,8 +1,7 @@ import { FC, useEffect, useState } from 'react'; -import { defineMessages } from 'react-intl'; +import { defineMessages, FormattedMessage } from 'react-intl'; import { useParams } from 'react-router-dom'; -import { Box, Tab, Tabs } from '@mui/material'; -import { tabsStyle } from 'theme/mui-style'; +import { Box, FormControlLabel, Switch, Tab, Tabs } from '@mui/material'; import { fetchStatistics } from 'course/assessment/operations'; import { SubmissionRecordShape } from 'course/assessment/types'; @@ -13,8 +12,8 @@ import useTranslation from 'lib/hooks/useTranslation'; import DuplicationHistoryStatistics from './DuplicationHistoryStatistics'; import GradeDistributionChart from './GradeDistributionChart'; import { getStatisticsPage } from './selectors'; -import StatisticsTablePanel from './StatisticsTablePanel'; -import SubmissionBarChart from './SubmissionBarChart'; +import StudentMarksPerQuestionPage from './StudentMarksPerQuestionPage'; +import SubmissionStatusChart from './SubmissionStatusChart'; import SubmissionTimeAndGradeChart from './SubmissionTimeAndGradeChart'; const translations = defineMessages({ @@ -42,13 +41,9 @@ const translations = defineMessages({ id: 'course.assessment.statistics.duplicationHistory', defaultMessage: 'Duplication History', }, - table: { - id: 'course.assessment.statistics.table', - defaultMessage: 'Table', - }, - submissionStatus: { - id: 'course.assessment.statistics.submissionStatus', - defaultMessage: 'Submission Status', + marksPerQuestion: { + id: 'course.assessment.statistics.marksPerQuestion', + defaultMessage: 'Marks Per Question', }, gradeDistribution: { id: 'course.assessment.statistics.gradeDistribution', @@ -58,18 +53,25 @@ const translations = defineMessages({ id: 'course.assessment.statistics.submissionTimeAndGrade', defaultMessage: 'Submission Time and Grade', }, + includePhantom: { + id: 'course.assessment.statistics.includePhantom', + defaultMessage: 'Include Phantom Student', + }, }); const AssessmentStatisticsPage: FC = () => { const { t } = useTranslation(); - const [tabValue, setTabValue] = useState('table'); + const [tabValue, setTabValue] = useState('marksPerQuestion'); + const [includePhantom, setIncludePhantom] = useState(false); + const { assessmentId } = useParams(); const parsedAssessmentId = parseInt(assessmentId!, 10); const dispatch = useAppDispatch(); const statisticsPage = useAppSelector(getStatisticsPage); - const submissions = statisticsPage.submissions as SubmissionRecordShape[]; - const numStudents = statisticsPage.allStudents.length; + const numStudents = includePhantom + ? statisticsPage.allStudents.length + : statisticsPage.allStudents.filter((student) => !student.isPhantom).length; useEffect(() => { if (assessmentId) { @@ -79,16 +81,25 @@ const AssessmentStatisticsPage: FC = () => { } }, [assessmentId]); + const submissions = statisticsPage.submissions as SubmissionRecordShape[]; + const noPhantomStudentSubmissions = submissions.filter( + (submission) => !submission.courseUser.isPhantom, + ); + const displayedSubmissions = includePhantom + ? submissions + : noPhantomStudentSubmissions; + const tabComponentMapping = { - table: , - duplicationHistory: , - submissionStatus: ( - + marksPerQuestion: ( + + ), + gradeDistribution: ( + ), - gradeDistribution: , submissionTimeAndGrade: ( - + ), + duplicationHistory: , }; return ( @@ -101,33 +112,41 @@ const AssessmentStatisticsPage: FC = () => { > <> + + setIncludePhantom(!includePhantom)} + /> + } + label={ + + + + } + labelPlacement="end" + /> { setTabValue(value); }} scrollButtons="auto" - sx={tabsStyle} - TabIndicatorProps={{ color: 'primary', style: { height: 5 } }} value={tabValue} variant="scrollable" > - - { label={t(translations.submissionTimeAndGrade)} value="submissionTimeAndGrade" /> + From bdebbddcff907c795f4260b9bdfbe731a886a272 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 24 Jan 2024 17:37:32 +0800 Subject: [PATCH 13/45] refactor types - change grade to totalGrade for submission - group courseUser and grader information - simplify type and interface definition --- .../assessments/assessment.json.jbuilder | 2 +- .../marks_per_question.json.jbuilder | 16 ++++--- .../GradeDistributionChart.tsx | 5 ++- .../StudentMarksPerQuestionPage.tsx | 4 +- .../StudentMarksPerQuestionTable.tsx | 42 ++++++++++--------- .../pages/AssessmentStatistics/utils.js | 4 +- .../bundles/course/assessment/propTypes.js | 38 ----------------- client/app/bundles/course/assessment/types.ts | 7 +--- .../assessment/utils/statisticsUtils.js | 10 ++--- .../course/statistics/assessmentStatistics.ts | 40 +++++++----------- 10 files changed, 65 insertions(+), 103 deletions(-) delete mode 100644 client/app/bundles/course/assessment/propTypes.js diff --git a/app/views/course/statistics/assessments/assessment.json.jbuilder b/app/views/course/statistics/assessments/assessment.json.jbuilder index 3430c71c6c..27ed22ba30 100644 --- a/app/views/course/statistics/assessments/assessment.json.jbuilder +++ b/app/views/course/statistics/assessments/assessment.json.jbuilder @@ -19,7 +19,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, en json.workflowState submission.workflow_state json.submittedAt submission.submitted_at&.iso8601 json.endAt end_at&.iso8601 - json.grade submission.grade + json.totalGrade submission.grade end json.allStudents @all_students.each do |student| diff --git a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder index ef31a86fc9..791c5a8883 100644 --- a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder +++ b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder @@ -3,10 +3,12 @@ json.questionCount @question_order_hash.size json.maximumGrade @question_maximum_grade_hash.values.sum json.assessmentTitle @assessment.title json.submissions @student_submissions_hash.each do |course_user, (submission, answers)| - json.id course_user.id - json.name course_user.name - json.role course_user.role - json.isPhantom course_user.phantom? + json.courseUser do + json.id course_user.id + json.name course_user.name + json.role course_user.role + json.isPhantom course_user.phantom? + end json.groups course_user.groups do |group| json.name group.name @@ -19,8 +21,10 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an if submission.workflow_state == 'published' && submission.grader_ids # the graders are all the same regardless of question, so we just pick the first one grader = @course_users_hash[submission.grader_ids.first] - json.graderId grader&.id || 0 - json.grader grader&.name || 'System' + json.grader do + json.id grader&.id || 0 + json.name grader&.name || 'System' + end end json.answers answers.each do |answer| diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx index f91af56545..1101e9244d 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx @@ -29,7 +29,8 @@ const GradeDistributionChart: FC = (props) => { const { t } = useTranslation(); const { submissions } = props; - const grades = submissions?.filter((s) => s.grade)?.map((s) => s.grade) ?? []; + const totalGrades = + submissions?.filter((s) => s.totalGrade)?.map((s) => s.totalGrade) ?? []; const data = { labels: [t(translations.yAxisLabel)], datasets: [ @@ -38,7 +39,7 @@ const GradeDistributionChart: FC = (props) => { backgroundColor: GREEN_CHART_BACKGROUND, borderColor: GREEN_CHART_BORDER, borderWidth: 1, - data: [grades], + data: [totalGrades], }, ], }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx index 3cb8aa22b5..83dce96881 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx @@ -24,7 +24,9 @@ const StudentMarksPerQuestionPage: FC = (props) => { {(data): JSX.Element => { const noPhantomStudentSubmissionsData = { ...data, - submissions: data.submissions.filter((datum) => !datum.isPhantom), + submissions: data.submissions.filter( + (datum) => !datum.courseUser.isPhantom, + ), }; const displayedData = includePhantom ? data diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index c9a89fabdb..2c10c7a62a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -5,7 +5,7 @@ import { Box, Chip } from '@mui/material'; import palette from 'theme/palette'; import { AssessmentMarksPerQuestionStats, - SubmissionStats, + SubmissionMarksPerQuestionStats, } from 'types/course/statistics/assessmentStatistics'; import { workflowStates } from 'course/assessment/submission/constants'; @@ -81,7 +81,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { const { data } = props; const sortedSubmission = data.submissions.sort((datum1, datum2) => - datum1.name.localeCompare(datum2.name), + datum1.courseUser.name.localeCompare(datum2.courseUser.name), ); // the case where the grade is null is handled separately inside the column @@ -119,9 +119,8 @@ const StudentMarksPerQuestionTable: FC = (props) => { return grade1 - grade2; }; - const answerColumns: ColumnTemplate[] = Array.from( - { length: data.questionCount }, - (_, index) => { + const answerColumns: ColumnTemplate[] = + Array.from({ length: data.questionCount }, (_, index) => { return { searchProps: { getValue: (datum) => datum.answers?.[index]?.grade?.toString() ?? '', @@ -147,19 +146,22 @@ const StudentMarksPerQuestionTable: FC = (props) => { }, }, }; - }, - ); + }); - const jointGroupsName = (datum: SubmissionStats): string => + const jointGroupsName = (datum: SubmissionMarksPerQuestionStats): string => datum.groups ? datum.groups.map((g) => g.name).join(', ') : ''; - const columns: ColumnTemplate[] = [ + const columns: ColumnTemplate[] = [ { - of: 'name', + searchProps: { + getValue: (datum) => datum.courseUser.name, + }, title: t(translations.name), sortable: true, cell: (datum) => ( - {datum.name} + + {datum.courseUser.name} + ), csvDownloadable: true, }, @@ -218,19 +220,21 @@ const StudentMarksPerQuestionTable: FC = (props) => { csvDownloadable: true, }, { - of: 'grader', + searchProps: { + getValue: (datum) => datum.grader?.name ?? '', + }, title: t(translations.grader), sortable: true, searchable: true, cell: (datum): JSX.Element | string => { - if (datum.grader && datum.graderId !== 0) { + if (datum.grader && datum.grader.id !== 0) { return ( - - {datum.grader} + + {datum.grader.name} ); } - return datum.grader ?? ''; + return datum.grader?.name ?? ''; }, csvDownloadable: true, }, @@ -246,10 +250,10 @@ const StudentMarksPerQuestionTable: FC = (props) => { }} data={sortedSubmission} getRowClassName={(datum): string => - `data_${datum.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` + `data_${datum.courseUser.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` } - getRowEqualityData={(datum): SubmissionStats => datum} - getRowId={(datum): string => datum.id.toString()} + getRowEqualityData={(datum): SubmissionMarksPerQuestionStats => datum} + getRowId={(datum): string => datum.courseUser.id.toString()} indexing={{ indices: true }} pagination={{ rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js b/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js index fe6c388bb1..f264a17c7a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js @@ -48,8 +48,8 @@ export function processSubmissionsIntoChartData(submissions) { previousDisplayValue = sub.displayValue; } numSubmissions += 1; - if (sub.grade != null) { - totalGrade += sub.grade; + if (sub.totalGrade != null) { + totalGrade += sub.totalGrade; numGrades += 1; } }); diff --git a/client/app/bundles/course/assessment/propTypes.js b/client/app/bundles/course/assessment/propTypes.js deleted file mode 100644 index cf8d00e74d..0000000000 --- a/client/app/bundles/course/assessment/propTypes.js +++ /dev/null @@ -1,38 +0,0 @@ -import PropTypes from 'prop-types'; - -export const assessmentShape = PropTypes.shape({ - id: PropTypes.number, - title: PropTypes.string, - startAt: PropTypes.object.isRequired, - endAt: PropTypes.object, - maximumGrade: PropTypes.number, - url: PropTypes.string, -}); - -export const ancestorShape = PropTypes.shape({ - id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - title: PropTypes.string.isRequired, - courseTitle: PropTypes.string.isRequired, -}); - -// Used in the courseUsers array -export const courseUserShape = PropTypes.shape({ - id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, - role: PropTypes.oneOf([ - 'owner', - 'manager', - 'student', - 'teaching_assistant', - 'observer', - ]).isRequired, - isPhantom: PropTypes.bool.isRequired, -}); - -export const submissionRecordsShape = PropTypes.shape({ - courseUser: courseUserShape, - submittedAt: PropTypes.object, - endAt: PropTypes.string, - grade: PropTypes.number, - dayDifference: PropTypes.number, -}); diff --git a/client/app/bundles/course/assessment/types.ts b/client/app/bundles/course/assessment/types.ts index e9027a89ae..6500ed884d 100644 --- a/client/app/bundles/course/assessment/types.ts +++ b/client/app/bundles/course/assessment/types.ts @@ -1,5 +1,6 @@ import { WorkflowState } from 'types/course/assessment/submission/submission'; import { CourseUserRoles } from 'types/course/courseUsers'; +import { SubmissionWithTimeInfo } from 'types/course/statistics/assessmentStatistics'; export interface CourseUserShape { id: number; @@ -8,12 +9,8 @@ export interface CourseUserShape { isPhantom: boolean; } -export interface SubmissionRecordShape { - courseUser: CourseUserShape; +export interface SubmissionRecordShape extends SubmissionWithTimeInfo { workflowState: WorkflowState; - submittedAt: string; - endAt: string; - grade: number; dayDifference: number; } diff --git a/client/app/bundles/course/assessment/utils/statisticsUtils.js b/client/app/bundles/course/assessment/utils/statisticsUtils.js index aa0b9b8004..090c2183b8 100644 --- a/client/app/bundles/course/assessment/utils/statisticsUtils.js +++ b/client/app/bundles/course/assessment/utils/statisticsUtils.js @@ -4,10 +4,10 @@ export const processCourseUser = (courseUser) => ({ }); export const processSubmission = (submission) => { - const grade = - submission.grade != null && typeof submission.grade === 'string' - ? parseFloat(submission.grade) - : submission.grade; + const totalGrade = + submission.totalGrade != null + ? parseFloat(submission.totalGrade) + : submission.totalGrade; const submittedAt = submission.submittedAt != null ? new Date(submission.submittedAt) @@ -21,7 +21,7 @@ export const processSubmission = (submission) => { return { ...submission, - grade, + totalGrade, submittedAt, endAt, dayDifference, diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index f73c90d80a..2bb66c7a12 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -1,5 +1,3 @@ -import { CourseUserRoles } from '../courseUsers'; - interface Assessment { id: number | string; title: string; @@ -9,29 +7,30 @@ interface Assessment { url: string; } -interface Submission { - courseUser: { - id: number | string; - name: string; - role: CourseUserRoles; - isPhantom: boolean; - }; +interface SubmissionInfo { + courseUser: StudentInfo; + totalGrade?: number | null; +} + +export interface SubmissionWithTimeInfo extends SubmissionInfo { submittedAt: string; endAt: string; - grade: number; } -interface Student { +interface UserInfo { id: number | string; name: string; - role: CourseUserRoles; +} + +interface StudentInfo extends UserInfo { + role: 'student'; isPhantom: boolean; } export interface AssessmentStatistics { assessment: Assessment; - submissions: Submission[]; - allStudents: Student[]; + submissions: SubmissionWithTimeInfo[]; + allStudents: StudentInfo[]; } export interface AssessmentAncestor { @@ -50,16 +49,9 @@ export interface AnswerGradeStats { maximumGrade: number; } -export interface SubmissionStats { - id: number; - name: string; - role: string; - isPhantom: boolean; - grader?: string; - graderId?: number; +export interface SubmissionMarksPerQuestionStats extends SubmissionInfo { + grader?: UserInfo; groups?: { name: string }[]; - groupCategoryId?: number; - totalGrade?: number | null; workflowState?: string; answers?: AnswerGradeStats[]; } @@ -68,5 +60,5 @@ export interface AssessmentMarksPerQuestionStats { maximumGrade: number; questionCount: number; assessmentTitle: string; - submissions: SubmissionStats[]; + submissions: SubmissionMarksPerQuestionStats[]; } From 6f890422e1d7aabe35625d41bd35ff59ab6f3539 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Thu, 25 Jan 2024 10:54:58 +0800 Subject: [PATCH 14/45] add feature for phantom in statistics - display GhostIcon for phantom students - refactor the props for includePhantom - sort table based on phantom value, then name --- .../GradeDistributionChart.tsx | 11 ++++++-- .../AssessmentStatistics/StatisticsCharts.tsx | 10 ++++--- .../StudentMarksPerQuestionTable.tsx | 24 ++++++++++++----- .../SubmissionStatusChart.tsx | 27 +++++++++++++------ .../SubmissionTimeAndGradeChart.tsx | 8 ++++-- .../pages/AssessmentStatistics/index.tsx | 25 +++++++++-------- 6 files changed, 71 insertions(+), 34 deletions(-) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx index 1101e9244d..69418a8e24 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx @@ -23,14 +23,21 @@ const translations = defineMessages({ interface Props { submissions: SubmissionRecordShape[]; + includePhantom: boolean; } const GradeDistributionChart: FC = (props) => { const { t } = useTranslation(); - const { submissions } = props; + const { submissions, includePhantom } = props; + + const includedSubmissions = includePhantom + ? submissions + : submissions.filter((s) => !s.courseUser.isPhantom); const totalGrades = - submissions?.filter((s) => s.totalGrade)?.map((s) => s.totalGrade) ?? []; + includedSubmissions + ?.filter((s) => s.totalGrade) + ?.map((s) => s.totalGrade) ?? []; const data = { labels: [t(translations.yAxisLabel)], datasets: [ diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx index 25d87e9166..1c70baf948 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx @@ -48,7 +48,8 @@ const StatisticsCharts: FC = (props) => { {t(translations.submissionStatuses)} @@ -56,13 +57,16 @@ const StatisticsCharts: FC = (props) => { {t(translations.gradeDistribution)} - + {t(translations.submissionTimeAndGrade)} - + {/* TODO: Add section on hardest questions */} diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 2c10c7a62a..58cb230a21 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -10,6 +10,7 @@ import { import { workflowStates } from 'course/assessment/submission/constants'; import Link from 'lib/components/core/Link'; +import GhostIcon from 'lib/components/icons/GhostIcon'; import Table, { ColumnTemplate } from 'lib/components/table'; import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; import useTranslation from 'lib/hooks/useTranslation'; @@ -80,9 +81,15 @@ const StudentMarksPerQuestionTable: FC = (props) => { const { courseId } = useParams(); const { data } = props; - const sortedSubmission = data.submissions.sort((datum1, datum2) => - datum1.courseUser.name.localeCompare(datum2.courseUser.name), - ); + const sortedSubmission = data.submissions + .sort((datum1, datum2) => + datum1.courseUser.name.localeCompare(datum2.courseUser.name), + ) + .sort( + (datum1, datum2) => + Number(datum1.courseUser.isPhantom) - + Number(datum2.courseUser.isPhantom), + ); // the case where the grade is null is handled separately inside the column // (refer to the definition of answerColumns below) @@ -159,9 +166,14 @@ const StudentMarksPerQuestionTable: FC = (props) => { title: t(translations.name), sortable: true, cell: (datum) => ( - - {datum.courseUser.name} - +
+ + {datum.courseUser.name} + + {datum.courseUser.isPhantom && ( + + )} +
), csvDownloadable: true, }, diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx index d06aa113a0..bcad65944b 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx @@ -2,7 +2,10 @@ import { defineMessages, FormattedMessage } from 'react-intl'; import palette from 'theme/palette'; import { workflowStates } from 'course/assessment/submission/constants'; -import { SubmissionRecordShape } from 'course/assessment/types'; +import { + CourseUserShape, + SubmissionRecordShape, +} from 'course/assessment/types'; import BarChart from 'lib/components/core/BarChart'; const translations = defineMessages({ @@ -34,23 +37,31 @@ const translations = defineMessages({ interface Props { submissions: SubmissionRecordShape[]; - numStudents: number; + allStudents: CourseUserShape[]; + includePhantom: boolean; } const SubmissionStatusChart = (props: Props): JSX.Element => { - const { submissions, numStudents } = props; + const { submissions, allStudents, includePhantom } = props; - const numUnstarted = numStudents - submissions.length; - const numAttempting = submissions.filter( + const numStudents = includePhantom + ? allStudents.length + : allStudents.filter((s) => !s.isPhantom).length; + const includedSubmissions = includePhantom + ? submissions + : submissions.filter((s) => !s.courseUser.isPhantom); + + const numUnstarted = numStudents - includedSubmissions.length; + const numAttempting = includedSubmissions.filter( (s) => s.workflowState === workflowStates.Attempting, ).length; - const numSubmitted = submissions.filter( + const numSubmitted = includedSubmissions.filter( (s) => s.workflowState === workflowStates.Submitted, ).length; - const numGraded = submissions.filter( + const numGraded = includedSubmissions.filter( (s) => s.workflowState === workflowStates.Graded, ).length; - const numPublished = submissions.filter( + const numPublished = includedSubmissions.filter( (s) => s.workflowState === workflowStates.Published, ).length; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx index 04c636359a..c496d25a37 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx @@ -34,13 +34,17 @@ const translations = defineMessages({ interface Props { submissions: SubmissionRecordShape[]; + includePhantom: boolean; } const SubmissionTimeAndGradeChart: FC = (props) => { const { t } = useTranslation(); - const { submissions } = props; + const { submissions, includePhantom } = props; + const includedSubmissions = includePhantom + ? submissions + : submissions.filter((s) => !s.courseUser.isPhantom); const { labels, lineData, barData } = - processSubmissionsIntoChartData(submissions); + processSubmissionsIntoChartData(includedSubmissions); const data = { labels, diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx index c3f499e6cc..4d69f5fe1d 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx @@ -69,9 +69,7 @@ const AssessmentStatisticsPage: FC = () => { const dispatch = useAppDispatch(); const statisticsPage = useAppSelector(getStatisticsPage); - const numStudents = includePhantom - ? statisticsPage.allStudents.length - : statisticsPage.allStudents.filter((student) => !student.isPhantom).length; + const allStudents = statisticsPage.allStudents; useEffect(() => { if (assessmentId) { @@ -82,22 +80,22 @@ const AssessmentStatisticsPage: FC = () => { }, [assessmentId]); const submissions = statisticsPage.submissions as SubmissionRecordShape[]; - const noPhantomStudentSubmissions = submissions.filter( - (submission) => !submission.courseUser.isPhantom, - ); - const displayedSubmissions = includePhantom - ? submissions - : noPhantomStudentSubmissions; const tabComponentMapping = { marksPerQuestion: ( ), gradeDistribution: ( - + ), submissionTimeAndGrade: ( - + ), duplicationHistory: , }; @@ -113,8 +111,9 @@ const AssessmentStatisticsPage: FC = () => { <> Date: Fri, 26 Jan 2024 11:48:48 +0800 Subject: [PATCH 15/45] add tests and fix behavior - test for assessment, ancestors, and marks_per_question controller - answers only passed on to view if workflow state is published --- .../marks_per_question.json.jbuilder | 10 +- .../statistics/assessment_controller_spec.rb | 147 ++++++++++++++++++ 2 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 spec/controllers/course/statistics/assessment_controller_spec.rb diff --git a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder index 791c5a8883..e3d53b01eb 100644 --- a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder +++ b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder @@ -25,12 +25,12 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.id grader&.id || 0 json.name grader&.name || 'System' end - end - json.answers answers.each do |answer| - json.id answer.id - json.grade answer.grade - json.maximumGrade @question_maximum_grade_hash[answer.question_id] + json.answers answers.each do |answer| + json.id answer.id + json.grade answer.grade + json.maximumGrade @question_maximum_grade_hash[answer.question_id] + end end end end diff --git a/spec/controllers/course/statistics/assessment_controller_spec.rb b/spec/controllers/course/statistics/assessment_controller_spec.rb new file mode 100644 index 0000000000..d55eafbb91 --- /dev/null +++ b/spec/controllers/course/statistics/assessment_controller_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Statistics::AssessmentsController, type: :controller do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course, :enrollable) } + let(:assessment) { create(:assessment, :published, :with_all_question_types, course: course) } + let(:students) { create_list(:course_student, 3, course: course) } + let(:teaching_assistant) { create(:course_teaching_assistant, course: course) } + + let!(:submission1) do + create(:submission, :published, + assessment: assessment, course: course, creator: students[0].user) + end + let!(:submission2) do + create(:submission, :attempting, + assessment: assessment, course: course, creator: students[1].user) + end + let!(:submission_teaching_assistant) do + create(:submission, :published, + assessment: assessment, course: course, creator: teaching_assistant.user) + end + + describe '#assessment' do + render_views + subject { get :assessment, as: :json, params: { course_id: course, id: assessment.id } } + + context 'when the Normal User get the submission data for chart display' do + let(:user) { create(:user) } + before { sign_in(user) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when the Course Student get the submission data for chart display' do + let(:user) { create(:course_student, course: course).user } + before { sign_in(user) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when the Course Manager get the submission data for chart display' do + let(:user) { create(:course_manager, course: course).user } + before { sign_in(user) } + + it 'returns OK with right number of submissions being displayed' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + # only the students starting the assessment will have their data recorded here + expect(json_result['submissions'].count).to eq(2) + + # only published submissions' answers will be included in the stats + expect(json_result['submissions'][0]['courseUser']['role']).to eq('student') + expect(json_result['submissions'][1]['courseUser']['role']).to eq('student') + + # showing the correct workflow state + expect(json_result['submissions'][0]['workflowState']).to eq('published') + expect(json_result['submissions'][1]['workflowState']).to eq('attempting') + + # however, all the students information will be sent to frontend + expect(json_result['allStudents'].count).to eq(3) + end + end + end + + describe '#ancestors' do + let(:destination_course) { create(:course) } + let!(:duplicate_objects) do + Course::Duplication::ObjectDuplicationService. + duplicate_objects(course, destination_course, [assessment], {}) + end + + context 'after the assessment is being duplicated' do + render_views + subject do + get :ancestors, as: :json, params: { course_id: destination_course, + id: destination_course.assessments[0].id } + end + + context 'when the administrator wants to get ancestors' do + let(:administrator) { create(:administrator) } + before { sign_in(administrator) } + + it 'gives both the assessment information' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + expect(json_result['assessments'].count).to eq(2) + end + end + + context 'when the course manager of the destination course wants to get ancestors' do + let(:course_manager) { create(:course_manager, course: destination_course) } + before { sign_in(course_manager.user) } + + it 'gives only the information regarding current destination as no authorization for parent course' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + expect(json_result['assessments'].count).to eq(1) + end + end + end + end + + describe '#marks_per_question' do + render_views + subject { get :marks_per_question, as: :json, params: { course_id: course, id: assessment.id } } + + context 'when the Normal User fetch marks per question statistics' do + let(:user) { create(:user) } + before { sign_in(user) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when the Course Student fetch marks per question statistics' do + let(:user) { create(:course_student, course: course).user } + before { sign_in(user) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + + context 'when the Course Manager fetch marks per question statistics' do + let(:user) { create(:course_manager, course: course).user } + before { sign_in(user) } + + it 'returns OK with right number of submissions being displayed' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + # all the students data will be included here, including the non-published one + expect(json_result['submissions'].count).to eq(3) + + # only published submissions' answers will be included in the stats + expect(json_result['submissions'][0]['answers']).not_to be_nil + expect(json_result['submissions'][1]['answers']).to be_nil + expect(json_result['submissions'][2]['answers']).to be_nil + + # showing the correct workflow state + expect(json_result['submissions'][0]['workflowState']).to eq('published') + expect(json_result['submissions'][1]['workflowState']).to eq('attempting') + expect(json_result['submissions'][2]['workflowState']).to be_nil + end + end + end + end +end From 99fdaa0e90c48fdbec1259ed11689c0372cebf71 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Mon, 29 Jan 2024 13:27:31 +0800 Subject: [PATCH 16/45] refactor assessment operations - split operations into several files depending on the object --- .../ConvertMcqMrqPrompt.tsx | 2 +- .../app/bundles/course/assessment/handles.ts | 2 +- .../assessments.ts} | 250 ++++-------------- .../assessment/operations/monitoring.ts | 8 + .../course/assessment/operations/questions.ts | 55 ++++ .../assessment/operations/statistics.ts | 90 +++++++ .../pages/AssessmentAuthenticate/index.tsx | 2 +- .../pages/AssessmentBlockedByMonitorPage.tsx | 2 +- .../AssessmentEdit/AssessmentEditPage.jsx | 2 +- .../assessment/pages/AssessmentEdit/index.tsx | 2 +- .../pages/AssessmentMonitoring/index.tsx | 2 +- .../AssessmentShow/AssessmentShowHeader.tsx | 2 +- .../pages/AssessmentShow/QuestionsManager.tsx | 2 +- .../assessment/pages/AssessmentShow/index.tsx | 2 +- .../prompts/DeleteQuestionButtonPrompt.tsx | 2 +- .../prompts/DuplicationPrompt.tsx | 2 +- .../DuplicationHistoryStatistics.tsx | 2 +- .../StudentMarksPerQuestionPage.tsx | 2 +- .../pages/AssessmentStatistics/index.tsx | 2 +- .../NewAssessmentFormButton.jsx | 2 +- .../AssessmentsIndex/UnavailableMessage.tsx | 2 +- .../pages/AssessmentsIndex/index.tsx | 3 +- .../course/statistics/assessmentStatistics.ts | 2 - 23 files changed, 225 insertions(+), 217 deletions(-) rename client/app/bundles/course/assessment/{operations.ts => operations/assessments.ts} (53%) create mode 100644 client/app/bundles/course/assessment/operations/monitoring.ts create mode 100644 client/app/bundles/course/assessment/operations/questions.ts create mode 100644 client/app/bundles/course/assessment/operations/statistics.ts diff --git a/client/app/bundles/course/assessment/components/ConvertMcqMrqButton/ConvertMcqMrqPrompt.tsx b/client/app/bundles/course/assessment/components/ConvertMcqMrqButton/ConvertMcqMrqPrompt.tsx index 2d40cf115e..c40c0414d4 100644 --- a/client/app/bundles/course/assessment/components/ConvertMcqMrqButton/ConvertMcqMrqPrompt.tsx +++ b/client/app/bundles/course/assessment/components/ConvertMcqMrqButton/ConvertMcqMrqPrompt.tsx @@ -7,7 +7,7 @@ import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; -import { convertMcqMrq } from '../../operations'; +import { convertMcqMrq } from '../../operations/questions'; import translations from '../../translations'; export interface ConvertMcqMrqData { diff --git a/client/app/bundles/course/assessment/handles.ts b/client/app/bundles/course/assessment/handles.ts index 13eab45b37..898f2b8c38 100644 --- a/client/app/bundles/course/assessment/handles.ts +++ b/client/app/bundles/course/assessment/handles.ts @@ -3,7 +3,7 @@ import { getIdFromUnknown } from 'utilities'; import { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; -import { fetchAssessment, fetchAssessments } from './operations'; +import { fetchAssessment, fetchAssessments } from './operations/assessments'; const getTabTitle = async ( categoryId?: number, diff --git a/client/app/bundles/course/assessment/operations.ts b/client/app/bundles/course/assessment/operations/assessments.ts similarity index 53% rename from client/app/bundles/course/assessment/operations.ts rename to client/app/bundles/course/assessment/operations/assessments.ts index e2abebd4bc..cfd86b5756 100644 --- a/client/app/bundles/course/assessment/operations.ts +++ b/client/app/bundles/course/assessment/operations/assessments.ts @@ -6,12 +6,7 @@ import { AssessmentsListData, AssessmentUnlockRequirements, FetchAssessmentData, - QuestionOrderPostData, } from 'types/course/assessment/assessments'; -import { MonitoringRequestData } from 'types/course/assessment/monitoring'; -import { McqMrqListData } from 'types/course/assessment/question/multiple-responses'; -import { QuestionDuplicationResult } from 'types/course/assessment/questions'; -import { AssessmentMarksPerQuestionStats } from 'types/course/statistics/assessmentStatistics'; import CourseAPI from 'api/course'; import { JustRedirect } from 'api/types'; @@ -19,13 +14,7 @@ import { setNotification } from 'lib/actions'; import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper'; import { getCourseId } from 'lib/helpers/url-helpers'; -import { - processAncestor, - processAssessment, - processCourseUser, - processSubmission, -} from './utils/statisticsUtils'; -import actionTypes from './constants'; +import actionTypes from '../constants'; export const fetchAssessments = async ( categoryId?: number, @@ -63,118 +52,6 @@ export const fetchAssessmentEditData = async ( return response.data; }; -export const deleteAssessment = async ( - deleteUrl: string, -): Promise => { - try { - const response = await CourseAPI.assessment.assessments.delete(deleteUrl); - return response.data; - } catch (error) { - if (error instanceof AxiosError) throw error.response?.data?.errors; - - throw error; - } -}; - -export const authenticateAssessment = async ( - assessmentId: number, - data, -): Promise => { - const adaptedData = { assessment: { password: data.password } }; - - try { - const response = await CourseAPI.assessment.assessments.authenticate( - assessmentId, - adaptedData, - ); - return response.data; - } catch (error) { - if (error instanceof AxiosError) throw error.response?.data?.errors; - throw error; - } -}; - -export const unblockAssessment = async ( - assessmentId: number, - password: string, -): Promise => { - try { - const response = await CourseAPI.assessment.assessments.unblockMonitor( - assessmentId, - password, - ); - - return response.data.redirectUrl; - } catch (error) { - if (error instanceof AxiosError) throw error.response?.data?.errors; - throw error; - } -}; - -export const attemptAssessment = async ( - assessmentId: number, -): Promise => { - try { - const response = - await CourseAPI.assessment.assessments.attempt(assessmentId); - return response.data; - } catch (error) { - if (error instanceof AxiosError) - throw new Error(error.response?.data?.error); - - throw error; - } -}; - -export const reorderQuestions = async ( - assessmentId: number, - questionIds: number[], -): Promise => { - const response = await CourseAPI.assessment.assessments.reorderQuestions( - assessmentId, - questionIds, - ); - return response.data; -}; - -export const duplicateQuestion = async ( - duplicationUrl: string, -): Promise => { - try { - const response = - await CourseAPI.assessment.assessments.duplicateQuestion(duplicationUrl); - return response.data; - } catch (error) { - if (error instanceof AxiosError) throw error.response?.data?.errors; - - throw error; - } -}; - -export const deleteQuestion = async (questionUrl: string): Promise => { - try { - await CourseAPI.assessment.assessments.deleteQuestion(questionUrl); - } catch (error) { - if (error instanceof AxiosError) throw error.response?.data?.errors; - - throw error; - } -}; - -export const convertMcqMrq = async ( - convertUrl: string, -): Promise => { - try { - const response = - await CourseAPI.assessment.assessments.convertMcqMrq(convertUrl); - return response.data; - } catch (error) { - if (error instanceof AxiosError) throw error.response?.data?.errors; - - throw error; - } -}; - export const createAssessment = ( categoryId: number, tabId: number, @@ -247,84 +124,65 @@ export const updateAssessment = ( }; }; -export function fetchStatistics( - assessmentId: number, - failureMessage: string, -): Operation { - return async (dispatch) => { - dispatch({ type: actionTypes.FETCH_STATISTICS_REQUEST }); - return CourseAPI.statistics.assessment - .fetchStatistics(assessmentId) - .then((response) => { - dispatch({ - type: actionTypes.FETCH_STATISTICS_SUCCESS, - assessment: processAssessment(response.data.assessment), - submissions: response.data.submissions.map(processSubmission), - allStudents: response.data.allStudents.map(processCourseUser), - }); - }) - .catch(() => { - dispatch({ type: actionTypes.FETCH_STATISTICS_FAILURE }); - dispatch(setNotification(failureMessage)); - }); - }; -} +export const deleteAssessment = async ( + deleteUrl: string, +): Promise => { + try { + const response = await CourseAPI.assessment.assessments.delete(deleteUrl); + return response.data; + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; -export function fetchAncestors( + throw error; + } +}; + +export const authenticateAssessment = async ( assessmentId: number, - failureMessage: string, -): Operation { - return async (dispatch) => { - dispatch({ type: actionTypes.FETCH_ANCESTORS_REQUEST }); - return CourseAPI.statistics.assessment - .fetchAncestors(assessmentId) - .then((response) => { - dispatch({ - type: actionTypes.FETCH_ANCESTORS_SUCCESS, - ancestors: response.data.assessments.map(processAncestor), - }); - }) - .catch(() => { - dispatch({ type: actionTypes.FETCH_ANCESTORS_FAILURE }); - dispatch(setNotification(failureMessage)); - }); - }; -} + data, +): Promise => { + const adaptedData = { assessment: { password: data.password } }; -export function fetchAncestorStatistics( - ancestorId: number, - failureMessage: string, -): Operation { - return async (dispatch) => { - dispatch({ type: actionTypes.FETCH_ANCESTOR_STATISTICS_REQUEST }); - return CourseAPI.statistics.assessment - .fetchStatistics(ancestorId) - .then((response) => { - dispatch({ - type: actionTypes.FETCH_ANCESTOR_STATISTICS_SUCCESS, - assessment: processAssessment(response.data.assessment), - submissions: response.data.submissions.map(processSubmission), - allStudents: response.data.allStudents.map(processCourseUser), - }); - }) - .catch(() => { - dispatch({ type: actionTypes.FETCH_ANCESTOR_STATISTICS_FAILURE }); - dispatch(setNotification(failureMessage)); - }); - }; -} + try { + const response = await CourseAPI.assessment.assessments.authenticate( + assessmentId, + adaptedData, + ); + return response.data; + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } +}; -export const fetchStudentMarkPerQuestion = async ( - assessmentId: string | number, -): Promise => { - const response = - await CourseAPI.statistics.assessment.fetchMarksPerQuestionStats( +export const unblockAssessment = async ( + assessmentId: number, + password: string, +): Promise => { + try { + const response = await CourseAPI.assessment.assessments.unblockMonitor( assessmentId, + password, ); - return response.data; + + return response.data.redirectUrl; + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } }; -export const fetchMonitoringData = async (): Promise => { - const response = await CourseAPI.assessment.assessments.fetchMonitoringData(); - return response.data; +export const attemptAssessment = async ( + assessmentId: number, +): Promise => { + try { + const response = + await CourseAPI.assessment.assessments.attempt(assessmentId); + return response.data; + } catch (error) { + if (error instanceof AxiosError) + throw new Error(error.response?.data?.error); + + throw error; + } }; diff --git a/client/app/bundles/course/assessment/operations/monitoring.ts b/client/app/bundles/course/assessment/operations/monitoring.ts new file mode 100644 index 0000000000..c4b06eb26c --- /dev/null +++ b/client/app/bundles/course/assessment/operations/monitoring.ts @@ -0,0 +1,8 @@ +import { MonitoringRequestData } from 'types/course/assessment/monitoring'; + +import CourseAPI from 'api/course'; + +export const fetchMonitoringData = async (): Promise => { + const response = await CourseAPI.assessment.assessments.fetchMonitoringData(); + return response.data; +}; diff --git a/client/app/bundles/course/assessment/operations/questions.ts b/client/app/bundles/course/assessment/operations/questions.ts new file mode 100644 index 0000000000..c8a100a866 --- /dev/null +++ b/client/app/bundles/course/assessment/operations/questions.ts @@ -0,0 +1,55 @@ +import { AxiosError } from 'axios'; +import { QuestionOrderPostData } from 'types/course/assessment/assessments'; +import { McqMrqListData } from 'types/course/assessment/question/multiple-responses'; +import { QuestionDuplicationResult } from 'types/course/assessment/questions'; + +import CourseAPI from 'api/course'; + +export const reorderQuestions = async ( + assessmentId: number, + questionIds: number[], +): Promise => { + const response = await CourseAPI.assessment.assessments.reorderQuestions( + assessmentId, + questionIds, + ); + return response.data; +}; + +export const duplicateQuestion = async ( + duplicationUrl: string, +): Promise => { + try { + const response = + await CourseAPI.assessment.assessments.duplicateQuestion(duplicationUrl); + return response.data; + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + + throw error; + } +}; + +export const deleteQuestion = async (questionUrl: string): Promise => { + try { + await CourseAPI.assessment.assessments.deleteQuestion(questionUrl); + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + + throw error; + } +}; + +export const convertMcqMrq = async ( + convertUrl: string, +): Promise => { + try { + const response = + await CourseAPI.assessment.assessments.convertMcqMrq(convertUrl); + return response.data; + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + + throw error; + } +}; diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts new file mode 100644 index 0000000000..7131004678 --- /dev/null +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -0,0 +1,90 @@ +import { Operation } from 'store'; +import { AssessmentMarksPerQuestionStats } from 'types/course/statistics/assessmentStatistics'; + +import CourseAPI from 'api/course'; +import { setNotification } from 'lib/actions'; + +import actionTypes from '../constants'; +import { + processAncestor, + processAssessment, + processCourseUser, + processSubmission, +} from '../utils/statisticsUtils'; + +export function fetchStatistics( + assessmentId: number, + failureMessage: string, +): Operation { + return async (dispatch) => { + dispatch({ type: actionTypes.FETCH_STATISTICS_REQUEST }); + return CourseAPI.statistics.assessment + .fetchStatistics(assessmentId) + .then((response) => { + dispatch({ + type: actionTypes.FETCH_STATISTICS_SUCCESS, + assessment: processAssessment(response.data.assessment), + submissions: response.data.submissions.map(processSubmission), + allStudents: response.data.allStudents.map(processCourseUser), + }); + }) + .catch(() => { + dispatch({ type: actionTypes.FETCH_STATISTICS_FAILURE }); + dispatch(setNotification(failureMessage)); + }); + }; +} + +export function fetchAncestors( + assessmentId: number, + failureMessage: string, +): Operation { + return async (dispatch) => { + dispatch({ type: actionTypes.FETCH_ANCESTORS_REQUEST }); + return CourseAPI.statistics.assessment + .fetchAncestors(assessmentId) + .then((response) => { + dispatch({ + type: actionTypes.FETCH_ANCESTORS_SUCCESS, + ancestors: response.data.assessments.map(processAncestor), + }); + }) + .catch(() => { + dispatch({ type: actionTypes.FETCH_ANCESTORS_FAILURE }); + dispatch(setNotification(failureMessage)); + }); + }; +} + +export function fetchAncestorStatistics( + ancestorId: number, + failureMessage: string, +): Operation { + return async (dispatch) => { + dispatch({ type: actionTypes.FETCH_ANCESTOR_STATISTICS_REQUEST }); + return CourseAPI.statistics.assessment + .fetchStatistics(ancestorId) + .then((response) => { + dispatch({ + type: actionTypes.FETCH_ANCESTOR_STATISTICS_SUCCESS, + assessment: processAssessment(response.data.assessment), + submissions: response.data.submissions.map(processSubmission), + allStudents: response.data.allStudents.map(processCourseUser), + }); + }) + .catch(() => { + dispatch({ type: actionTypes.FETCH_ANCESTOR_STATISTICS_FAILURE }); + dispatch(setNotification(failureMessage)); + }); + }; +} + +export const fetchStudentMarkPerQuestion = async ( + assessmentId: string | number, +): Promise => { + const response = + await CourseAPI.statistics.assessment.fetchMarksPerQuestionStats( + assessmentId, + ); + return response.data; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentAuthenticate/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentAuthenticate/index.tsx index f8699acee0..2169cb12bd 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentAuthenticate/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentAuthenticate/index.tsx @@ -10,6 +10,7 @@ import { } from 'types/course/assessment/assessments'; import { object, string } from 'yup'; +import { authenticateAssessment } from 'course/assessment/operations/assessments'; import FormTextField from 'lib/components/form/fields/TextField'; import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper'; import { getAssessmentURL } from 'lib/helpers/url-builders'; @@ -18,7 +19,6 @@ import useTranslation from 'lib/hooks/useTranslation'; import { formatFullDateTime } from 'lib/moment'; import formTranslations from 'lib/translations/form'; -import { authenticateAssessment } from '../../operations'; import translations from '../../translations'; interface AssessmentAuthenticateProps { diff --git a/client/app/bundles/course/assessment/pages/AssessmentBlockedByMonitorPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentBlockedByMonitorPage.tsx index 9d04e4b0af..8fcb86ad6e 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentBlockedByMonitorPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentBlockedByMonitorPage.tsx @@ -10,7 +10,7 @@ import Page from 'lib/components/core/layouts/Page'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; -import { unblockAssessment } from '../operations'; +import { unblockAssessment } from '../operations/assessments'; import translations from '../translations'; interface AssessmentBlockedByMonitorPageProps { diff --git a/client/app/bundles/course/assessment/pages/AssessmentEdit/AssessmentEditPage.jsx b/client/app/bundles/course/assessment/pages/AssessmentEdit/AssessmentEditPage.jsx index 402aac2df1..c7deb05fd3 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentEdit/AssessmentEditPage.jsx +++ b/client/app/bundles/course/assessment/pages/AssessmentEdit/AssessmentEditPage.jsx @@ -9,7 +9,7 @@ import Page from 'lib/components/core/layouts/Page'; import { achievementTypesConditionAttributes } from 'lib/types'; import AssessmentForm from '../../components/AssessmentForm'; -import { updateAssessment } from '../../operations'; +import { updateAssessment } from '../../operations/assessments'; import translations from '../../translations'; class AssessmentEditPage extends Component { diff --git a/client/app/bundles/course/assessment/pages/AssessmentEdit/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentEdit/index.tsx index f96bad64f2..d6010c635c 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentEdit/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentEdit/index.tsx @@ -1,9 +1,9 @@ +import { fetchAssessmentEditData } from 'course/assessment/operations/assessments'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; import { getAssessmentId } from 'lib/helpers/url-helpers'; import { DEFAULT_MONITORING_OPTIONS } from '../../constants'; -import { fetchAssessmentEditData } from '../../operations'; import translations from '../../translations'; import { categoryAndTabTitle } from '../../utils'; diff --git a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/index.tsx index dbc033cb52..9b0397e3f1 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/index.tsx @@ -1,7 +1,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; -import { fetchMonitoringData } from '../../operations'; +import { fetchMonitoringData } from '../../operations/monitoring'; import translations from '../../translations'; import PulseGrid from './PulseGrid'; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx index f0ac60b756..ec8ac53b45 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx @@ -12,12 +12,12 @@ import { AssessmentDeleteResult, } from 'types/course/assessment/assessments'; +import { deleteAssessment } from 'course/assessment/operations/assessments'; import DeleteButton from 'lib/components/core/buttons/DeleteButton'; import { PromptText } from 'lib/components/core/dialogs/Prompt'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; -import { deleteAssessment } from '../../operations'; import translations from '../../translations'; import { ACTION_LABELS } from '../AssessmentsIndex/ActionButtons'; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/QuestionsManager.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/QuestionsManager.tsx index dcc9510b4b..21ccb5aa02 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/QuestionsManager.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/QuestionsManager.tsx @@ -8,7 +8,7 @@ import { QuestionData } from 'types/course/assessment/questions'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; -import { reorderQuestions } from '../../operations'; +import { reorderQuestions } from '../../operations/questions'; import translations from '../../translations'; import Question from './Question'; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/index.tsx index e7253a98d0..0aaa354cdd 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/index.tsx @@ -5,10 +5,10 @@ import { isUnauthenticatedAssessmentData, } from 'types/course/assessment/assessments'; +import { fetchAssessment } from 'course/assessment/operations/assessments'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; -import { fetchAssessment } from '../../operations'; import AssessmentAuthenticate from '../AssessmentAuthenticate'; import AssessmentBlockedByMonitorPage from '../AssessmentBlockedByMonitorPage'; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/prompts/DeleteQuestionButtonPrompt.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/prompts/DeleteQuestionButtonPrompt.tsx index efd9e891bb..da934f3539 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/prompts/DeleteQuestionButtonPrompt.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/prompts/DeleteQuestionButtonPrompt.tsx @@ -6,7 +6,7 @@ import { PromptText } from 'lib/components/core/dialogs/Prompt'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; -import { deleteQuestion } from '../../../operations'; +import { deleteQuestion } from '../../../operations/questions'; import translations from '../../../translations'; interface DeleteQuestionButtonPromptProps { diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/prompts/DuplicationPrompt.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/prompts/DuplicationPrompt.tsx index cab58aa454..88560d761e 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/prompts/DuplicationPrompt.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/prompts/DuplicationPrompt.tsx @@ -19,7 +19,7 @@ import Link from 'lib/components/core/Link'; import { loadingToast } from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; -import { duplicateQuestion } from '../../../operations'; +import { duplicateQuestion } from '../../../operations/questions'; import translations from '../../../translations'; interface DuplicationPromptProps { diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx index eaa60e6225..f6e880c2c7 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx @@ -6,7 +6,7 @@ import { fetchAncestors, fetchAncestorStatistics, fetchStatistics, -} from 'course/assessment/operations'; +} from 'course/assessment/operations/statistics'; import ErrorCard from 'lib/components/core/ErrorCard'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx index 83dce96881..03c15924ba 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx @@ -2,7 +2,7 @@ import { FC } from 'react'; import { useParams } from 'react-router-dom'; import { AssessmentMarksPerQuestionStats } from 'types/course/statistics/assessmentStatistics'; -import { fetchStudentMarkPerQuestion } from 'course/assessment/operations'; +import { fetchStudentMarkPerQuestion } from 'course/assessment/operations/statistics'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx index 4d69f5fe1d..31869ee7a8 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx @@ -3,7 +3,7 @@ import { defineMessages, FormattedMessage } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Box, FormControlLabel, Switch, Tab, Tabs } from '@mui/material'; -import { fetchStatistics } from 'course/assessment/operations'; +import { fetchStatistics } from 'course/assessment/operations/statistics'; import { SubmissionRecordShape } from 'course/assessment/types'; import Page from 'lib/components/core/layouts/Page'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/NewAssessmentFormButton.jsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/NewAssessmentFormButton.jsx index 1f7ac00b79..f7ffbde431 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/NewAssessmentFormButton.jsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/NewAssessmentFormButton.jsx @@ -11,7 +11,7 @@ import { } from '@mui/material'; import PropTypes from 'prop-types'; -import { createAssessment } from 'course/assessment/operations'; +import { createAssessment } from 'course/assessment/operations/assessments'; import AddButton from 'lib/components/core/buttons/AddButton'; import ConfirmationDialog from 'lib/components/core/dialogs/ConfirmationDialog'; import formTranslations from 'lib/translations/form'; diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/UnavailableMessage.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/UnavailableMessage.tsx index dd30f97b5f..d5c3294c5f 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/UnavailableMessage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/UnavailableMessage.tsx @@ -6,11 +6,11 @@ import { AssessmentUnlockRequirements, } from 'types/course/assessment/assessments'; +import { fetchAssessmentUnlockRequirements } from 'course/assessment/operations/assessments'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; import useTranslation from 'lib/hooks/useTranslation'; -import { fetchAssessmentUnlockRequirements } from '../../operations'; import translations from '../../translations'; interface UnavailableMessageProps { diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx index c2117f3860..714962e510 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx @@ -2,12 +2,11 @@ import { useSearchParams } from 'react-router-dom'; import { Tab, Tabs } from '@mui/material'; import { AssessmentsListData } from 'types/course/assessment/assessments'; +import { fetchAssessments } from 'course/assessment/operations/assessments'; import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; -import { fetchAssessments } from '../../operations'; - import AssessmentsTable from './AssessmentsTable'; import NewAssessmentFormButton from './NewAssessmentFormButton'; diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 2bb66c7a12..9734eaba12 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -57,8 +57,6 @@ export interface SubmissionMarksPerQuestionStats extends SubmissionInfo { } export interface AssessmentMarksPerQuestionStats { - maximumGrade: number; questionCount: number; - assessmentTitle: string; submissions: SubmissionMarksPerQuestionStats[]; } From b688fa4d637857fb1f2256a5c638a1c88333c7d9 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Mon, 29 Jan 2024 13:27:53 +0800 Subject: [PATCH 17/45] maxGrade and title for assessment taken from redux --- .../assessments/marks_per_question.json.jbuilder | 2 -- .../StudentMarksPerQuestionTable.tsx | 11 +++++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder index e3d53b01eb..e42ae4287e 100644 --- a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder +++ b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder @@ -1,7 +1,5 @@ # frozen_string_literal: true json.questionCount @question_order_hash.size -json.maximumGrade @question_maximum_grade_hash.values.sum -json.assessmentTitle @assessment.title json.submissions @student_submissions_hash.each do |course_user, (submission, answers)| json.courseUser do json.id course_user.id diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 58cb230a21..c83c3dcc50 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -13,9 +13,11 @@ import Link from 'lib/components/core/Link'; import GhostIcon from 'lib/components/icons/GhostIcon'; import Table, { ColumnTemplate } from 'lib/components/table'; import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; +import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import { getClassNameForMarkCell } from './ColorGradationLevel'; +import { getStatisticsPage } from './selectors'; interface Props { data: AssessmentMarksPerQuestionStats; @@ -81,6 +83,8 @@ const StudentMarksPerQuestionTable: FC = (props) => { const { courseId } = useParams(); const { data } = props; + const { assessment } = useAppSelector(getStatisticsPage); + const sortedSubmission = data.submissions .sort((datum1, datum2) => datum1.courseUser.name.localeCompare(datum2.courseUser.name), @@ -218,7 +222,10 @@ const StudentMarksPerQuestionTable: FC = (props) => { sortable: true, cell: (datum): ReactNode => datum.totalGrade - ? renderNonNullGradeCell(datum.totalGrade ?? null, data.maximumGrade) + ? renderNonNullGradeCell( + datum.totalGrade ?? null, + assessment.maximumGrade, + ) : null, className: 'text-right', sortProps: { @@ -257,7 +264,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { columns={columns} csvDownload={{ filename: t(translations.filename, { - assessment: data.assessmentTitle, + assessment: assessment.title, }), }} data={sortedSubmission} From 6ad03d45f9c46f8f7bedbfd407fe4680b9a5f666 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 30 Jan 2024 02:17:14 +0800 Subject: [PATCH 18/45] refactor assessment statistics - modify redux to support marks per question - change the flow of the pages --- .../course/statistics/submissions_concern.rb | 21 +++++++ .../statistics/assessments_controller.rb | 26 ++++++++ app/models/course/assessment.rb | 10 ++++ .../assessment_statistics.json.jbuilder | 60 +++++++++++++++++++ .../course/Statistics/AssessmentStatistics.ts | 9 +++ .../assessment/operations/statistics.ts | 20 +++++++ .../DuplicationHistoryStatistics.tsx | 45 +++++--------- .../GradeDistributionChart.tsx | 15 +++-- .../AssessmentStatistics/StatisticsCharts.tsx | 11 ++-- .../StudentMarksPerQuestionPage.tsx | 40 ------------- .../StudentMarksPerQuestionTable.tsx | 44 +++++++------- .../SubmissionStatusChart.tsx | 19 ++++-- .../SubmissionTimeAndGradeChart.tsx | 15 +++-- .../pages/AssessmentStatistics/index.tsx | 52 +++++++--------- .../pages/AssessmentStatistics/selectors.ts | 10 +++- .../pages/AssessmentStatistics/utils.js | 4 +- .../course/assessment/reducers/statistics.ts | 38 ++++++++++++ client/app/bundles/course/assessment/store.ts | 2 + .../assessment/utils/statisticsUtils.js | 9 +++ client/app/lib/components/core/BarChart.jsx | 4 +- .../course/statistics/assessmentStatistics.ts | 29 +++++++-- config/routes.rb | 1 + 22 files changed, 337 insertions(+), 147 deletions(-) create mode 100644 app/views/course/statistics/assessments/assessment_statistics.json.jbuilder delete mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx create mode 100644 client/app/bundles/course/assessment/reducers/statistics.ts diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 40225c5692..37bc811791 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -20,6 +20,13 @@ def student_submission_marks_hash(submissions, students) student_hash end + def student_submission_hash(submissions, students) + student_hash = initialize_student_hash(students) + + populate_with_submission_info(student_hash, submissions) + student_hash + end + def populate_with_submission_and_end_time(student_hash, submissions) submissions.map do |submission| submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first @@ -43,4 +50,18 @@ def populate_with_submission_and_ordered_answer(student_hash, submissions) student_hash[submitter_course_user] = [submission, answers] end end + + def populate_with_submission_info(student_hash, submissions) + submissions.map do |submission| + submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first + next unless submitter_course_user&.student? + + answers = submission.answers. + select(&:current_answer). + sort_by { |answer| @question_order_hash[answer.question_id] } + end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at + + student_hash[submitter_course_user] = [submission, answers, end_at] + end + end end diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 2938afa6ed..e41e342d25 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -5,6 +5,21 @@ class Course::Statistics::AssessmentsController < Course::Statistics::Controller before_action :load_course_user_students, except: [:ancestors] + def assessment_statistics + @assessment = Course::Assessment.where(id: assessment_params[:id]). + calculated(:maximum_grade, :question_count). + preload(lesson_plan_item: [:reference_times, personal_times: :course_user], + course: :course_users).first + submissions = Course::Assessment::Submission.where(assessment_id: assessment_params[:id]). + calculated(:grade, :grader_ids). + preload(:answers, creator: :course_users) + @course_users_hash = preload_course_users_hash(current_course) + + fetch_all_ancestor_assessments + create_question_related_hash + @student_submissions_hash = student_submission_hash(submissions, @all_students) + end + def assessment @assessment = Course::Assessment.where(id: assessment_params[:id]). calculated(:maximum_grade). @@ -53,6 +68,17 @@ def load_course_user_students @all_students = current_course.course_users.students end + def fetch_all_ancestor_assessments + current_assessment = Course::Assessment.preload(:duplication_traceable).find(assessment_params[:id]) + @ancestors = [current_assessment] + while current_assessment.duplication_traceable.present? && current_assessment.duplication_traceable.source_id.present? + current_assessment = current_assessment.duplication_traceable.source + break unless can?(:read_ancestor, current_assessment) + + @ancestors.unshift(current_assessment) + end + end + def create_question_related_hash @question_order_hash = @assessment.question_assessments.to_h do |q| [q.question_id, q.weight] diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index bc38c8f476..2602dafa5a 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -91,6 +91,16 @@ class Course::Assessment < ApplicationRecord ) end) + # @!attribute [r] question_count + # Gets the number of questions in this assessment. + # @return [Integer] + calculated :question_count, (lambda do + Course::QuestionAssessment.unscope(:order). + select('coalesce(count(DISTINCT cqa.question_id), 0)'). + joins("INNER JOIN course_question_assessments cqa ON cqa.assessment_id = course_assessments.id"). + group('course_assessments.id') + end) + # @!method self.ordered_by_date_and_title # Orders the assessments by the starting date and title. scope :ordered_by_date_and_title, (lambda do diff --git a/app/views/course/statistics/assessments/assessment_statistics.json.jbuilder b/app/views/course/statistics/assessments/assessment_statistics.json.jbuilder new file mode 100644 index 0000000000..2f9d77f50c --- /dev/null +++ b/app/views/course/statistics/assessments/assessment_statistics.json.jbuilder @@ -0,0 +1,60 @@ +# frozen_string_literal: true +json.assessment do + json.id @assessment.id + json.title @assessment.title + json.startAt @assessment.start_at&.iso8601 + json.endAt @assessment.end_at&.iso8601 + json.maximumGrade @assessment.maximum_grade + json.questionCount @assessment.question_count + json.url course_assessment_path(current_course, @assessment) +end + +json.submissions @student_submissions_hash.each do |course_user, (submission, answers, end_at)| + json.courseUser do + json.id course_user.id + json.name course_user.name + json.role course_user.role + json.isPhantom course_user.phantom? + end + + json.groups course_user.groups do |group| + json.name group.name + end + + json.submissionExists !submission.nil? + + if !submission.nil? + json.workflowState submission.workflow_state + json.submittedAt submission.submitted_at&.iso8601 + json.endAt end_at&.iso8601 + json.totalGrade submission.grade + + if submission.workflow_state == 'published' && submission.grader_ids + # the graders are all the same regardless of question, so we just pick the first one + grader = @course_users_hash[submission.grader_ids.first] + json.grader do + json.id grader&.id || 0 + json.name grader&.name || 'System' + end + + json.answers answers.each do |answer| + json.id answer.id + json.grade answer.grade + json.maximumGrade @question_maximum_grade_hash[answer.question_id] + end + end + end +end + +json.allStudents @all_students.each do |student| + json.id student.id + json.name student.name + json.role student.role + json.isPhantom student.phantom? +end + +json.ancestors @ancestors do |ancestor| + json.id ancestor.id + json.title ancestor.title + json.courseTitle ancestor.course&.title +end diff --git a/client/app/api/course/Statistics/AssessmentStatistics.ts b/client/app/api/course/Statistics/AssessmentStatistics.ts index 29d7c2812e..ee31aef266 100644 --- a/client/app/api/course/Statistics/AssessmentStatistics.ts +++ b/client/app/api/course/Statistics/AssessmentStatistics.ts @@ -2,6 +2,7 @@ import { AssessmentAncestors, AssessmentMarksPerQuestionStats, AssessmentStatistics, + AssessmentStatisticsStats, } from 'types/course/statistics/assessmentStatistics'; import { APIResponse } from 'api/types'; @@ -41,4 +42,12 @@ export default class AssessmentStatisticsAPI extends BaseCourseAPI { `${this.#urlPrefix}/${assessmentId}/marks_per_question`, ); } + + fetchAssessmentStatistics( + assessmentId: string | number, + ): APIResponse { + return this.client.get( + `${this.#urlPrefix}/${assessmentId}/assessment_statistics`, + ); + } } diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts index 7131004678..8547f6b1ac 100644 --- a/client/app/bundles/course/assessment/operations/statistics.ts +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -5,6 +5,7 @@ import CourseAPI from 'api/course'; import { setNotification } from 'lib/actions'; import actionTypes from '../constants'; +import { statisticsActions as actions } from '../reducers/statistics'; import { processAncestor, processAssessment, @@ -35,6 +36,25 @@ export function fetchStatistics( }; } +export function fetchAssessmentStatistics(assessmentId: number): Operation { + return async (dispatch) => { + CourseAPI.statistics.assessment + .fetchAssessmentStatistics(assessmentId) + .then((response) => { + const data = response.data; + dispatch( + actions.initialize({ + assessment: data.assessment, + allStudents: data.allStudents, + submissions: data.submissions, + ancestors: data.ancestors, + isLoading: false, + }), + ); + }); + }; +} + export function fetchAncestors( assessmentId: number, failureMessage: string, diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx index f6e880c2c7..9858b56403 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx @@ -1,20 +1,16 @@ import { FC, useEffect, useState } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; +import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; import { fetchAncestors, - fetchAncestorStatistics, fetchStatistics, } from 'course/assessment/operations/statistics'; -import ErrorCard from 'lib/components/core/ErrorCard'; -import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import AncestorSelect from './AncestorSelect'; -import AncestorStatistics from './AncestorStatistics'; -import { getStatisticsPage } from './selectors'; +import { getAssessmentStatistics } from './selectors'; const translations = defineMessages({ fetchFailure: { @@ -33,6 +29,8 @@ const translations = defineMessages({ const DuplicationHistoryStatistics: FC = () => { const { t } = useTranslation(); + const ancestors = useAppSelector(getAssessmentStatistics).ancestors; + const { assessmentId } = useParams(); const dispatch = useAppDispatch(); @@ -40,7 +38,6 @@ const DuplicationHistoryStatistics: FC = () => { const [selectedAncestorId, setSelectedAncestorId] = useState(parsedAssessmentId); - const statisticsPage = useAppSelector(getStatisticsPage); useEffect(() => { if (assessmentId) { @@ -56,42 +53,30 @@ const DuplicationHistoryStatistics: FC = () => { } }, [assessmentId]); - if (statisticsPage.isFetching) { - return ; - } - - if (statisticsPage.isError) { - return ( - } - /> - ); - } - const fetchAncestorSubmissions = (id: number): void => { if (id === selectedAncestorId) { return; } - dispatch( - fetchAncestorStatistics( - id, - t(translations.fetchAncestorStatisticsFailure), - ), - ); + // dispatch( + // fetchAncestorStatistics( + // id, + // t(translations.fetchAncestorStatisticsFailure), + // ), + // ); setSelectedAncestorId(id); }; return ( <> -
+ {/*
{ statisticsPage.isFetchingAncestorStatistics } /> -
+
*/} ); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx index 69418a8e24..6ef7cc11ec 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx @@ -4,8 +4,11 @@ import { GREEN_CHART_BACKGROUND, GREEN_CHART_BORDER } from 'theme/colors'; import { SubmissionRecordShape } from 'course/assessment/types'; import ViolinChart from 'lib/components/core/charts/ViolinChart'; +import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; +import { getAssessmentStatistics } from './selectors'; + const translations = defineMessages({ yAxisLabel: { id: 'course.assessment.statistics.gradeViolin.yAxisLabel', @@ -22,17 +25,21 @@ const translations = defineMessages({ }); interface Props { - submissions: SubmissionRecordShape[]; + ancestorSubmissions?: SubmissionRecordShape[]; includePhantom: boolean; } const GradeDistributionChart: FC = (props) => { const { t } = useTranslation(); - const { submissions, includePhantom } = props; + const { includePhantom, ancestorSubmissions } = props; + + const statistics = useAppSelector(getAssessmentStatistics); + const submissions = statistics.submissions; + const nonNullSubmissions = submissions.filter((s) => s.answers); const includedSubmissions = includePhantom - ? submissions - : submissions.filter((s) => !s.courseUser.isPhantom); + ? nonNullSubmissions + : nonNullSubmissions.filter((s) => !s.courseUser.isPhantom); const totalGrades = includedSubmissions diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx index 1c70baf948..3d36c861a1 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx @@ -48,24 +48,27 @@ const StatisticsCharts: FC = (props) => { {t(translations.submissionStatuses)}
{t(translations.gradeDistribution)} - + {t(translations.submissionTimeAndGrade)} diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx deleted file mode 100644 index 03c15924ba..0000000000 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionPage.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { FC } from 'react'; -import { useParams } from 'react-router-dom'; -import { AssessmentMarksPerQuestionStats } from 'types/course/statistics/assessmentStatistics'; - -import { fetchStudentMarkPerQuestion } from 'course/assessment/operations/statistics'; -import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import Preload from 'lib/components/wrappers/Preload'; - -import StudentMarksPerQuestionTable from './StudentMarksPerQuestionTable'; - -interface Props { - includePhantom: boolean; -} - -const StudentMarksPerQuestionPage: FC = (props) => { - const { includePhantom } = props; - const { assessmentId } = useParams(); - - const fetchStudentMarks = (): Promise => - fetchStudentMarkPerQuestion(assessmentId!); - - return ( - } while={fetchStudentMarks}> - {(data): JSX.Element => { - const noPhantomStudentSubmissionsData = { - ...data, - submissions: data.submissions.filter( - (datum) => !datum.courseUser.isPhantom, - ), - }; - const displayedData = includePhantom - ? data - : noPhantomStudentSubmissionsData; - return ; - }} - - ); -}; - -export default StudentMarksPerQuestionPage; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index c83c3dcc50..17c5eadd6d 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -3,10 +3,7 @@ import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Box, Chip } from '@mui/material'; import palette from 'theme/palette'; -import { - AssessmentMarksPerQuestionStats, - SubmissionMarksPerQuestionStats, -} from 'types/course/statistics/assessmentStatistics'; +import { SubmissionMarksPerQuestionStats } from 'types/course/statistics/assessmentStatistics'; import { workflowStates } from 'course/assessment/submission/constants'; import Link from 'lib/components/core/Link'; @@ -17,10 +14,10 @@ import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import { getClassNameForMarkCell } from './ColorGradationLevel'; -import { getStatisticsPage } from './selectors'; +import { getAssessmentStatistics } from './selectors'; interface Props { - data: AssessmentMarksPerQuestionStats; + includePhantom: boolean; } const translations = defineMessages({ @@ -81,11 +78,18 @@ const statusTranslations = { const StudentMarksPerQuestionTable: FC = (props) => { const { t } = useTranslation(); const { courseId } = useParams(); - const { data } = props; + const { includePhantom } = props; - const { assessment } = useAppSelector(getStatisticsPage); + const statistics = useAppSelector(getAssessmentStatistics); + const assessment = statistics.assessment; - const sortedSubmission = data.submissions + const submissions = statistics.submissions.slice(); + + const filteredSubmissions = includePhantom + ? submissions + : submissions.filter((s) => !s.courseUser.isPhantom); + + const sortedSubmission = filteredSubmissions .sort((datum1, datum2) => datum1.courseUser.name.localeCompare(datum2.courseUser.name), ) @@ -98,17 +102,13 @@ const StudentMarksPerQuestionTable: FC = (props) => { // the case where the grade is null is handled separately inside the column // (refer to the definition of answerColumns below) const renderNonNullGradeCell = ( - grade: number | null, - maxGrade: number | null, + grade: number, + maxGrade: number, ): ReactNode => { - if (!grade || !maxGrade) { - return null; - } - const className = getClassNameForMarkCell(grade, maxGrade); return (
- {grade} + {grade.toFixed(1)}
); }; @@ -131,17 +131,17 @@ const StudentMarksPerQuestionTable: FC = (props) => { }; const answerColumns: ColumnTemplate[] = - Array.from({ length: data.questionCount }, (_, index) => { + Array.from({ length: assessment?.questionCount ?? 0 }, (_, index) => { return { searchProps: { getValue: (datum) => datum.answers?.[index]?.grade?.toString() ?? '', }, title: t(translations.questionIndex, { index: index + 1 }), cell: (datum): ReactNode => { - return datum.answers?.[index].grade + return typeof datum.answers?.[index].grade === 'number' ? renderNonNullGradeCell( - datum.answers?.[index].grade ?? null, - datum.answers?.[index].maximumGrade ?? null, + datum.answers?.[index].grade, + datum.answers?.[index].maximumGrade, ) : null; }, @@ -224,7 +224,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { datum.totalGrade ? renderNonNullGradeCell( datum.totalGrade ?? null, - assessment.maximumGrade, + assessment!.maximumGrade, ) : null, className: 'text-right', @@ -264,7 +264,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { columns={columns} csvDownload={{ filename: t(translations.filename, { - assessment: assessment.title, + assessment: assessment?.title ?? '', }), }} data={sortedSubmission} diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx index bcad65944b..ec7ff371c1 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx @@ -7,6 +7,9 @@ import { SubmissionRecordShape, } from 'course/assessment/types'; import BarChart from 'lib/components/core/BarChart'; +import { useAppSelector } from 'lib/hooks/store'; + +import { getAssessmentStatistics } from './selectors'; const translations = defineMessages({ datasetLabel: { @@ -36,20 +39,26 @@ const translations = defineMessages({ }); interface Props { - submissions: SubmissionRecordShape[]; - allStudents: CourseUserShape[]; + ancestorSubmissions?: SubmissionRecordShape[]; + ancestorAllStudents?: CourseUserShape[]; includePhantom: boolean; } const SubmissionStatusChart = (props: Props): JSX.Element => { - const { submissions, allStudents, includePhantom } = props; + const { ancestorSubmissions, includePhantom, ancestorAllStudents } = props; + const statistics = useAppSelector(getAssessmentStatistics); + + const submissions = statistics.submissions.slice(); + const allStudents = statistics.allStudents.slice(); + + const nonNullSubmissions = submissions.filter((s) => s.submissionExists); const numStudents = includePhantom ? allStudents.length : allStudents.filter((s) => !s.isPhantom).length; const includedSubmissions = includePhantom - ? submissions - : submissions.filter((s) => !s.courseUser.isPhantom); + ? nonNullSubmissions + : nonNullSubmissions.filter((s) => !s.courseUser.isPhantom); const numUnstarted = numStudents - includedSubmissions.length; const numAttempting = includedSubmissions.filter( diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx index c496d25a37..e4d14ccf53 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx @@ -9,8 +9,10 @@ import { import { SubmissionRecordShape } from 'course/assessment/types'; import GeneralChart from 'lib/components/core/charts/GeneralChart'; +import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; +import { getAssessmentStatistics } from './selectors'; import { processSubmissionsIntoChartData } from './utils'; const translations = defineMessages({ @@ -33,16 +35,21 @@ const translations = defineMessages({ }); interface Props { - submissions: SubmissionRecordShape[]; + ancestorSubmissions?: SubmissionRecordShape[]; includePhantom: boolean; } const SubmissionTimeAndGradeChart: FC = (props) => { const { t } = useTranslation(); - const { submissions, includePhantom } = props; + const { ancestorSubmissions, includePhantom } = props; + const statistics = useAppSelector(getAssessmentStatistics); + + const submissions = statistics.submissions; + + const nonNullSubmissions = submissions.filter((s) => s.answers); const includedSubmissions = includePhantom - ? submissions - : submissions.filter((s) => !s.courseUser.isPhantom); + ? nonNullSubmissions + : nonNullSubmissions.filter((s) => !s.courseUser.isPhantom); const { labels, lineData, barData } = processSubmissionsIntoChartData(includedSubmissions); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx index 31869ee7a8..00f9b1b669 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx @@ -3,16 +3,18 @@ import { defineMessages, FormattedMessage } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Box, FormControlLabel, Switch, Tab, Tabs } from '@mui/material'; -import { fetchStatistics } from 'course/assessment/operations/statistics'; -import { SubmissionRecordShape } from 'course/assessment/types'; +import { fetchAssessmentStatistics } from 'course/assessment/operations/statistics'; +import { statisticsActions as actions } from 'course/assessment/reducers/statistics'; import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; import DuplicationHistoryStatistics from './DuplicationHistoryStatistics'; import GradeDistributionChart from './GradeDistributionChart'; -import { getStatisticsPage } from './selectors'; -import StudentMarksPerQuestionPage from './StudentMarksPerQuestionPage'; +import { getAssessmentStatistics } from './selectors'; +import StudentMarksPerQuestionTable from './StudentMarksPerQuestionTable'; import SubmissionStatusChart from './SubmissionStatusChart'; import SubmissionTimeAndGradeChart from './SubmissionTimeAndGradeChart'; @@ -68,53 +70,43 @@ const AssessmentStatisticsPage: FC = () => { const parsedAssessmentId = parseInt(assessmentId!, 10); const dispatch = useAppDispatch(); - const statisticsPage = useAppSelector(getStatisticsPage); - const allStudents = statisticsPage.allStudents; + const statistics = useAppSelector(getAssessmentStatistics); useEffect(() => { - if (assessmentId) { - dispatch( - fetchStatistics(parsedAssessmentId, t(translations.fetchFailure)), - ); - } - }, [assessmentId]); - - const submissions = statisticsPage.submissions as SubmissionRecordShape[]; + dispatch(actions.reset()); + dispatch(fetchAssessmentStatistics(parsedAssessmentId)).catch(() => + toast.error(t(translations.fetchFailure)), + ); + }, [dispatch, parsedAssessmentId]); const tabComponentMapping = { marksPerQuestion: ( - + ), gradeDistribution: ( - + ), submissionTimeAndGrade: ( - + ), duplicationHistory: , }; + if (statistics.isLoading) { + return ; + } + return ( <> - + state.assessments.statisticsPage; +const getLocalState = (state: AppState) => state.assessments; -export const getStatisticsPage = (state: AppState) => getLocalState(state); +export const getStatisticsPage = (state: AppState) => + getLocalState(state).statisticsPage; + +export const getAssessmentStatistics = (state: AppState) => + getLocalState(state).statistics; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js b/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js index f264a17c7a..7d569d4621 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js @@ -16,7 +16,9 @@ export function processSubmissionsIntoChartData(submissions) { displayValue: s.dayDifference != null ? processDayDifference(s.dayDifference) - : `${s.submittedAt.getFullYear()}-${s.submittedAt.getMonth()}-${s.submittedAt.getDate()}`, + : `${new Date(s.submittedAt).getFullYear()}-${new Date( + s.submittedAt, + ).getMonth()}-${new Date(s.submittedAt).getDate()}`, })) .sort((a, b) => { if (a.dayDifference != null) { diff --git a/client/app/bundles/course/assessment/reducers/statistics.ts b/client/app/bundles/course/assessment/reducers/statistics.ts new file mode 100644 index 0000000000..80bf0bd58d --- /dev/null +++ b/client/app/bundles/course/assessment/reducers/statistics.ts @@ -0,0 +1,38 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { AssessmentStatisticsStore } from 'types/course/statistics/assessmentStatistics'; + +import { + processAncestor, + processAssessment, + processCourseUser, + processSubmission, +} from '../utils/statisticsUtils'; + +const initialState: AssessmentStatisticsStore = { + assessment: null, + allStudents: [], + submissions: [], + ancestors: [], + isLoading: true, +}; + +export const statisticsSlice = createSlice({ + name: 'statistics', + initialState, + reducers: { + initialize: (state, action: PayloadAction) => { + state.assessment = processAssessment(action.payload.assessment); + state.allStudents = action.payload.allStudents.map(processCourseUser); + state.submissions = action.payload.submissions.map(processSubmission); + state.ancestors = action.payload.ancestors.map(processAncestor); + state.isLoading = action.payload.isLoading; + }, + reset: () => { + return initialState; + }, + }, +}); + +export const statisticsActions = statisticsSlice.actions; + +export default statisticsSlice.reducer; diff --git a/client/app/bundles/course/assessment/store.ts b/client/app/bundles/course/assessment/store.ts index 9e8d13ad0b..73e37c6c30 100644 --- a/client/app/bundles/course/assessment/store.ts +++ b/client/app/bundles/course/assessment/store.ts @@ -3,6 +3,7 @@ import { combineReducers } from 'redux'; import editPageReducer from './reducers/editPage'; import formDialogReducer from './reducers/formDialog'; import monitoringReducer from './reducers/monitoring'; +import statisticsReducer from './reducers/statistics'; import statisticsPageReducer from './reducers/statisticsPage'; import submissionReducer from './submission/reducers'; @@ -10,6 +11,7 @@ const reducer = combineReducers({ formDialog: formDialogReducer, editPage: editPageReducer, monitoring: monitoringReducer, + statistics: statisticsReducer, statisticsPage: statisticsPageReducer, submission: submissionReducer, }); diff --git a/client/app/bundles/course/assessment/utils/statisticsUtils.js b/client/app/bundles/course/assessment/utils/statisticsUtils.js index 090c2183b8..3ca2f2e5c9 100644 --- a/client/app/bundles/course/assessment/utils/statisticsUtils.js +++ b/client/app/bundles/course/assessment/utils/statisticsUtils.js @@ -3,11 +3,19 @@ export const processCourseUser = (courseUser) => ({ id: parseInt(courseUser.id, 10), }); +const processAnswer = (answer) => ({ + ...answer, + grade: parseFloat(answer.grade), + maximumGrade: parseFloat(answer.maximumGrade), +}); + export const processSubmission = (submission) => { const totalGrade = submission.totalGrade != null ? parseFloat(submission.totalGrade) : submission.totalGrade; + const answers = + submission.answers != null ? submission.answers.map(processAnswer) : null; const submittedAt = submission.submittedAt != null ? new Date(submission.submittedAt) @@ -21,6 +29,7 @@ export const processSubmission = (submission) => { return { ...submission, + answers, totalGrade, submittedAt, endAt, diff --git a/client/app/lib/components/core/BarChart.jsx b/client/app/lib/components/core/BarChart.jsx index 0944a220f6..db964702b5 100644 --- a/client/app/lib/components/core/BarChart.jsx +++ b/client/app/lib/components/core/BarChart.jsx @@ -1,5 +1,7 @@ +import { memo } from 'react'; import { Tooltip } from 'react-tooltip'; import { Typography } from '@mui/material'; +import { equal } from 'fast-deep-equal'; import PropTypes from 'prop-types'; const styles = { @@ -55,4 +57,4 @@ BarChart.propTypes = { ).isRequired, }; -export default BarChart; +export default memo(BarChart, equal); diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 9734eaba12..98772ed57a 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -1,9 +1,10 @@ interface Assessment { - id: number | string; + id: number; title: string; startAt: string | null; endAt: string | null; maximumGrade: number; + questionCount?: number; url: string; } @@ -13,8 +14,9 @@ interface SubmissionInfo { } export interface SubmissionWithTimeInfo extends SubmissionInfo { - submittedAt: string; - endAt: string; + submittedAt: Date; + endAt: Date; + dayDifference?: number; } interface UserInfo { @@ -34,7 +36,7 @@ export interface AssessmentStatistics { } export interface AssessmentAncestor { - id: number | string; + id: number; title: string; courseTitle: string; } @@ -56,7 +58,26 @@ export interface SubmissionMarksPerQuestionStats extends SubmissionInfo { answers?: AnswerGradeStats[]; } +export interface SubmissionDetailsStats extends SubmissionWithTimeInfo { + submissionExists: boolean; + grader?: UserInfo; + groups?: { name: string }[]; + workflowState?: string; + answers?: AnswerGradeStats[]; +} + export interface AssessmentMarksPerQuestionStats { questionCount: number; submissions: SubmissionMarksPerQuestionStats[]; } + +export interface AssessmentStatisticsStats { + assessment: Assessment | null; + allStudents: StudentInfo[]; + submissions: SubmissionDetailsStats[]; + ancestors: AssessmentAncestor[]; +} + +export interface AssessmentStatisticsStore extends AssessmentStatisticsStats { + isLoading: boolean; +} diff --git a/config/routes.rb b/config/routes.rb index 9e01e2f578..1b4d8f7093 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -439,6 +439,7 @@ get 'assessment/:id' => 'assessments#assessment' get 'assessment/:id/ancestors' => 'assessments#ancestors' get 'assessment/:id/marks_per_question' => 'assessments#marks_per_question' + get 'assessment/:id/assessment_statistics' => 'assessments#assessment_statistics' end scope module: :video do From 6e891387b3a1fab0481b3f08fbf7636045dc6305 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 30 Jan 2024 07:46:51 +0800 Subject: [PATCH 19/45] cleanup redundant variables - remove all redundant types - remove some routes as being merged --- .../course/statistics/submissions_concern.rb | 38 ------------ .../statistics/assessments_controller.rb | 25 +------- .../course/Statistics/AssessmentStatistics.ts | 19 ------ .../assessment/operations/statistics.ts | 33 ---------- .../DuplicationHistoryStatistics.tsx | 48 +-------------- .../StudentMarksPerQuestionTable.tsx | 24 ++++---- .../pages/AssessmentStatistics/selectors.ts | 13 +--- .../course/assessment/reducers/statistics.ts | 11 +--- client/app/bundles/course/assessment/types.ts | 4 +- .../assessment/utils/statisticsUtils.js | 1 - .../course/statistics/assessmentStatistics.ts | 61 +++++++------------ config/routes.rb | 2 - 12 files changed, 45 insertions(+), 234 deletions(-) diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 37bc811791..43beaae0ef 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -6,20 +6,6 @@ def initialize_student_hash(students) students.to_h { |student| [student, nil] } end - def student_submission_end_time_hash(submissions, students) - student_hash = initialize_student_hash(students) - - populate_with_submission_and_end_time(student_hash, submissions) - student_hash - end - - def student_submission_marks_hash(submissions, students) - student_hash = initialize_student_hash(students) - - populate_with_submission_and_ordered_answer(student_hash, submissions) - student_hash - end - def student_submission_hash(submissions, students) student_hash = initialize_student_hash(students) @@ -27,30 +13,6 @@ def student_submission_hash(submissions, students) student_hash end - def populate_with_submission_and_end_time(student_hash, submissions) - submissions.map do |submission| - submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first - next unless submitter_course_user&.student? - - end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at - - student_hash[submitter_course_user] = [submission, end_at] - end - end - - def populate_with_submission_and_ordered_answer(student_hash, submissions) - submissions.map do |submission| - submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first - next unless submitter_course_user&.student? - - answers = submission.answers. - select(&:current_answer). - sort_by { |answer| @question_order_hash[answer.question_id] } - - student_hash[submitter_course_user] = [submission, answers] - end - end - def populate_with_submission_info(student_hash, submissions) submissions.map do |submission| submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index e41e342d25..8c20cb0bf6 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -3,7 +3,7 @@ class Course::Statistics::AssessmentsController < Course::Statistics::Controller include Course::UsersHelper include Course::Statistics::SubmissionsConcern - before_action :load_course_user_students, except: [:ancestors] + before_action :load_course_user_students def assessment_statistics @assessment = Course::Assessment.where(id: assessment_params[:id]). @@ -35,29 +35,6 @@ def assessment @student_submissions_hash = student_submission_end_time_hash(submissions, @all_students).compact end - def ancestors - @assessment = Course::Assessment.preload(:duplication_traceable).find(assessment_params[:id]) - @assessments = [@assessment] - while @assessment.duplication_traceable.present? && @assessment.duplication_traceable.source_id.present? - @assessment = @assessment.duplication_traceable.source - break unless can?(:read_ancestor, @assessment) - - @assessments.unshift(@assessment) - end - end - - def marks_per_question - @assessment = Course::Assessment.where(id: assessment_params[:id]). - preload(course: :course_users).first - submissions = Course::Assessment::Submission.preload(:answers, creator: :course_users). - where(assessment_id: assessment_params[:id]). - calculated(:grade, :grader_ids) - @course_users_hash = preload_course_users_hash(current_course) - - create_question_related_hash - @student_submissions_hash = student_submission_marks_hash(submissions, @all_students) - end - private def assessment_params diff --git a/client/app/api/course/Statistics/AssessmentStatistics.ts b/client/app/api/course/Statistics/AssessmentStatistics.ts index ee31aef266..dfe02e98fb 100644 --- a/client/app/api/course/Statistics/AssessmentStatistics.ts +++ b/client/app/api/course/Statistics/AssessmentStatistics.ts @@ -1,6 +1,4 @@ import { - AssessmentAncestors, - AssessmentMarksPerQuestionStats, AssessmentStatistics, AssessmentStatisticsStats, } from 'types/course/statistics/assessmentStatistics'; @@ -26,23 +24,6 @@ export default class AssessmentStatisticsAPI extends BaseCourseAPI { return this.client.get(`${this.#urlPrefix}/${assessmentId}`); } - /** - * Fetches the ancestors for a specific individual assessment. - */ - fetchAncestors( - assessmentId: string | number, - ): APIResponse { - return this.client.get(`${this.#urlPrefix}/${assessmentId}/ancestors`); - } - - fetchMarksPerQuestionStats( - assessmentId: string | number, - ): APIResponse { - return this.client.get( - `${this.#urlPrefix}/${assessmentId}/marks_per_question`, - ); - } - fetchAssessmentStatistics( assessmentId: string | number, ): APIResponse { diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts index 8547f6b1ac..eb9b3aac20 100644 --- a/client/app/bundles/course/assessment/operations/statistics.ts +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -1,5 +1,4 @@ import { Operation } from 'store'; -import { AssessmentMarksPerQuestionStats } from 'types/course/statistics/assessmentStatistics'; import CourseAPI from 'api/course'; import { setNotification } from 'lib/actions'; @@ -7,7 +6,6 @@ import { setNotification } from 'lib/actions'; import actionTypes from '../constants'; import { statisticsActions as actions } from '../reducers/statistics'; import { - processAncestor, processAssessment, processCourseUser, processSubmission, @@ -55,27 +53,6 @@ export function fetchAssessmentStatistics(assessmentId: number): Operation { }; } -export function fetchAncestors( - assessmentId: number, - failureMessage: string, -): Operation { - return async (dispatch) => { - dispatch({ type: actionTypes.FETCH_ANCESTORS_REQUEST }); - return CourseAPI.statistics.assessment - .fetchAncestors(assessmentId) - .then((response) => { - dispatch({ - type: actionTypes.FETCH_ANCESTORS_SUCCESS, - ancestors: response.data.assessments.map(processAncestor), - }); - }) - .catch(() => { - dispatch({ type: actionTypes.FETCH_ANCESTORS_FAILURE }); - dispatch(setNotification(failureMessage)); - }); - }; -} - export function fetchAncestorStatistics( ancestorId: number, failureMessage: string, @@ -98,13 +75,3 @@ export function fetchAncestorStatistics( }); }; } - -export const fetchStudentMarkPerQuestion = async ( - assessmentId: string | number, -): Promise => { - const response = - await CourseAPI.statistics.assessment.fetchMarksPerQuestionStats( - assessmentId, - ); - return response.data; -}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx index 9858b56403..12401d8a1d 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx @@ -1,68 +1,24 @@ -import { FC, useEffect, useState } from 'react'; -import { defineMessages } from 'react-intl'; +import { FC, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { - fetchAncestors, - fetchStatistics, -} from 'course/assessment/operations/statistics'; -import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; -import useTranslation from 'lib/hooks/useTranslation'; +import { useAppSelector } from 'lib/hooks/store'; import AncestorSelect from './AncestorSelect'; import { getAssessmentStatistics } from './selectors'; -const translations = defineMessages({ - fetchFailure: { - id: 'course.assessment.statistics.fail', - defaultMessage: 'Failed to fetch statistics.', - }, - fetchAncestorsFailure: { - id: 'course.assessment.statistics.ancestorFail', - defaultMessage: 'Failed to fetch past iterations of this assessment.', - }, - fetchAncestorStatisticsFailure: { - id: 'course.assessment.statistics.ancestorStatisticsFail', - defaultMessage: "Failed to fetch ancestor's statistics.", - }, -}); - const DuplicationHistoryStatistics: FC = () => { - const { t } = useTranslation(); const ancestors = useAppSelector(getAssessmentStatistics).ancestors; const { assessmentId } = useParams(); - const dispatch = useAppDispatch(); - const parsedAssessmentId = parseInt(assessmentId!, 10); const [selectedAncestorId, setSelectedAncestorId] = useState(parsedAssessmentId); - useEffect(() => { - if (assessmentId) { - dispatch( - fetchStatistics(parsedAssessmentId, t(translations.fetchFailure)), - ); - dispatch( - fetchAncestors( - parsedAssessmentId, - t(translations.fetchAncestorsFailure), - ), - ); - } - }, [assessmentId]); - const fetchAncestorSubmissions = (id: number): void => { if (id === selectedAncestorId) { return; } - // dispatch( - // fetchAncestorStatistics( - // id, - // t(translations.fetchAncestorStatisticsFailure), - // ), - // ); setSelectedAncestorId(id); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 17c5eadd6d..ff8779b39b 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -3,7 +3,7 @@ import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Box, Chip } from '@mui/material'; import palette from 'theme/palette'; -import { SubmissionMarksPerQuestionStats } from 'types/course/statistics/assessmentStatistics'; +import { SubmissionDetailsStats } from 'types/course/statistics/assessmentStatistics'; import { workflowStates } from 'course/assessment/submission/constants'; import Link from 'lib/components/core/Link'; @@ -16,10 +16,6 @@ import useTranslation from 'lib/hooks/useTranslation'; import { getClassNameForMarkCell } from './ColorGradationLevel'; import { getAssessmentStatistics } from './selectors'; -interface Props { - includePhantom: boolean; -} - const translations = defineMessages({ name: { id: 'course.assessment.statistics.name', @@ -67,6 +63,10 @@ const translations = defineMessages({ }, }); +interface Props { + includePhantom: boolean; +} + const statusTranslations = { attempting: 'Attempting', submitted: 'Submitted', @@ -130,8 +130,9 @@ const StudentMarksPerQuestionTable: FC = (props) => { return grade1 - grade2; }; - const answerColumns: ColumnTemplate[] = - Array.from({ length: assessment?.questionCount ?? 0 }, (_, index) => { + const answerColumns: ColumnTemplate[] = Array.from( + { length: assessment?.questionCount ?? 0 }, + (_, index) => { return { searchProps: { getValue: (datum) => datum.answers?.[index]?.grade?.toString() ?? '', @@ -157,12 +158,13 @@ const StudentMarksPerQuestionTable: FC = (props) => { }, }, }; - }); + }, + ); - const jointGroupsName = (datum: SubmissionMarksPerQuestionStats): string => + const jointGroupsName = (datum: SubmissionDetailsStats): string => datum.groups ? datum.groups.map((g) => g.name).join(', ') : ''; - const columns: ColumnTemplate[] = [ + const columns: ColumnTemplate[] = [ { searchProps: { getValue: (datum) => datum.courseUser.name, @@ -271,7 +273,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { getRowClassName={(datum): string => `data_${datum.courseUser.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` } - getRowEqualityData={(datum): SubmissionMarksPerQuestionStats => datum} + getRowEqualityData={(datum): SubmissionDetailsStats => datum} getRowId={(datum): string => datum.courseUser.id.toString()} indexing={{ indices: true }} pagination={{ diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts index 3dfdf37a41..0d3e99b468 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/selectors.ts @@ -1,13 +1,6 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -// TODO: define store for statistics page (in next PR) import { AppState } from 'store'; import { AssessmentStatisticsStore } from 'types/course/statistics/assessmentStatistics'; -import { selectEntity } from 'utilities/store'; -const getLocalState = (state: AppState) => state.assessments; - -export const getStatisticsPage = (state: AppState) => - getLocalState(state).statisticsPage; - -export const getAssessmentStatistics = (state: AppState) => - getLocalState(state).statistics; +export const getAssessmentStatistics = ( + state: AppState, +): AssessmentStatisticsStore => state.assessments.statistics; diff --git a/client/app/bundles/course/assessment/reducers/statistics.ts b/client/app/bundles/course/assessment/reducers/statistics.ts index 80bf0bd58d..6a83accca4 100644 --- a/client/app/bundles/course/assessment/reducers/statistics.ts +++ b/client/app/bundles/course/assessment/reducers/statistics.ts @@ -1,12 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { AssessmentStatisticsStore } from 'types/course/statistics/assessmentStatistics'; -import { - processAncestor, - processAssessment, - processCourseUser, - processSubmission, -} from '../utils/statisticsUtils'; +import { processAssessment, processSubmission } from '../utils/statisticsUtils'; const initialState: AssessmentStatisticsStore = { assessment: null, @@ -22,9 +17,9 @@ export const statisticsSlice = createSlice({ reducers: { initialize: (state, action: PayloadAction) => { state.assessment = processAssessment(action.payload.assessment); - state.allStudents = action.payload.allStudents.map(processCourseUser); + state.allStudents = action.payload.allStudents; state.submissions = action.payload.submissions.map(processSubmission); - state.ancestors = action.payload.ancestors.map(processAncestor); + state.ancestors = action.payload.ancestors; state.isLoading = action.payload.isLoading; }, reset: () => { diff --git a/client/app/bundles/course/assessment/types.ts b/client/app/bundles/course/assessment/types.ts index 6500ed884d..820923c4b5 100644 --- a/client/app/bundles/course/assessment/types.ts +++ b/client/app/bundles/course/assessment/types.ts @@ -1,6 +1,6 @@ import { WorkflowState } from 'types/course/assessment/submission/submission'; import { CourseUserRoles } from 'types/course/courseUsers'; -import { SubmissionWithTimeInfo } from 'types/course/statistics/assessmentStatistics'; +import { SubmissionInfo } from 'types/course/statistics/assessmentStatistics'; export interface CourseUserShape { id: number; @@ -9,7 +9,7 @@ export interface CourseUserShape { isPhantom: boolean; } -export interface SubmissionRecordShape extends SubmissionWithTimeInfo { +export interface SubmissionRecordShape extends SubmissionInfo { workflowState: WorkflowState; dayDifference: number; } diff --git a/client/app/bundles/course/assessment/utils/statisticsUtils.js b/client/app/bundles/course/assessment/utils/statisticsUtils.js index 3ca2f2e5c9..80c2ed3722 100644 --- a/client/app/bundles/course/assessment/utils/statisticsUtils.js +++ b/client/app/bundles/course/assessment/utils/statisticsUtils.js @@ -40,7 +40,6 @@ export const processSubmission = (submission) => { export const processAssessment = (assessment) => ({ ...assessment, - id: parseInt(assessment.id, 10), startAt: new Date(assessment.startAt), endAt: assessment.endAt == null ? null : new Date(assessment.endAt), maximumGrade: parseFloat(assessment.maximumGrade), diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 98772ed57a..9da3954a8c 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -8,19 +8,8 @@ interface Assessment { url: string; } -interface SubmissionInfo { - courseUser: StudentInfo; - totalGrade?: number | null; -} - -export interface SubmissionWithTimeInfo extends SubmissionInfo { - submittedAt: Date; - endAt: Date; - dayDifference?: number; -} - interface UserInfo { - id: number | string; + id: number; name: string; } @@ -29,53 +18,45 @@ interface StudentInfo extends UserInfo { isPhantom: boolean; } -export interface AssessmentStatistics { - assessment: Assessment; - submissions: SubmissionWithTimeInfo[]; - allStudents: StudentInfo[]; -} - -export interface AssessmentAncestor { - id: number; - title: string; - courseTitle: string; -} - -export interface AssessmentAncestors { - assessments: AssessmentAncestor[]; -} - -export interface AnswerGradeStats { +interface AnswerStats { id: number; grade: number; maximumGrade: number; } -export interface SubmissionMarksPerQuestionStats extends SubmissionInfo { - grader?: UserInfo; - groups?: { name: string }[]; - workflowState?: string; - answers?: AnswerGradeStats[]; +export interface SubmissionInfo { + courseUser: StudentInfo; + totalGrade?: number | null; + submittedAt: Date; + endAt: Date; + dayDifference?: number; } -export interface SubmissionDetailsStats extends SubmissionWithTimeInfo { +export interface SubmissionDetailsStats extends SubmissionInfo { submissionExists: boolean; grader?: UserInfo; groups?: { name: string }[]; workflowState?: string; - answers?: AnswerGradeStats[]; + answers?: AnswerStats[]; } -export interface AssessmentMarksPerQuestionStats { - questionCount: number; - submissions: SubmissionMarksPerQuestionStats[]; +export interface AssessmentStatistics { + assessment: Assessment; + submissions: SubmissionInfo[]; + allStudents: StudentInfo[]; +} + +export interface AncestorInfo { + id: number; + title: string; + courseTitle: string; } export interface AssessmentStatisticsStats { assessment: Assessment | null; allStudents: StudentInfo[]; submissions: SubmissionDetailsStats[]; - ancestors: AssessmentAncestor[]; + ancestors: AncestorInfo[]; } export interface AssessmentStatisticsStore extends AssessmentStatisticsStats { diff --git a/config/routes.rb b/config/routes.rb index 1b4d8f7093..80a1c42604 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -437,8 +437,6 @@ get 'course/course/performance' => 'aggregate#course_performance' get 'user/:user_id/learning_rate_records' => 'users#learning_rate_records' get 'assessment/:id' => 'assessments#assessment' - get 'assessment/:id/ancestors' => 'assessments#ancestors' - get 'assessment/:id/marks_per_question' => 'assessments#marks_per_question' get 'assessment/:id/assessment_statistics' => 'assessments#assessment_statistics' end From bb9a67b1b803b7559a016ff679e6c6ebfdcb4ef5 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 31 Jan 2024 00:28:48 +0800 Subject: [PATCH 20/45] simplify controller and API Call - combine ancestor info and marks per question into main statistics - remove all unnecessary controllers --- .../course/statistics/submissions_concern.rb | 24 +++++++- .../statistics/assessments_controller.rb | 15 ++--- ...lder => ancestor_statistics.json.jbuilder} | 0 .../assessments/ancestors.json.jbuilder | 6 -- ...jbuilder => main_statistics.json.jbuilder} | 0 .../marks_per_question.json.jbuilder | 34 ----------- .../course/Statistics/AssessmentStatistics.ts | 20 +++--- .../assessment/operations/statistics.ts | 61 +++---------------- config/routes.rb | 4 +- 9 files changed, 51 insertions(+), 113 deletions(-) rename app/views/course/statistics/assessments/{assessment.json.jbuilder => ancestor_statistics.json.jbuilder} (100%) delete mode 100644 app/views/course/statistics/assessments/ancestors.json.jbuilder rename app/views/course/statistics/assessments/{assessment_statistics.json.jbuilder => main_statistics.json.jbuilder} (100%) delete mode 100644 app/views/course/statistics/assessments/marks_per_question.json.jbuilder diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 43beaae0ef..6d76937843 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -6,14 +6,21 @@ def initialize_student_hash(students) students.to_h { |student| [student, nil] } end - def student_submission_hash(submissions, students) + def fetch_hash_for_main_assessment(submissions, students) student_hash = initialize_student_hash(students) - populate_with_submission_info(student_hash, submissions) + populate_hash_including_answers(student_hash, submissions) student_hash end - def populate_with_submission_info(student_hash, submissions) + def fetch_hash_for_ancestor_assessment(submissions, students) + student_hash = initialize_student_hash(students) + + populate_hash_without_answers(student_hash, submissions) + student_hash + end + + def populate_hash_including_answers(student_hash, submissions) submissions.map do |submission| submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first next unless submitter_course_user&.student? @@ -26,4 +33,15 @@ def populate_with_submission_info(student_hash, submissions) student_hash[submitter_course_user] = [submission, answers, end_at] end end + + def populate_hash_without_answers(student_hash, submissions) + submissions.map do |submission| + submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first + next unless submitter_course_user&.student? + + end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at + + student_hash[submitter_course_user] = [submission, end_at] + end + end end diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 8c20cb0bf6..67b1cf540d 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -3,9 +3,7 @@ class Course::Statistics::AssessmentsController < Course::Statistics::Controller include Course::UsersHelper include Course::Statistics::SubmissionsConcern - before_action :load_course_user_students - - def assessment_statistics + def main_statistics @assessment = Course::Assessment.where(id: assessment_params[:id]). calculated(:maximum_grade, :question_count). preload(lesson_plan_item: [:reference_times, personal_times: :course_user], @@ -15,12 +13,13 @@ def assessment_statistics preload(:answers, creator: :course_users) @course_users_hash = preload_course_users_hash(current_course) + load_course_user_students fetch_all_ancestor_assessments create_question_related_hash - @student_submissions_hash = student_submission_hash(submissions, @all_students) + @student_submissions_hash = fetch_hash_for_main_assessment(submissions, @all_students) end - def assessment + def ancestor_statistics @assessment = Course::Assessment.where(id: assessment_params[:id]). calculated(:maximum_grade). preload(lesson_plan_item: [:reference_times, personal_times: :course_user], @@ -29,10 +28,12 @@ def assessment submissions = Course::Assessment::Submission.preload(creator: :course_users). where(assessment_id: assessment_params[:id]). calculated(:grade) + + load_course_user_students # we do not need the nil value for this hash, since we aim only # to display the statistics charts - @student_submissions_hash = student_submission_end_time_hash(submissions, @all_students).compact + @student_submissions_hash = fetch_hash_for_ancestor_assessment(submissions, @all_students).compact end private @@ -42,7 +43,7 @@ def assessment_params end def load_course_user_students - @all_students = current_course.course_users.students + @all_students = @assessment.course.course_users.students end def fetch_all_ancestor_assessments diff --git a/app/views/course/statistics/assessments/assessment.json.jbuilder b/app/views/course/statistics/assessments/ancestor_statistics.json.jbuilder similarity index 100% rename from app/views/course/statistics/assessments/assessment.json.jbuilder rename to app/views/course/statistics/assessments/ancestor_statistics.json.jbuilder diff --git a/app/views/course/statistics/assessments/ancestors.json.jbuilder b/app/views/course/statistics/assessments/ancestors.json.jbuilder deleted file mode 100644 index 2ca9c2bd7e..0000000000 --- a/app/views/course/statistics/assessments/ancestors.json.jbuilder +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -json.assessments @assessments do |assessment| - json.id assessment.id - json.title assessment.title - json.courseTitle assessment.course&.title -end diff --git a/app/views/course/statistics/assessments/assessment_statistics.json.jbuilder b/app/views/course/statistics/assessments/main_statistics.json.jbuilder similarity index 100% rename from app/views/course/statistics/assessments/assessment_statistics.json.jbuilder rename to app/views/course/statistics/assessments/main_statistics.json.jbuilder diff --git a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder b/app/views/course/statistics/assessments/marks_per_question.json.jbuilder deleted file mode 100644 index e42ae4287e..0000000000 --- a/app/views/course/statistics/assessments/marks_per_question.json.jbuilder +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true -json.questionCount @question_order_hash.size -json.submissions @student_submissions_hash.each do |course_user, (submission, answers)| - json.courseUser do - json.id course_user.id - json.name course_user.name - json.role course_user.role - json.isPhantom course_user.phantom? - end - - json.groups course_user.groups do |group| - json.name group.name - end - - if !submission.nil? && !answers.nil? - json.totalGrade submission.grade - json.workflowState submission.workflow_state - - if submission.workflow_state == 'published' && submission.grader_ids - # the graders are all the same regardless of question, so we just pick the first one - grader = @course_users_hash[submission.grader_ids.first] - json.grader do - json.id grader&.id || 0 - json.name grader&.name || 'System' - end - - json.answers answers.each do |answer| - json.id answer.id - json.grade answer.grade - json.maximumGrade @question_maximum_grade_hash[answer.question_id] - end - end - end -end diff --git a/client/app/api/course/Statistics/AssessmentStatistics.ts b/client/app/api/course/Statistics/AssessmentStatistics.ts index dfe02e98fb..03d329eba7 100644 --- a/client/app/api/course/Statistics/AssessmentStatistics.ts +++ b/client/app/api/course/Statistics/AssessmentStatistics.ts @@ -1,6 +1,6 @@ import { - AssessmentStatistics, - AssessmentStatisticsStats, + AncestorAssessmentStats, + MainAssessmentStats, } from 'types/course/statistics/assessmentStatistics'; import { APIResponse } from 'api/types'; @@ -18,17 +18,19 @@ export default class AssessmentStatisticsAPI extends BaseCourseAPI { * * This is used both for an assessment and for its ancestors. */ - fetchStatistics( - assessmentId: string | number, - ): APIResponse { - return this.client.get(`${this.#urlPrefix}/${assessmentId}`); + fetchAncestorStatistics( + ancestorId: string | number, + ): APIResponse { + return this.client.get( + `${this.#urlPrefix}/${ancestorId}/ancestor_statistics/`, + ); } - fetchAssessmentStatistics( + fetchMainStatistics( assessmentId: string | number, - ): APIResponse { + ): APIResponse { return this.client.get( - `${this.#urlPrefix}/${assessmentId}/assessment_statistics`, + `${this.#urlPrefix}/${assessmentId}/main_statistics`, ); } } diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts index eb9b3aac20..cd60ed57c0 100644 --- a/client/app/bundles/course/assessment/operations/statistics.ts +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -1,43 +1,14 @@ import { Operation } from 'store'; +import { AncestorAssessmentStats } from 'types/course/statistics/assessmentStatistics'; import CourseAPI from 'api/course'; -import { setNotification } from 'lib/actions'; -import actionTypes from '../constants'; import { statisticsActions as actions } from '../reducers/statistics'; -import { - processAssessment, - processCourseUser, - processSubmission, -} from '../utils/statisticsUtils'; - -export function fetchStatistics( - assessmentId: number, - failureMessage: string, -): Operation { - return async (dispatch) => { - dispatch({ type: actionTypes.FETCH_STATISTICS_REQUEST }); - return CourseAPI.statistics.assessment - .fetchStatistics(assessmentId) - .then((response) => { - dispatch({ - type: actionTypes.FETCH_STATISTICS_SUCCESS, - assessment: processAssessment(response.data.assessment), - submissions: response.data.submissions.map(processSubmission), - allStudents: response.data.allStudents.map(processCourseUser), - }); - }) - .catch(() => { - dispatch({ type: actionTypes.FETCH_STATISTICS_FAILURE }); - dispatch(setNotification(failureMessage)); - }); - }; -} export function fetchAssessmentStatistics(assessmentId: number): Operation { return async (dispatch) => { CourseAPI.statistics.assessment - .fetchAssessmentStatistics(assessmentId) + .fetchMainStatistics(assessmentId) .then((response) => { const data = response.data; dispatch( @@ -53,25 +24,11 @@ export function fetchAssessmentStatistics(assessmentId: number): Operation { }; } -export function fetchAncestorStatistics( +export const fetchAncestorStatistics = async ( ancestorId: number, - failureMessage: string, -): Operation { - return async (dispatch) => { - dispatch({ type: actionTypes.FETCH_ANCESTOR_STATISTICS_REQUEST }); - return CourseAPI.statistics.assessment - .fetchStatistics(ancestorId) - .then((response) => { - dispatch({ - type: actionTypes.FETCH_ANCESTOR_STATISTICS_SUCCESS, - assessment: processAssessment(response.data.assessment), - submissions: response.data.submissions.map(processSubmission), - allStudents: response.data.allStudents.map(processCourseUser), - }); - }) - .catch(() => { - dispatch({ type: actionTypes.FETCH_ANCESTOR_STATISTICS_FAILURE }); - dispatch(setNotification(failureMessage)); - }); - }; -} +): Promise => { + const response = + await CourseAPI.statistics.assessment.fetchAncestorStatistics(ancestorId); + + return response.data; +}; diff --git a/config/routes.rb b/config/routes.rb index 80a1c42604..69d8997118 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -436,8 +436,8 @@ get 'course/course/progression' => 'aggregate#course_progression' get 'course/course/performance' => 'aggregate#course_performance' get 'user/:user_id/learning_rate_records' => 'users#learning_rate_records' - get 'assessment/:id' => 'assessments#assessment' - get 'assessment/:id/assessment_statistics' => 'assessments#assessment_statistics' + get 'assessment/:id/main_statistics' => 'assessments#main_statistics' + get 'assessment/:id/ancestor_statistics' => 'assessments#ancestor_statistics' end scope module: :video do From 6c39907a20fa822b2df29a46f2a44e727f011c1d Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 31 Jan 2024 00:33:49 +0800 Subject: [PATCH 21/45] redefine types for assessment and statistics - get the types correct to support migration of stats page to tsx --- client/app/bundles/course/assessment/types.ts | 13 ----- .../course/statistics/assessmentStatistics.ts | 57 ++++++++++++------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/client/app/bundles/course/assessment/types.ts b/client/app/bundles/course/assessment/types.ts index 820923c4b5..3562a92d75 100644 --- a/client/app/bundles/course/assessment/types.ts +++ b/client/app/bundles/course/assessment/types.ts @@ -1,6 +1,4 @@ -import { WorkflowState } from 'types/course/assessment/submission/submission'; import { CourseUserRoles } from 'types/course/courseUsers'; -import { SubmissionInfo } from 'types/course/statistics/assessmentStatistics'; export interface CourseUserShape { id: number; @@ -8,14 +6,3 @@ export interface CourseUserShape { role: CourseUserRoles; isPhantom: boolean; } - -export interface SubmissionRecordShape extends SubmissionInfo { - workflowState: WorkflowState; - dayDifference: number; -} - -export interface AncestorShape { - id: number; - title: string; - courseTitle: string; -} diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 9da3954a8c..f71d03d753 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -1,49 +1,56 @@ -interface Assessment { +import { WorkflowState } from '../assessment/submission/submission'; + +interface AssessmentInfo { id: number; title: string; startAt: string | null; endAt: string | null; maximumGrade: number; - questionCount?: number; url: string; } +interface MainAssessmentInfo extends AssessmentInfo { + questionCount: number; +} + +interface AncestorAssessmentInfo extends AssessmentInfo {} + interface UserInfo { id: number; name: string; } -interface StudentInfo extends UserInfo { - role: 'student'; +export interface StudentInfo extends UserInfo { isPhantom: boolean; + role: 'student'; } -interface AnswerStats { +interface AnswerInfo { id: number; grade: number; maximumGrade: number; } -export interface SubmissionInfo { +interface SubmissionInfo { courseUser: StudentInfo; + workflowState?: WorkflowState; + submittedAt?: string; + endAt?: string; totalGrade?: number | null; - submittedAt: Date; - endAt: Date; - dayDifference?: number; } -export interface SubmissionDetailsStats extends SubmissionInfo { - submissionExists: boolean; +export interface MainSubmissionInfo extends SubmissionInfo { + answers?: AnswerInfo[]; grader?: UserInfo; - groups?: { name: string }[]; - workflowState?: string; - answers?: AnswerStats[]; + groups: { name: string }[]; + submissionExists: boolean; } -export interface AssessmentStatistics { - assessment: Assessment; - submissions: SubmissionInfo[]; - allStudents: StudentInfo[]; +export interface AncestorSubmissionInfo extends SubmissionInfo { + workflowState: WorkflowState; + submittedAt: string; + endAt: string; + totalGrade: number | null; } export interface AncestorInfo { @@ -52,13 +59,19 @@ export interface AncestorInfo { courseTitle: string; } -export interface AssessmentStatisticsStats { - assessment: Assessment | null; +export interface MainAssessmentStats { + assessment: MainAssessmentInfo | null; + submissions: MainSubmissionInfo[]; allStudents: StudentInfo[]; - submissions: SubmissionDetailsStats[]; ancestors: AncestorInfo[]; } -export interface AssessmentStatisticsStore extends AssessmentStatisticsStats { +export interface AncestorAssessmentStats { + assessment: AncestorAssessmentInfo; + submissions: AncestorSubmissionInfo[]; + allStudents: StudentInfo[]; +} + +export interface AssessmentStatisticsStore extends MainAssessmentStats { isLoading: boolean; } From 1a30415e48208dfb7afa9b60d18bcbfc99c098d5 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 31 Jan 2024 00:37:42 +0800 Subject: [PATCH 22/45] small modification for statisticsUtils function - make the reassignment of variable more consistent --- .../course/assessment/utils/statisticsUtils.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/client/app/bundles/course/assessment/utils/statisticsUtils.js b/client/app/bundles/course/assessment/utils/statisticsUtils.js index 80c2ed3722..a135a0c835 100644 --- a/client/app/bundles/course/assessment/utils/statisticsUtils.js +++ b/client/app/bundles/course/assessment/utils/statisticsUtils.js @@ -11,17 +11,12 @@ const processAnswer = (answer) => ({ export const processSubmission = (submission) => { const totalGrade = - submission.totalGrade != null - ? parseFloat(submission.totalGrade) - : submission.totalGrade; + submission.totalGrade != null ? parseFloat(submission.totalGrade) : null; const answers = submission.answers != null ? submission.answers.map(processAnswer) : null; const submittedAt = - submission.submittedAt != null - ? new Date(submission.submittedAt) - : submission.submittedAt; - const endAt = - submission.endAt != null ? new Date(submission.endAt) : submission.endAt; + submission.submittedAt != null ? new Date(submission.submittedAt) : null; + const endAt = submission.endAt != null ? new Date(submission.endAt) : null; const dayDifference = submittedAt != null && endAt != null ? Math.floor((submittedAt - endAt) / 86400000) From a3ac66256d0be8bd3bcd045e0da34fd98384838b Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 31 Jan 2024 00:40:22 +0800 Subject: [PATCH 23/45] componentise common chart for ancestor and main assessment - both uses the same component but different sources - first get the necessary info, then display using common info --- .../GradeDistribution/AncestorGradesChart.tsx | 21 +++++++++ .../GradesChart.tsx} | 29 +++--------- .../GradeDistribution/MainGradesChart.tsx | 32 +++++++++++++ .../AncestorSubmissionChart.tsx | 30 ++++++++++++ .../SubmissionStatus/MainSubmissionChart.tsx | 40 ++++++++++++++++ .../SubmissionStatusChart.tsx | 46 ++++++------------- ...cestorSubmissionTimeAndGradeStatistics.tsx | 33 +++++++++++++ .../MainSubmissionTimeAndGradeStatistics.tsx | 38 +++++++++++++++ .../SubmissionTimeAndGradeChart.tsx | 25 ++-------- 9 files changed, 218 insertions(+), 76 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistribution/AncestorGradesChart.tsx rename client/app/bundles/course/assessment/pages/AssessmentStatistics/{GradeDistributionChart.tsx => GradeDistribution/GradesChart.tsx} (59%) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistribution/MainGradesChart.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatus/AncestorSubmissionChart.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatus/MainSubmissionChart.tsx rename client/app/bundles/course/assessment/pages/AssessmentStatistics/{ => SubmissionStatus}/SubmissionStatusChart.tsx (60%) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeStatistics/AncestorSubmissionTimeAndGradeStatistics.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeStatistics/MainSubmissionTimeAndGradeStatistics.tsx rename client/app/bundles/course/assessment/pages/AssessmentStatistics/{ => SubmissionTimeAndGradeStatistics}/SubmissionTimeAndGradeChart.tsx (75%) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistribution/AncestorGradesChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistribution/AncestorGradesChart.tsx new file mode 100644 index 0000000000..0771548f4b --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistribution/AncestorGradesChart.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react'; +import { AncestorSubmissionInfo } from 'types/course/statistics/assessmentStatistics'; + +import GradesChart from './GradesChart'; + +interface Props { + ancestorSubmissions: AncestorSubmissionInfo[]; +} + +const AncestorGradesChart: FC = (props) => { + const { ancestorSubmissions } = props; + + const totalGrades = + ancestorSubmissions + ?.filter((s) => s.totalGrade) + ?.map((s) => s.totalGrade) ?? []; + + return ; +}; + +export default AncestorGradesChart; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistribution/GradesChart.tsx similarity index 59% rename from client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx rename to client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistribution/GradesChart.tsx index 6ef7cc11ec..3a231fb584 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistributionChart.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistribution/GradesChart.tsx @@ -2,13 +2,9 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { GREEN_CHART_BACKGROUND, GREEN_CHART_BORDER } from 'theme/colors'; -import { SubmissionRecordShape } from 'course/assessment/types'; import ViolinChart from 'lib/components/core/charts/ViolinChart'; -import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import { getAssessmentStatistics } from './selectors'; - const translations = defineMessages({ yAxisLabel: { id: 'course.assessment.statistics.gradeViolin.yAxisLabel', @@ -25,27 +21,14 @@ const translations = defineMessages({ }); interface Props { - ancestorSubmissions?: SubmissionRecordShape[]; - includePhantom: boolean; + totalGrades: (number | null | undefined)[]; } -const GradeDistributionChart: FC = (props) => { +const GradesChart: FC = (props) => { const { t } = useTranslation(); - const { includePhantom, ancestorSubmissions } = props; - - const statistics = useAppSelector(getAssessmentStatistics); - const submissions = statistics.submissions; - const nonNullSubmissions = submissions.filter((s) => s.answers); - - const includedSubmissions = includePhantom - ? nonNullSubmissions - : nonNullSubmissions.filter((s) => !s.courseUser.isPhantom); + const { totalGrades } = props; - const totalGrades = - includedSubmissions - ?.filter((s) => s.totalGrade) - ?.map((s) => s.totalGrade) ?? []; - const data = { + const transformedData = { labels: [t(translations.yAxisLabel)], datasets: [ { @@ -76,9 +59,9 @@ const GradeDistributionChart: FC = (props) => { return (
- +
); }; -export default GradeDistributionChart; +export default GradesChart; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistribution/MainGradesChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistribution/MainGradesChart.tsx new file mode 100644 index 0000000000..dbde13c307 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/GradeDistribution/MainGradesChart.tsx @@ -0,0 +1,32 @@ +import { FC } from 'react'; + +import { useAppSelector } from 'lib/hooks/store'; + +import { getAssessmentStatistics } from '../selectors'; + +import GradesChart from './GradesChart'; + +interface Props { + includePhantom: boolean; +} + +const MainGradesChart: FC = (props) => { + const { includePhantom } = props; + + const statistics = useAppSelector(getAssessmentStatistics); + const submissions = statistics.submissions; + const nonNullSubmissions = submissions.filter((s) => s.totalGrade); + + const includedSubmissions = includePhantom + ? nonNullSubmissions + : nonNullSubmissions.filter((s) => !s.courseUser.isPhantom); + + const totalGrades = + includedSubmissions + ?.filter((s) => s.totalGrade) + ?.map((s) => s.totalGrade) ?? []; + + return ; +}; + +export default MainGradesChart; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatus/AncestorSubmissionChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatus/AncestorSubmissionChart.tsx new file mode 100644 index 0000000000..cbf42289f3 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatus/AncestorSubmissionChart.tsx @@ -0,0 +1,30 @@ +import { WorkflowState } from 'types/course/assessment/submission/submission'; +import { + AncestorSubmissionInfo, + StudentInfo, +} from 'types/course/statistics/assessmentStatistics'; + +import SubmissionStatusChart from './SubmissionStatusChart'; + +interface Props { + ancestorSubmissions: AncestorSubmissionInfo[]; + ancestorAllStudents: StudentInfo[]; +} + +const AncestorSubmissionChart = (props: Props): JSX.Element => { + const { ancestorSubmissions, ancestorAllStudents } = props; + + const numStudents = ancestorAllStudents.length; + const submissionWorkflowStates = ancestorSubmissions.map( + (s) => s.workflowState as WorkflowState, + ); + + return ( + + ); +}; + +export default AncestorSubmissionChart; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatus/MainSubmissionChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatus/MainSubmissionChart.tsx new file mode 100644 index 0000000000..ca00e58618 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatus/MainSubmissionChart.tsx @@ -0,0 +1,40 @@ +import { WorkflowState } from 'types/course/assessment/submission/submission'; + +import { useAppSelector } from 'lib/hooks/store'; + +import { getAssessmentStatistics } from '../selectors'; + +import SubmissionStatusChart from './SubmissionStatusChart'; + +interface Props { + includePhantom: boolean; +} + +const MainSubmissionChart = (props: Props): JSX.Element => { + const { includePhantom } = props; + const statistics = useAppSelector(getAssessmentStatistics); + + const submissions = statistics.submissions; + const allStudents = statistics.allStudents; + + const nonNullSubmissions = submissions.filter((s) => s.submissionExists); + + const numStudents = includePhantom + ? allStudents.length + : allStudents.filter((s) => !s.isPhantom).length; + const includedSubmissions = includePhantom + ? nonNullSubmissions + : nonNullSubmissions.filter((s) => !s.courseUser.isPhantom); + const submissionWorkflowStates = includedSubmissions.map( + (s) => s.workflowState as WorkflowState, + ); + + return ( + + ); +}; + +export default MainSubmissionChart; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatus/SubmissionStatusChart.tsx similarity index 60% rename from client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx rename to client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatus/SubmissionStatusChart.tsx index ec7ff371c1..d78ac268d4 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatusChart.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionStatus/SubmissionStatusChart.tsx @@ -1,15 +1,9 @@ import { defineMessages, FormattedMessage } from 'react-intl'; import palette from 'theme/palette'; +import { WorkflowState } from 'types/course/assessment/submission/submission'; import { workflowStates } from 'course/assessment/submission/constants'; -import { - CourseUserShape, - SubmissionRecordShape, -} from 'course/assessment/types'; import BarChart from 'lib/components/core/BarChart'; -import { useAppSelector } from 'lib/hooks/store'; - -import { getAssessmentStatistics } from './selectors'; const translations = defineMessages({ datasetLabel: { @@ -39,39 +33,25 @@ const translations = defineMessages({ }); interface Props { - ancestorSubmissions?: SubmissionRecordShape[]; - ancestorAllStudents?: CourseUserShape[]; - includePhantom: boolean; + numStudents: number; + submissionWorkflowStates: WorkflowState[]; } const SubmissionStatusChart = (props: Props): JSX.Element => { - const { ancestorSubmissions, includePhantom, ancestorAllStudents } = props; - const statistics = useAppSelector(getAssessmentStatistics); - - const submissions = statistics.submissions.slice(); - const allStudents = statistics.allStudents.slice(); - - const nonNullSubmissions = submissions.filter((s) => s.submissionExists); - - const numStudents = includePhantom - ? allStudents.length - : allStudents.filter((s) => !s.isPhantom).length; - const includedSubmissions = includePhantom - ? nonNullSubmissions - : nonNullSubmissions.filter((s) => !s.courseUser.isPhantom); + const { numStudents, submissionWorkflowStates } = props; - const numUnstarted = numStudents - includedSubmissions.length; - const numAttempting = includedSubmissions.filter( - (s) => s.workflowState === workflowStates.Attempting, + const numUnstarted = numStudents - submissionWorkflowStates.length; + const numAttempting = submissionWorkflowStates.filter( + (workflow) => workflow === workflowStates.Attempting, ).length; - const numSubmitted = includedSubmissions.filter( - (s) => s.workflowState === workflowStates.Submitted, + const numSubmitted = submissionWorkflowStates.filter( + (workflow) => workflow === workflowStates.Submitted, ).length; - const numGraded = includedSubmissions.filter( - (s) => s.workflowState === workflowStates.Graded, + const numGraded = submissionWorkflowStates.filter( + (workflow) => workflow === workflowStates.Graded, ).length; - const numPublished = includedSubmissions.filter( - (s) => s.workflowState === workflowStates.Published, + const numPublished = submissionWorkflowStates.filter( + (workflow) => workflow === workflowStates.Published, ).length; const data = [ diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeStatistics/AncestorSubmissionTimeAndGradeStatistics.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeStatistics/AncestorSubmissionTimeAndGradeStatistics.tsx new file mode 100644 index 0000000000..7793d0429d --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeStatistics/AncestorSubmissionTimeAndGradeStatistics.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; +import { AncestorSubmissionInfo } from 'types/course/statistics/assessmentStatistics'; + +import { processSubmission } from 'course/assessment/utils/statisticsUtils'; + +import { processSubmissionsIntoChartData } from '../utils'; + +import SubmissionTimeAndGradeChart from './SubmissionTimeAndGradeChart'; + +interface Props { + ancestorSubmissions: AncestorSubmissionInfo[]; +} + +const AncestorSubmissionTimeAndGradeStatistics: FC = (props) => { + const { ancestorSubmissions } = props; + const mappedAncestorSubmissions = ancestorSubmissions.map(processSubmission); + + const { labels, lineData, barData } = processSubmissionsIntoChartData( + mappedAncestorSubmissions, + ); + const hasEndAt = ancestorSubmissions.every((s) => s.endAt); + + return ( + + ); +}; + +export default AncestorSubmissionTimeAndGradeStatistics; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeStatistics/MainSubmissionTimeAndGradeStatistics.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeStatistics/MainSubmissionTimeAndGradeStatistics.tsx new file mode 100644 index 0000000000..412f72c27f --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeStatistics/MainSubmissionTimeAndGradeStatistics.tsx @@ -0,0 +1,38 @@ +import { FC } from 'react'; + +import { useAppSelector } from 'lib/hooks/store'; + +import { getAssessmentStatistics } from '../selectors'; +import { processSubmissionsIntoChartData } from '../utils'; + +import SubmissionTimeAndGradeChart from './SubmissionTimeAndGradeChart'; + +interface Props { + includePhantom: boolean; +} + +const MainSubmissionTimeAndGradeStatistics: FC = (props) => { + const { includePhantom } = props; + const statistics = useAppSelector(getAssessmentStatistics); + + const submissions = statistics.submissions; + + const nonNullSubmissions = submissions.filter((s) => s.totalGrade); + const includedSubmissions = includePhantom + ? nonNullSubmissions + : nonNullSubmissions.filter((s) => !s.courseUser.isPhantom); + const { labels, lineData, barData } = + processSubmissionsIntoChartData(includedSubmissions); + const hasEndAt = includedSubmissions.every((s) => s.endAt); + + return ( + + ); +}; + +export default MainSubmissionTimeAndGradeStatistics; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeStatistics/SubmissionTimeAndGradeChart.tsx similarity index 75% rename from client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx rename to client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeStatistics/SubmissionTimeAndGradeChart.tsx index e4d14ccf53..659629ab58 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeChart.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/SubmissionTimeAndGradeStatistics/SubmissionTimeAndGradeChart.tsx @@ -7,14 +7,9 @@ import { ORANGE_CHART_BORDER, } from 'theme/colors'; -import { SubmissionRecordShape } from 'course/assessment/types'; import GeneralChart from 'lib/components/core/charts/GeneralChart'; -import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import { getAssessmentStatistics } from './selectors'; -import { processSubmissionsIntoChartData } from './utils'; - const translations = defineMessages({ lineDatasetLabel: { id: 'course.assessment.statistics.submissionTimeGradeChart.lineDatasetLabel', @@ -35,23 +30,15 @@ const translations = defineMessages({ }); interface Props { - ancestorSubmissions?: SubmissionRecordShape[]; - includePhantom: boolean; + barData: number[]; + hasEndAt: boolean; + labels: string[]; + lineData: number[]; } const SubmissionTimeAndGradeChart: FC = (props) => { const { t } = useTranslation(); - const { ancestorSubmissions, includePhantom } = props; - const statistics = useAppSelector(getAssessmentStatistics); - - const submissions = statistics.submissions; - - const nonNullSubmissions = submissions.filter((s) => s.answers); - const includedSubmissions = includePhantom - ? nonNullSubmissions - : nonNullSubmissions.filter((s) => !s.courseUser.isPhantom); - const { labels, lineData, barData } = - processSubmissionsIntoChartData(includedSubmissions); + const { barData, hasEndAt, labels, lineData } = props; const data = { labels, @@ -78,8 +65,6 @@ const SubmissionTimeAndGradeChart: FC = (props) => { ], }; - const hasEndAt = submissions.every((s) => s.endAt != null); - const options = { scales: { A: { From b8ad271598896e19706a6995339dfd2485a2e379 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 31 Jan 2024 00:41:33 +0800 Subject: [PATCH 24/45] refactor ancestor assessment statistics chart - remove the data from redux as it's unnecessary --- .../AssessmentStatistics/AncestorOptions.tsx | 18 +++--- .../AssessmentStatistics/AncestorSelect.tsx | 54 ----------------- .../AncestorStatistics.tsx | 58 ++++++------------- .../DuplicationHistoryStatistics.tsx | 27 +++------ .../AssessmentStatistics/StatisticsCharts.tsx | 29 ++++------ .../StudentMarksPerQuestionTable.tsx | 10 ++-- .../pages/AssessmentStatistics/index.tsx | 16 +++-- .../assessment/utils/statisticsUtils.js | 1 - 8 files changed, 58 insertions(+), 155 deletions(-) delete mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx index b847cc6727..59749f8984 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorOptions.tsx @@ -1,9 +1,9 @@ -import { FC, Fragment } from 'react'; +import { Dispatch, FC, Fragment, SetStateAction } from 'react'; import { defineMessages } from 'react-intl'; import { ArrowForward } from '@mui/icons-material'; import { Card, CardContent, Chip, Typography } from '@mui/material'; +import { AncestorInfo } from 'types/course/statistics/assessmentStatistics'; -import { AncestorShape } from 'course/assessment/types'; import useTranslation from 'lib/hooks/useTranslation'; const translations = defineMessages({ @@ -26,19 +26,19 @@ const translations = defineMessages({ }); interface Props { - assessmentId: number; - ancestors: AncestorShape[]; + ancestors: AncestorInfo[]; + parsedAssessmentId: number; selectedAncestorId: number; - fetchAncestorSubmissions: (id: number) => void; + setSelectedAncestorId: Dispatch>; } const AncestorOptions: FC = (props) => { const { t } = useTranslation(); const { - assessmentId, ancestors, + parsedAssessmentId, selectedAncestorId, - fetchAncestorSubmissions, + setSelectedAncestorId, } = props; return ( @@ -51,7 +51,7 @@ const AncestorOptions: FC = (props) => { ? 'h-[17rem] w-[35rem] min-w-[30rem] mx-4 bg-green-100 cursor-pointer' : 'h-[17rem] w-[35rem] min-w-[30rem] mx-4 cursor-pointer' } - onClick={() => fetchAncestorSubmissions(ancestor.id)} + onClick={() => setSelectedAncestorId(ancestor.id)} > @@ -62,7 +62,7 @@ const AncestorOptions: FC = (props) => { courseTitle: ancestor.courseTitle, })} - {ancestor.id === assessmentId ? ( + {ancestor.id === parsedAssessmentId ? ( ) : null} diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx deleted file mode 100644 index fd16258153..0000000000 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorSelect.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { defineMessages, FormattedMessage } from 'react-intl'; - -import { AncestorShape } from 'course/assessment/types'; -import ErrorCard from 'lib/components/core/ErrorCard'; -import LoadingIndicator from 'lib/components/core/LoadingIndicator'; - -import AncestorOptions from './AncestorOptions'; - -const translations = defineMessages({ - fetchAncestorsFailure: { - id: 'course.assessment.statistics.ancestorFail', - defaultMessage: 'Failed to fetch past iterations of this assessment.', - }, -}); - -interface AncestorSelectProps { - ancestors: AncestorShape[]; - fetchAncestorSubmissions: (id: number) => void; - isErrorAncestors: boolean; - isFetchingAncestors: boolean; - parsedAssessmentId: number; - selectedAncestorId: number; -} - -const AncestorSelect = (props: AncestorSelectProps): JSX.Element => { - const { - ancestors, - isFetchingAncestors, - isErrorAncestors, - parsedAssessmentId, - selectedAncestorId, - fetchAncestorSubmissions, - } = props; - if (isFetchingAncestors) { - return ; - } - if (isErrorAncestors) { - return ( - } - /> - ); - } - return ( - - ); -}; - -export default AncestorSelect; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorStatistics.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorStatistics.tsx index 436872c81a..6d79be1e71 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorStatistics.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AncestorStatistics.tsx @@ -1,57 +1,35 @@ -import { defineMessages, FormattedMessage } from 'react-intl'; +import { AncestorAssessmentStats } from 'types/course/statistics/assessmentStatistics'; -import { - CourseUserShape, - SubmissionRecordShape, -} from 'course/assessment/types'; -import ErrorCard from 'lib/components/core/ErrorCard'; +import { fetchAncestorStatistics } from 'course/assessment/operations/statistics'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; import StatisticsCharts from './StatisticsCharts'; -const translations = defineMessages({ - fetchAncestorStatisticsFailure: { - id: 'course.assessment.statistics.ancestorStatisticsFail', - defaultMessage: "Failed to fetch ancestor's statistics.", - }, -}); - interface AncestorStatisticsProps { - ancestorAllStudents: CourseUserShape[]; - ancestorSubmissions: SubmissionRecordShape[]; - isErrorAncestorStatistics: boolean; - isFetchingAncestorStatistics: boolean; currentAssessmentSelected: boolean; + selectedAssessmentId: number; } const AncestorStatistics = (props: AncestorStatisticsProps): JSX.Element => { - const { - ancestorAllStudents, - ancestorSubmissions, - isErrorAncestorStatistics, - isFetchingAncestorStatistics, - currentAssessmentSelected, - } = props; + const { currentAssessmentSelected, selectedAssessmentId } = props; if (currentAssessmentSelected) { return <> ; } - if (isFetchingAncestorStatistics) { - return ; - } - if (isErrorAncestorStatistics) { - return ( - - } - /> - ); - } + + const fetchAncestorStatisticsInfo = (): Promise => { + return fetchAncestorStatistics(selectedAssessmentId); + }; + return ( - + } while={fetchAncestorStatisticsInfo}> + {(data): JSX.Element => ( + + )} + ); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx index 12401d8a1d..3fc738aa93 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/DuplicationHistoryStatistics.tsx @@ -3,7 +3,8 @@ import { useParams } from 'react-router-dom'; import { useAppSelector } from 'lib/hooks/store'; -import AncestorSelect from './AncestorSelect'; +import AncestorOptions from './AncestorOptions'; +import AncestorStatistics from './AncestorStatistics'; import { getAssessmentStatistics } from './selectors'; const DuplicationHistoryStatistics: FC = () => { @@ -15,34 +16,20 @@ const DuplicationHistoryStatistics: FC = () => { const [selectedAncestorId, setSelectedAncestorId] = useState(parsedAssessmentId); - const fetchAncestorSubmissions = (id: number): void => { - if (id === selectedAncestorId) { - return; - } - setSelectedAncestorId(id); - }; - return ( <> - - {/*
+
-
*/} +
); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx index 3d36c861a1..8472bf88b5 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx @@ -1,16 +1,16 @@ import { FC, ReactNode } from 'react'; import { defineMessages } from 'react-intl'; import { Card, CardContent, Typography } from '@mui/material'; - import { - CourseUserShape, - SubmissionRecordShape, -} from 'course/assessment/types'; + AncestorSubmissionInfo, + StudentInfo, +} from 'types/course/statistics/assessmentStatistics'; + import useTranslation from 'lib/hooks/useTranslation'; -import GradeDistributionChart from './GradeDistributionChart'; -import SubmissionStatusChart from './SubmissionStatusChart'; -import SubmissionTimeAndGradeChart from './SubmissionTimeAndGradeChart'; +import AncestorGradesChart from './GradeDistribution/AncestorGradesChart'; +import AncestorSubmissionChart from './SubmissionStatus/AncestorSubmissionChart'; +import AncestorSubmissionTimeAndGradeStatistics from './SubmissionTimeAndGradeStatistics/AncestorSubmissionTimeAndGradeStatistics'; const translations = defineMessages({ submissionStatuses: { @@ -28,8 +28,8 @@ const translations = defineMessages({ }); interface Props { - submissions: SubmissionRecordShape[]; - allStudents: CourseUserShape[]; + submissions: AncestorSubmissionInfo[]; + allStudents: StudentInfo[]; } const CardTitle: FC<{ children: ReactNode }> = ({ children }) => ( @@ -47,28 +47,23 @@ const StatisticsCharts: FC = (props) => { {t(translations.submissionStatuses)} - {t(translations.gradeDistribution)} - + {t(translations.submissionTimeAndGrade)} - diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index ff8779b39b..081234597b 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -3,7 +3,7 @@ import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Box, Chip } from '@mui/material'; import palette from 'theme/palette'; -import { SubmissionDetailsStats } from 'types/course/statistics/assessmentStatistics'; +import { MainSubmissionInfo } from 'types/course/statistics/assessmentStatistics'; import { workflowStates } from 'course/assessment/submission/constants'; import Link from 'lib/components/core/Link'; @@ -130,7 +130,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { return grade1 - grade2; }; - const answerColumns: ColumnTemplate[] = Array.from( + const answerColumns: ColumnTemplate[] = Array.from( { length: assessment?.questionCount ?? 0 }, (_, index) => { return { @@ -161,10 +161,10 @@ const StudentMarksPerQuestionTable: FC = (props) => { }, ); - const jointGroupsName = (datum: SubmissionDetailsStats): string => + const jointGroupsName = (datum: MainSubmissionInfo): string => datum.groups ? datum.groups.map((g) => g.name).join(', ') : ''; - const columns: ColumnTemplate[] = [ + const columns: ColumnTemplate[] = [ { searchProps: { getValue: (datum) => datum.courseUser.name, @@ -273,7 +273,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { getRowClassName={(datum): string => `data_${datum.courseUser.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` } - getRowEqualityData={(datum): SubmissionDetailsStats => datum} + getRowEqualityData={(datum): MainSubmissionInfo => datum} getRowId={(datum): string => datum.courseUser.id.toString()} indexing={{ indices: true }} pagination={{ diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx index 00f9b1b669..672348e331 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx @@ -11,12 +11,12 @@ import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; +import MainGradesChart from './GradeDistribution/MainGradesChart'; +import SubmissionStatusMainAssessment from './SubmissionStatus/MainSubmissionChart'; +import MainSubmissionTimeAndGradeStatistics from './SubmissionTimeAndGradeStatistics/MainSubmissionTimeAndGradeStatistics'; import DuplicationHistoryStatistics from './DuplicationHistoryStatistics'; -import GradeDistributionChart from './GradeDistributionChart'; import { getAssessmentStatistics } from './selectors'; import StudentMarksPerQuestionTable from './StudentMarksPerQuestionTable'; -import SubmissionStatusChart from './SubmissionStatusChart'; -import SubmissionTimeAndGradeChart from './SubmissionTimeAndGradeChart'; const translations = defineMessages({ statistics: { @@ -77,17 +77,15 @@ const AssessmentStatisticsPage: FC = () => { dispatch(fetchAssessmentStatistics(parsedAssessmentId)).catch(() => toast.error(t(translations.fetchFailure)), ); - }, [dispatch, parsedAssessmentId]); + }, [dispatch]); const tabComponentMapping = { marksPerQuestion: ( ), - gradeDistribution: ( - - ), + gradeDistribution: , submissionTimeAndGrade: ( - + ), duplicationHistory: , }; @@ -106,7 +104,7 @@ const AssessmentStatisticsPage: FC = () => { > <> - + { submittedAt, endAt, dayDifference, - courseUser: processCourseUser(submission.courseUser), }; }; From 718d1d0cd02e843a8d35dbfae70a653455c38d3b Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 31 Jan 2024 10:01:14 +0800 Subject: [PATCH 25/45] minor refactoring on redux store - remove statisticsPage - not include Phantom Students inside duplicated assessment stats --- .../statistics/assessments_controller.rb | 5 +- app/models/course/assessment.rb | 2 +- .../assessments/main_statistics.json.jbuilder | 2 +- .../AssessmentStatistics/StatisticsCharts.tsx | 21 ++++- .../assessment/reducers/statisticsPage.js | 88 ------------------- client/app/bundles/course/assessment/store.ts | 2 - client/app/routers/AuthenticatedApp.tsx | 1 - 7 files changed, 22 insertions(+), 99 deletions(-) delete mode 100644 client/app/bundles/course/assessment/reducers/statisticsPage.js diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 67b1cf540d..09aa676ca1 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -28,7 +28,7 @@ def ancestor_statistics submissions = Course::Assessment::Submission.preload(creator: :course_users). where(assessment_id: assessment_params[:id]). calculated(:grade) - + load_course_user_students # we do not need the nil value for this hash, since we aim only @@ -49,7 +49,8 @@ def load_course_user_students def fetch_all_ancestor_assessments current_assessment = Course::Assessment.preload(:duplication_traceable).find(assessment_params[:id]) @ancestors = [current_assessment] - while current_assessment.duplication_traceable.present? && current_assessment.duplication_traceable.source_id.present? + while current_assessment.duplication_traceable.present? && + current_assessment.duplication_traceable.source_id.present? current_assessment = current_assessment.duplication_traceable.source break unless can?(:read_ancestor, current_assessment) diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 2602dafa5a..82e2fbfa10 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -97,7 +97,7 @@ class Course::Assessment < ApplicationRecord calculated :question_count, (lambda do Course::QuestionAssessment.unscope(:order). select('coalesce(count(DISTINCT cqa.question_id), 0)'). - joins("INNER JOIN course_question_assessments cqa ON cqa.assessment_id = course_assessments.id"). + joins('INNER JOIN course_question_assessments cqa ON cqa.assessment_id = course_assessments.id'). group('course_assessments.id') end) diff --git a/app/views/course/statistics/assessments/main_statistics.json.jbuilder b/app/views/course/statistics/assessments/main_statistics.json.jbuilder index 2f9d77f50c..fb3e2ac2f8 100644 --- a/app/views/course/statistics/assessments/main_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/main_statistics.json.jbuilder @@ -23,7 +23,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.submissionExists !submission.nil? - if !submission.nil? + unless submission.nil? json.workflowState submission.workflow_state json.submittedAt submission.submitted_at&.iso8601 json.endAt end_at&.iso8601 diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx index 8472bf88b5..0409fa092a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StatisticsCharts.tsx @@ -25,6 +25,11 @@ const translations = defineMessages({ id: 'course.assessment.statistics.submissionTimeAndGrade', defaultMessage: 'Submission Time and Grade', }, + noIncludePhantom: { + id: 'course.assessment.statistics.noIncludePhantom', + defaultMessage: + '*All statistics in this duplicated assessments does not include Phantom Students', + }, }); interface Props { @@ -42,28 +47,36 @@ const StatisticsCharts: FC = (props) => { const { t } = useTranslation(); const { submissions, allStudents } = props; + const noPhantomStudents = allStudents.filter((student) => !student.isPhantom); + const noPhantomSubmissions = submissions.filter( + (s) => !s.courseUser.isPhantom, + ); + return (
+ + {t(translations.noIncludePhantom)} + {t(translations.submissionStatuses)} {t(translations.gradeDistribution)} - + {t(translations.submissionTimeAndGrade)} diff --git a/client/app/bundles/course/assessment/reducers/statisticsPage.js b/client/app/bundles/course/assessment/reducers/statisticsPage.js deleted file mode 100644 index a9c0d5aecd..0000000000 --- a/client/app/bundles/course/assessment/reducers/statisticsPage.js +++ /dev/null @@ -1,88 +0,0 @@ -import actionTypes from '../constants'; - -const initialState = { - isFetching: false, - isError: false, - isFetchingAncestors: false, - isErrorAncestors: false, - isFetchingAncestorStatistics: false, - isErrorAncestorStatistics: false, - assessment: null, - submissions: [], - allStudents: [], - ancestors: [], - ancestorAssessment: null, - ancestorSubmissions: [], - ancestorAllStudents: [], -}; - -export default function (state = initialState, action) { - switch (action.type) { - case actionTypes.FETCH_STATISTICS_REQUEST: { - return { - ...state, - isFetching: true, - }; - } - case actionTypes.FETCH_STATISTICS_SUCCESS: { - return { - ...state, - assessment: action.assessment, - submissions: action.submissions, - allStudents: action.allStudents, - isFetching: false, - }; - } - case actionTypes.FETCH_STATISTICS_FAILURE: { - return { - ...state, - isFetching: false, - isError: true, - }; - } - case actionTypes.FETCH_ANCESTORS_REQUEST: { - return { - ...state, - isFetchingAncestors: true, - }; - } - case actionTypes.FETCH_ANCESTORS_SUCCESS: { - return { - ...state, - ancestors: action.ancestors, - isFetchingAncestors: false, - }; - } - case actionTypes.FETCH_ANCESTORS_FAILURE: { - return { - ...state, - isFetchingAncestors: false, - isErrorAncestors: true, - }; - } - case actionTypes.FETCH_ANCESTOR_STATISTICS_REQUEST: { - return { - ...state, - isFetchingAncestorStatistics: true, - }; - } - case actionTypes.FETCH_ANCESTOR_STATISTICS_SUCCESS: { - return { - ...state, - isFetchingAncestorStatistics: false, - ancestorAssessment: action.assessment, - ancestorSubmissions: action.submissions, - ancestorAllStudents: action.allStudents, - }; - } - case actionTypes.FETCH_ANCESTOR_STATISTICS_FAILURE: { - return { - ...state, - isFetchingAncestorStatistics: false, - isErrorAncestorStatistics: true, - }; - } - default: - return state; - } -} diff --git a/client/app/bundles/course/assessment/store.ts b/client/app/bundles/course/assessment/store.ts index 73e37c6c30..d17a46c992 100644 --- a/client/app/bundles/course/assessment/store.ts +++ b/client/app/bundles/course/assessment/store.ts @@ -4,7 +4,6 @@ import editPageReducer from './reducers/editPage'; import formDialogReducer from './reducers/formDialog'; import monitoringReducer from './reducers/monitoring'; import statisticsReducer from './reducers/statistics'; -import statisticsPageReducer from './reducers/statisticsPage'; import submissionReducer from './submission/reducers'; const reducer = combineReducers({ @@ -12,7 +11,6 @@ const reducer = combineReducers({ editPage: editPageReducer, monitoring: monitoringReducer, statistics: statisticsReducer, - statisticsPage: statisticsPageReducer, submission: submissionReducer, }); diff --git a/client/app/routers/AuthenticatedApp.tsx b/client/app/routers/AuthenticatedApp.tsx index c28a1d0fcf..c9860cf915 100644 --- a/client/app/routers/AuthenticatedApp.tsx +++ b/client/app/routers/AuthenticatedApp.tsx @@ -564,7 +564,6 @@ const authenticatedRouter: Translated = (t) => { path: 'statistics', handle: AssessmentStatisticsPage.handle, - // @ts-ignore `connect` throws error when cannot find `store` as direct parent element: , }, { From 04fd3c4e8f18bf9def39c09916bf786540d3aca9 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 31 Jan 2024 10:56:46 +0800 Subject: [PATCH 26/45] modify test cases for assessment statistics --- .../statistics/assessment_controller_spec.rb | 168 ++++++++++-------- 1 file changed, 96 insertions(+), 72 deletions(-) diff --git a/spec/controllers/course/statistics/assessment_controller_spec.rb b/spec/controllers/course/statistics/assessment_controller_spec.rb index d55eafbb91..e8cd71ac4f 100644 --- a/spec/controllers/course/statistics/assessment_controller_spec.rb +++ b/spec/controllers/course/statistics/assessment_controller_spec.rb @@ -5,8 +5,17 @@ let(:instance) { Instance.default } with_tenant(:instance) do - let(:course) { create(:course, :enrollable) } - let(:assessment) { create(:assessment, :published, :with_all_question_types, course: course) } + let(:original_course) { create(:course) } + let(:course) { create(:course) } + let(:original_assessment) { create(:assessment, :published, :with_all_question_types, course: original_course) } + + let!(:duplicate_objects) do + Course::Duplication::ObjectDuplicationService. + duplicate_objects(original_course, course, [original_assessment], {}) + end + + let(:assessment) { course.assessments.first } + let(:students) { create_list(:course_student, 3, course: course) } let(:teaching_assistant) { create(:course_teaching_assistant, course: course) } @@ -23,125 +32,140 @@ assessment: assessment, course: course, creator: teaching_assistant.user) end - describe '#assessment' do + describe '#main_statistics' do render_views - subject { get :assessment, as: :json, params: { course_id: course, id: assessment.id } } + subject { get :main_statistics, as: :json, params: { course_id: course, id: assessment.id } } - context 'when the Normal User get the submission data for chart display' do + context 'when the Normal User get the main statistics data' do let(:user) { create(:user) } before { sign_in(user) } it { expect { subject }.to raise_exception(CanCan::AccessDenied) } end - context 'when the Course Student get the submission data for chart display' do + context 'when the Course Student get the main statistics data' do let(:user) { create(:course_student, course: course).user } before { sign_in(user) } it { expect { subject }.to raise_exception(CanCan::AccessDenied) } end - context 'when the Course Manager get the submission data for chart display' do + context 'when the Course Manager get the main statistics data' do let(:user) { create(:course_manager, course: course).user } before { sign_in(user) } - it 'returns OK with right number of submissions being displayed' do + it 'returns OK with right number of submissions and ancestor being displayed' do expect(subject).to have_http_status(:success) json_result = JSON.parse(response.body) - # only the students starting the assessment will have their data recorded here - expect(json_result['submissions'].count).to eq(2) - - # only published submissions' answers will be included in the stats - expect(json_result['submissions'][0]['courseUser']['role']).to eq('student') - expect(json_result['submissions'][1]['courseUser']['role']).to eq('student') + # all the students data will be included here, including the non-existing submission one + expect(json_result['submissions'].count).to eq(3) + expect(json_result['allStudents'].count).to eq(3) # showing the correct workflow state expect(json_result['submissions'][0]['workflowState']).to eq('published') expect(json_result['submissions'][1]['workflowState']).to eq('attempting') + expect(json_result['submissions'][2]['workflowState']).to be_nil - # however, all the students information will be sent to frontend - expect(json_result['allStudents'].count).to eq(3) + # checking if the submission exists + expect(json_result['submissions'][0]['submissionExists']).to be_truthy + expect(json_result['submissions'][1]['submissionExists']).to be_truthy + expect(json_result['submissions'][2]['submissionExists']).to be_falsey + + # only published submissions' answers will be included in the stats + expect(json_result['submissions'][0]['answers']).not_to be_nil + expect(json_result['submissions'][1]['answers']).to be_nil + expect(json_result['submissions'][2]['answers']).to be_nil + + # only published submissions' answers will be included in the stats + expect(json_result['submissions'][0]['courseUser']['role']).to eq('student') + expect(json_result['submissions'][1]['courseUser']['role']).to eq('student') + expect(json_result['submissions'][2]['courseUser']['role']).to eq('student') + + # only 1 ancestor will be returned (current) as no permission for ancestor assessment + expect(json_result['ancestors'].count).to eq(1) end end - end - describe '#ancestors' do - let(:destination_course) { create(:course) } - let!(:duplicate_objects) do - Course::Duplication::ObjectDuplicationService. - duplicate_objects(course, destination_course, [assessment], {}) - end + context 'when the administrator get the main statistics data' do + let(:administrator) { create(:administrator) } + before { sign_in(administrator) } - context 'after the assessment is being duplicated' do - render_views - subject do - get :ancestors, as: :json, params: { course_id: destination_course, - id: destination_course.assessments[0].id } - end + it 'returns OK with right information and 2 ancestors (both current and its predecassors)' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) - context 'when the administrator wants to get ancestors' do - let(:administrator) { create(:administrator) } - before { sign_in(administrator) } + # all the students data will be included here, including the non-existing submission one + expect(json_result['submissions'].count).to eq(3) + expect(json_result['allStudents'].count).to eq(3) - it 'gives both the assessment information' do - expect(subject).to have_http_status(:success) - json_result = JSON.parse(response.body) + # showing the correct workflow state + expect(json_result['submissions'][0]['workflowState']).to eq('published') + expect(json_result['submissions'][1]['workflowState']).to eq('attempting') + expect(json_result['submissions'][2]['workflowState']).to be_nil - expect(json_result['assessments'].count).to eq(2) - end - end + # checking if the submission exists + expect(json_result['submissions'][0]['submissionExists']).to be_truthy + expect(json_result['submissions'][1]['submissionExists']).to be_truthy + expect(json_result['submissions'][2]['submissionExists']).to be_falsey - context 'when the course manager of the destination course wants to get ancestors' do - let(:course_manager) { create(:course_manager, course: destination_course) } - before { sign_in(course_manager.user) } + # only published submissions' answers will be included in the stats + expect(json_result['submissions'][0]['answers']).not_to be_nil + expect(json_result['submissions'][1]['answers']).to be_nil + expect(json_result['submissions'][2]['answers']).to be_nil - it 'gives only the information regarding current destination as no authorization for parent course' do - expect(subject).to have_http_status(:success) - json_result = JSON.parse(response.body) + # only published submissions' answers will be included in the stats + expect(json_result['submissions'][0]['courseUser']['role']).to eq('student') + expect(json_result['submissions'][1]['courseUser']['role']).to eq('student') + expect(json_result['submissions'][2]['courseUser']['role']).to eq('student') - expect(json_result['assessments'].count).to eq(1) - end + expect(json_result['ancestors'].count).to eq(2) end end end - describe '#marks_per_question' do - render_views - subject { get :marks_per_question, as: :json, params: { course_id: course, id: assessment.id } } + describe '#ancestor_statistics' do + let(:original_students) { create_list(:course_student, 3, course: original_course) } + let(:original_teaching_assistant) { create(:course_teaching_assistant, course: original_course) } - context 'when the Normal User fetch marks per question statistics' do - let(:user) { create(:user) } - before { sign_in(user) } - it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + let!(:original_submission1) do + create(:submission, :published, + assessment: original_assessment, course: original_course, creator: original_students[0].user) + end + let!(:original_submission2) do + create(:submission, :attempting, + assessment: original_assessment, course: original_course, creator: original_students[1].user) + end + let!(:original_submission_teaching_assistant) do + create(:submission, :published, + assessment: original_assessment, course: original_course, creator: original_teaching_assistant.user) end - context 'when the Course Student fetch marks per question statistics' do - let(:user) { create(:course_student, course: course).user } - before { sign_in(user) } - it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + render_views + subject do + get :ancestor_statistics, as: :json, params: { course_id: original_course, + id: original_assessment } end - context 'when the Course Manager fetch marks per question statistics' do - let(:user) { create(:course_manager, course: course).user } - before { sign_in(user) } + context 'when the course manager of the original course wants to get statistics' do + let(:course_manager) { create(:course_manager, course: original_course) } + before { sign_in(course_manager.user) } - it 'returns OK with right number of submissions being displayed' do + it 'gives only the information regarding current destination as no authorization for parent course' do expect(subject).to have_http_status(:success) json_result = JSON.parse(response.body) - # all the students data will be included here, including the non-published one - expect(json_result['submissions'].count).to eq(3) - - # only published submissions' answers will be included in the stats - expect(json_result['submissions'][0]['answers']).not_to be_nil - expect(json_result['submissions'][1]['answers']).to be_nil - expect(json_result['submissions'][2]['answers']).to be_nil + # all the students data will be included here, including the non-existing submission one + expect(json_result['allStudents'].count).to eq(3) - # showing the correct workflow state - expect(json_result['submissions'][0]['workflowState']).to eq('published') - expect(json_result['submissions'][1]['workflowState']).to eq('attempting') - expect(json_result['submissions'][2]['workflowState']).to be_nil + # however, only the existing submissions will be shown + expect(json_result['submissions'].count).to eq(2) end end + + context 'when the course manager of the current course wants to get statistics' do + let(:course_manager) { create(:course_manager, course: course) } + before { sign_in(course_manager.user) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end end end end From b9b6afdc794db0be5b8f9533670289fd025548e9 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 31 Jan 2024 18:09:42 +0800 Subject: [PATCH 27/45] get attributes for attempt count from BE --- .../statistics/assessments_controller.rb | 11 ++++---- .../course/statistics/assessments_helper.rb | 25 +++++++++++++++++++ .../assessments/main_statistics.json.jbuilder | 9 +++++++ .../course/statistics/assessmentStatistics.ts | 9 +++++++ 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 app/helpers/course/statistics/assessments_helper.rb diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 09aa676ca1..6983cdd373 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -7,11 +7,11 @@ def main_statistics @assessment = Course::Assessment.where(id: assessment_params[:id]). calculated(:maximum_grade, :question_count). preload(lesson_plan_item: [:reference_times, personal_times: :course_user], - course: :course_users).first + course: [course_users: :groups]).first submissions = Course::Assessment::Submission.where(assessment_id: assessment_params[:id]). calculated(:grade, :grader_ids). - preload(:answers, creator: :course_users) - @course_users_hash = preload_course_users_hash(current_course) + preload(answers: :question, creator: :course_users) + @course_users_hash = preload_course_users_hash(@assessment.course) load_course_user_students fetch_all_ancestor_assessments @@ -31,8 +31,6 @@ def ancestor_statistics load_course_user_students - # we do not need the nil value for this hash, since we aim only - # to display the statistics charts @student_submissions_hash = fetch_hash_for_ancestor_assessment(submissions, @all_students).compact end @@ -65,5 +63,8 @@ def create_question_related_hash @question_maximum_grade_hash = @assessment.questions.to_h do |q| [q.id, q.maximum_grade] end + @question_auto_gradable_status_hash = @assessment.questions.to_h do |q| + [q.id, q.auto_gradable?] + end end end diff --git a/app/helpers/course/statistics/assessments_helper.rb b/app/helpers/course/statistics/assessments_helper.rb new file mode 100644 index 0000000000..ee49b7d564 --- /dev/null +++ b/app/helpers/course/statistics/assessments_helper.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +module Course::Statistics::AssessmentsHelper + # Return the number of attempts and also the correctness status of the last + # submission by the submitter + # + # if the submission is still being attempted, then we return the last non-current + # attempt or current_answer in case there is no last non-current attempt + # + # this method is basically similar with the one defined inside the SubmissionHelper + # but this one avoids the n+1 problem that exists within the SubmissionHelper + # + # we cannot afford having the n+1 problem in here, since we will iterate over all + # answers from all existing submissions within the assessment + + def attempt_status(submission, question_id) + attempts = submission.answers.select { |answer| answer.question_id == question_id } + last_non_current_answer = attempts.reject(&:current_answer?).last + current_answer = attempts.find(&:current_answer?) + # Fallback to last attempt if none of the attempts have been autograded. + latest_attempt = last_non_current_answer || attempts.last + + last_attempt = submission.attempting? ? latest_attempt : current_answer + [attempts.length, last_attempt.correct] + end +end diff --git a/app/views/course/statistics/assessments/main_statistics.json.jbuilder b/app/views/course/statistics/assessments/main_statistics.json.jbuilder index fb3e2ac2f8..1535e97c6d 100644 --- a/app/views/course/statistics/assessments/main_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/main_statistics.json.jbuilder @@ -1,6 +1,7 @@ # frozen_string_literal: true json.assessment do json.id @assessment.id + json.isAutograded @assessment.autograded json.title @assessment.title json.startAt @assessment.start_at&.iso8601 json.endAt @assessment.end_at&.iso8601 @@ -29,6 +30,14 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.endAt end_at&.iso8601 json.totalGrade submission.grade + json.attemptStatus answers.each do |answer| + num_attempts, correct = attempt_status(submission, answer.question_id) + json.isAutograded @question_auto_gradable_status_hash[answer.question_id] + json.answerId answer.id + json.attemptCount num_attempts + json.correct correct + end + if submission.workflow_state == 'published' && submission.grader_ids # the graders are all the same regardless of question, so we just pick the first one grader = @course_users_hash[submission.grader_ids.first] diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index f71d03d753..a9f61ea9c9 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -10,6 +10,7 @@ interface AssessmentInfo { } interface MainAssessmentInfo extends AssessmentInfo { + isAutograded: boolean; questionCount: number; } @@ -31,6 +32,13 @@ interface AnswerInfo { maximumGrade: number; } +export interface AttemptInfo { + isAutograded: boolean; + answerId: number; + attemptCount: number; + correct: boolean | null; +} + interface SubmissionInfo { courseUser: StudentInfo; workflowState?: WorkflowState; @@ -40,6 +48,7 @@ interface SubmissionInfo { } export interface MainSubmissionInfo extends SubmissionInfo { + attemptStatus?: AttemptInfo[]; answers?: AnswerInfo[]; grader?: UserInfo; groups: { name: string }[]; From 3c6d13c02851ae719c0f84337839057adc0f6ce0 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 31 Jan 2024 18:10:10 +0800 Subject: [PATCH 28/45] display Attempt Count Table --- .../StudentAttemptCountTable.tsx | 250 ++++++++++++++++++ .../StudentMarksPerQuestionTable.tsx | 18 +- ...lorGradationLevel.ts => classNameUtils.ts} | 12 + .../pages/AssessmentStatistics/index.tsx | 12 + 4 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx rename client/app/bundles/course/assessment/pages/AssessmentStatistics/{ColorGradationLevel.ts => classNameUtils.ts} (77%) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx new file mode 100644 index 0000000000..317664f51d --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -0,0 +1,250 @@ +import { FC, ReactNode } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { Box, Chip } from '@mui/material'; +import palette from 'theme/palette'; +import { + AttemptInfo, + MainSubmissionInfo, +} from 'types/course/statistics/assessmentStatistics'; + +import { workflowStates } from 'course/assessment/submission/constants'; +import Link from 'lib/components/core/Link'; +import Note from 'lib/components/core/Note'; +import GhostIcon from 'lib/components/icons/GhostIcon'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { getClassNameForAttemptCountCell } from './classNameUtils'; +import { getAssessmentStatistics } from './selectors'; + +const translations = defineMessages({ + onlyForAutogradedAssessment: { + id: 'course.assessment.statistics.onlyForAutogradedAssessment', + defaultMessage: 'This table is only displayed for Autograded Assessment', + }, + name: { + id: 'course.assessment.statistics.name', + defaultMessage: 'Name', + }, + group: { + id: 'course.assessment.statistics.group', + defaultMessage: 'Group', + }, + searchText: { + id: 'course.assessment.statistics.searchText', + defaultMessage: 'Search by Name or Groups', + }, + answers: { + id: 'course.assessment.statistics.answers', + defaultMessage: 'Answers', + }, + questionIndex: { + id: 'course.assessment.statistics.questionIndex', + defaultMessage: 'Q{index}', + }, + noSubmission: { + id: 'course.assessment.statistics.noSubmission', + defaultMessage: 'No Submission yet', + }, + workflowState: { + id: 'course.assessment.statistics.workflowState', + defaultMessage: 'Status', + }, + filename: { + id: 'course.assessment.statistics.filename', + defaultMessage: 'Question-level Attempt Statistics for {assessment}', + }, +}); + +interface Props { + includePhantom: boolean; +} + +const statusTranslations = { + attempting: 'Attempting', + submitted: 'Submitted', + graded: 'Graded, unpublished', + published: 'Graded', + unstarted: 'Not Started', +}; + +const StudentAttemptCountTable: FC = (props) => { + const { t } = useTranslation(); + const { courseId } = useParams(); + const { includePhantom } = props; + + const statistics = useAppSelector(getAssessmentStatistics); + const assessment = statistics.assessment; + const submissions = statistics.submissions; + + if (assessment?.isAutograded) { + return ; + } + + // since submissions come from Redux store, it is immutable, and hence + // toggling between includePhantom status will render typeError if we + // use submissions. Hence the reason of using slice in here, basically + // creating a new array and use this instead for the display. + const filteredSubmissions = includePhantom + ? submissions.slice() + : submissions.slice().filter((s) => !s.courseUser.isPhantom); + + const sortedSubmission = filteredSubmissions + .sort((datum1, datum2) => + datum1.courseUser.name.localeCompare(datum2.courseUser.name), + ) + .sort( + (datum1, datum2) => + Number(datum1.courseUser.isPhantom) - + Number(datum2.courseUser.isPhantom), + ); + + // the case where the attempt count is null is handled separately inside the column + // (refer to the definition of answerColumns below) + const renderNonNullAttemptCountCell = (attempt: AttemptInfo): ReactNode => { + const className = getClassNameForAttemptCountCell(attempt); + return ( +
+ {attempt.attemptCount} +
+ ); + }; + + // the customised sorting for grades to ensure null always is less than any non-null grade + const sortNullableAttemptCount = ( + attempt1: AttemptInfo | null, + attempt2: AttemptInfo | null, + ): number => { + if (!attempt1 && !attempt2) { + return 0; + } + if (!attempt1) { + return -1; + } + if (!attempt2) { + return 1; + } + + const convertedAttempt1 = + attempt1.attemptCount * (attempt1.correct ? 1 : -1); + const convertedAttempt2 = + attempt2.attemptCount * (attempt2.correct ? 1 : -1); + return convertedAttempt1 - convertedAttempt2; + }; + + const answerColumns: ColumnTemplate[] = Array.from( + { length: assessment?.questionCount ?? 0 }, + (_, index) => { + return { + searchProps: { + getValue: (datum) => + datum.attemptStatus?.[index]?.attemptCount?.toString() ?? '', + }, + title: t(translations.questionIndex, { index: index + 1 }), + cell: (datum): ReactNode => { + return typeof datum.attemptStatus?.[index].attemptCount === 'number' + ? renderNonNullAttemptCountCell(datum.attemptStatus?.[index]) + : null; + }, + sortable: true, + csvDownloadable: true, + className: 'text-right', + sortProps: { + sort: (datum1, datum2): number => { + return sortNullableAttemptCount( + datum1.attemptStatus?.[index] ?? null, + datum2.attemptStatus?.[index] ?? null, + ); + }, + }, + }; + }, + ); + + const jointGroupsName = (datum: MainSubmissionInfo): string => + datum.groups ? datum.groups.map((g) => g.name).join(', ') : ''; + + const columns: ColumnTemplate[] = [ + { + searchProps: { + getValue: (datum) => datum.courseUser.name, + }, + title: t(translations.name), + sortable: true, + searchable: true, + cell: (datum) => ( +
+ + {datum.courseUser.name} + + {datum.courseUser.isPhantom && ( + + )} +
+ ), + csvDownloadable: true, + }, + { + of: 'groups', + title: t(translations.group), + sortable: true, + searchable: true, + searchProps: { + getValue: (datum) => jointGroupsName(datum), + }, + cell: (datum) => jointGroupsName(datum), + csvDownloadable: true, + }, + { + of: 'workflowState', + title: t(translations.workflowState), + sortable: true, + cell: (datum) => ( + + ), + className: 'center', + }, + ...answerColumns, + ]; + + return ( +
+ `data_${datum.courseUser.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` + } + getRowEqualityData={(datum): MainSubmissionInfo => datum} + getRowId={(datum): string => datum.courseUser.id.toString()} + indexing={{ indices: true }} + pagination={{ + rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], + showAllRows: true, + }} + search={{ searchPlaceholder: t(translations.searchText) }} + toolbar={{ show: true }} + /> + ); +}; + +export default StudentAttemptCountTable; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 081234597b..63cd1203fc 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -13,7 +13,7 @@ import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import { getClassNameForMarkCell } from './ColorGradationLevel'; +import { getClassNameForMarkCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; const translations = defineMessages({ @@ -35,7 +35,7 @@ const translations = defineMessages({ }, searchText: { id: 'course.assessment.statistics.searchText', - defaultMessage: 'Search by Group or Grader Name', + defaultMessage: 'Search by Name, Group, or Grader Name', }, answers: { id: 'course.assessment.statistics.answers', @@ -59,7 +59,7 @@ const translations = defineMessages({ }, filename: { id: 'course.assessment.statistics.filename', - defaultMessage: 'Question-level Statistics for {assessment}', + defaultMessage: 'Question-level Marks Statistics for {assessment}', }, }); @@ -82,12 +82,15 @@ const StudentMarksPerQuestionTable: FC = (props) => { const statistics = useAppSelector(getAssessmentStatistics); const assessment = statistics.assessment; + const submissions = statistics.submissions; - const submissions = statistics.submissions.slice(); - + // since submissions come from Redux store, it is immutable, and hence + // toggling between includePhantom status will render typeError if we + // use submissions. Hence the reason of using slice in here, basically + // creating a new array and use this instead for the display. const filteredSubmissions = includePhantom - ? submissions - : submissions.filter((s) => !s.courseUser.isPhantom); + ? submissions.slice() + : submissions.slice().filter((s) => !s.courseUser.isPhantom); const sortedSubmission = filteredSubmissions .sort((datum1, datum2) => @@ -171,6 +174,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { }, title: t(translations.name), sortable: true, + searchable: true, cell: (datum) => (
diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/ColorGradationLevel.ts b/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts similarity index 77% rename from client/app/bundles/course/assessment/pages/AssessmentStatistics/ColorGradationLevel.ts rename to client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts index bd2edca0d3..16d96d2508 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/ColorGradationLevel.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/classNameUtils.ts @@ -1,3 +1,5 @@ +import { AttemptInfo } from 'types/course/statistics/assessmentStatistics'; + const lowerGradeBackgroundColorClassName = { 0: 'bg-red-50', 100: 'bg-red-100', @@ -36,3 +38,13 @@ export const getClassNameForMarkCell = ( ? `${higherGradeBackgroundColorClassName[gradientLevel]} p-[1rem]` : `${lowerGradeBackgroundColorClassName[gradientLevel]} p-[1rem]`; }; + +export const getClassNameForAttemptCountCell = ( + attempt: AttemptInfo, +): string => { + if (!attempt.isAutograded || attempt.correct === null) { + return 'bg-gray-300 p-[1rem]'; + } + + return attempt.correct ? 'bg-green-300 p-[1rem]' : 'bg-red-300 p-[1rem]'; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx index 672348e331..a04e1c0cb0 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.tsx @@ -16,6 +16,7 @@ import SubmissionStatusMainAssessment from './SubmissionStatus/MainSubmissionCha import MainSubmissionTimeAndGradeStatistics from './SubmissionTimeAndGradeStatistics/MainSubmissionTimeAndGradeStatistics'; import DuplicationHistoryStatistics from './DuplicationHistoryStatistics'; import { getAssessmentStatistics } from './selectors'; +import StudentAttemptCountTable from './StudentAttemptCountTable'; import StudentMarksPerQuestionTable from './StudentMarksPerQuestionTable'; const translations = defineMessages({ @@ -47,6 +48,10 @@ const translations = defineMessages({ id: 'course.assessment.statistics.marksPerQuestion', defaultMessage: 'Marks Per Question', }, + attemptCount: { + id: 'course.assessment.statistics.attemptCount', + defaultMessage: 'Attempt Count', + }, gradeDistribution: { id: 'course.assessment.statistics.gradeDistribution', defaultMessage: 'Grade Distribution', @@ -83,6 +88,7 @@ const AssessmentStatisticsPage: FC = () => { marksPerQuestion: ( ), + attemptCount: , gradeDistribution: , submissionTimeAndGrade: ( @@ -137,6 +143,12 @@ const AssessmentStatisticsPage: FC = () => { label={t(translations.marksPerQuestion)} value="marksPerQuestion" /> + Date: Sat, 3 Feb 2024 00:07:29 +0800 Subject: [PATCH 29/45] performance improvement and refactoring - use SQL to fetch all answers with all its relevant information - autograded definition to be at least one question autogradable --- .../course/statistics/submissions_concern.rb | 64 ++++++++++++++++++- .../statistics/assessments_controller.rb | 6 +- .../course/statistics/assessments_helper.rb | 25 -------- .../assessments/main_statistics.json.jbuilder | 9 +-- .../StudentAttemptCountTable.tsx | 11 ++-- .../course/statistics/assessmentStatistics.ts | 2 - 6 files changed, 74 insertions(+), 43 deletions(-) delete mode 100644 app/helpers/course/statistics/assessments_helper.rb diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 6d76937843..054e607f29 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -20,14 +20,72 @@ def fetch_hash_for_ancestor_assessment(submissions, students) student_hash end + def answer_statistics_hash + submission_answer_statistics = Course::Assessment::Answer.find_by_sql(<<-SQL.squish + WITH + statistics_info AS ( + SELECT + caa.question_id, + caa.submission_id, + COUNT(*) AS attempt_count, + MAX(CASE WHEN caa.current_answer = true THEN caa.grade END) AS grade + FROM course_assessment_answers caa + JOIN course_assessment_submissions cas ON caa.submission_id = cas.id + WHERE cas.assessment_id = #{assessment_params[:id]} + GROUP BY caa.question_id, caa.submission_id + ), + + attempt_info AS ( + SELECT + caa_ranked.question_id, + caa_ranked.submission_id, + jsonb_agg(jsonb_build_array(caa_ranked.correct, caa_ranked.workflow_state)) AS submission_info + FROM ( + SELECT + caa_inner.question_id, + caa_inner.submission_id, + caa_inner.correct, + cas_inner.workflow_state, + ROW_NUMBER() OVER (PARTITION BY caa_inner.question_id, caa_inner.submission_id ORDER BY caa_inner.created_at DESC) AS row_num + FROM + course_assessment_answers caa_inner + JOIN + course_assessment_submissions cas_inner ON caa_inner.submission_id = cas_inner.id + WHERE + cas_inner.assessment_id = #{assessment_params[:id]} + ) AS caa_ranked + WHERE caa_ranked.row_num <= 2 + GROUP BY caa_ranked.question_id, caa_ranked.submission_id + ) + + SELECT + statistics_info.question_id, + statistics_info.submission_id, + statistics_info.attempt_count, + statistics_info.grade, + CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>1 != 'attempting' + THEN attempt_info.submission_info->0->>0 ELSE attempt_info.submission_info->1->>0 + END AS correct + FROM statistics_info + JOIN attempt_info + ON statistics_info.question_id = attempt_info.question_id AND statistics_info.submission_id = attempt_info.submission_id + SQL + ) + + submission_answer_statistics.group_by { |answer| answer.submission_id }. + transform_values do |grouped_answers| + grouped_answers.sort_by { |answer| @question_order_hash[answer.question_id] } + end + end + def populate_hash_including_answers(student_hash, submissions) + answers_hash = answer_statistics_hash + submissions.map do |submission| submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first next unless submitter_course_user&.student? - answers = submission.answers. - select(&:current_answer). - sort_by { |answer| @question_order_hash[answer.question_id] } + answers = answers_hash[submission.id] end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at student_hash[submitter_course_user] = [submission, answers, end_at] diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 6983cdd373..7f258992b6 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -7,15 +7,17 @@ def main_statistics @assessment = Course::Assessment.where(id: assessment_params[:id]). calculated(:maximum_grade, :question_count). preload(lesson_plan_item: [:reference_times, personal_times: :course_user], - course: [course_users: :groups]).first + course: :course_users).first submissions = Course::Assessment::Submission.where(assessment_id: assessment_params[:id]). calculated(:grade, :grader_ids). - preload(answers: :question, creator: :course_users) + preload(creator: :course_users) @course_users_hash = preload_course_users_hash(@assessment.course) load_course_user_students fetch_all_ancestor_assessments create_question_related_hash + + @assessment_autograded = @question_auto_gradable_status_hash.any? { |key, value| value } @student_submissions_hash = fetch_hash_for_main_assessment(submissions, @all_students) end diff --git a/app/helpers/course/statistics/assessments_helper.rb b/app/helpers/course/statistics/assessments_helper.rb deleted file mode 100644 index ee49b7d564..0000000000 --- a/app/helpers/course/statistics/assessments_helper.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true -module Course::Statistics::AssessmentsHelper - # Return the number of attempts and also the correctness status of the last - # submission by the submitter - # - # if the submission is still being attempted, then we return the last non-current - # attempt or current_answer in case there is no last non-current attempt - # - # this method is basically similar with the one defined inside the SubmissionHelper - # but this one avoids the n+1 problem that exists within the SubmissionHelper - # - # we cannot afford having the n+1 problem in here, since we will iterate over all - # answers from all existing submissions within the assessment - - def attempt_status(submission, question_id) - attempts = submission.answers.select { |answer| answer.question_id == question_id } - last_non_current_answer = attempts.reject(&:current_answer?).last - current_answer = attempts.find(&:current_answer?) - # Fallback to last attempt if none of the attempts have been autograded. - latest_attempt = last_non_current_answer || attempts.last - - last_attempt = submission.attempting? ? latest_attempt : current_answer - [attempts.length, last_attempt.correct] - end -end diff --git a/app/views/course/statistics/assessments/main_statistics.json.jbuilder b/app/views/course/statistics/assessments/main_statistics.json.jbuilder index 1535e97c6d..d81bf3cbc1 100644 --- a/app/views/course/statistics/assessments/main_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/main_statistics.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.assessment do json.id @assessment.id - json.isAutograded @assessment.autograded + json.isAutograded @assessment_autograded json.title @assessment.title json.startAt @assessment.start_at&.iso8601 json.endAt @assessment.end_at&.iso8601 @@ -31,11 +31,9 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.totalGrade submission.grade json.attemptStatus answers.each do |answer| - num_attempts, correct = attempt_status(submission, answer.question_id) json.isAutograded @question_auto_gradable_status_hash[answer.question_id] - json.answerId answer.id - json.attemptCount num_attempts - json.correct correct + json.attemptCount answer.attempt_count + json.correct answer.correct end if submission.workflow_state == 'published' && submission.grader_ids @@ -47,7 +45,6 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an end json.answers answers.each do |answer| - json.id answer.id json.grade answer.grade json.maximumGrade @question_maximum_grade_hash[answer.question_id] end diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx index 317664f51d..646c643c33 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -21,9 +21,10 @@ import { getClassNameForAttemptCountCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; const translations = defineMessages({ - onlyForAutogradedAssessment: { - id: 'course.assessment.statistics.onlyForAutogradedAssessment', - defaultMessage: 'This table is only displayed for Autograded Assessment', + onlyForAutogradableAssessment: { + id: 'course.assessment.statistics.onlyForAutogradableAssessment', + defaultMessage: + 'This table is only displayed for Assessment with at least one Autograded Questions', }, name: { id: 'course.assessment.statistics.name', @@ -80,8 +81,8 @@ const StudentAttemptCountTable: FC = (props) => { const assessment = statistics.assessment; const submissions = statistics.submissions; - if (assessment?.isAutograded) { - return ; + if (!assessment?.isAutograded) { + return ; } // since submissions come from Redux store, it is immutable, and hence diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index a9f61ea9c9..8f390aa4ad 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -27,14 +27,12 @@ export interface StudentInfo extends UserInfo { } interface AnswerInfo { - id: number; grade: number; maximumGrade: number; } export interface AttemptInfo { isAutograded: boolean; - answerId: number; attemptCount: number; correct: boolean | null; } From 98bca78b3b2a5b1ca8851a4d41528499255d200e Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Sat, 3 Feb 2024 00:08:05 +0800 Subject: [PATCH 30/45] resolve n+1 issue in group names and refactor - instead of sending group as list of names, now it's list of strings --- .../course/statistics/submissions_concern.rb | 2 +- .../course/statistics/users_concern.rb | 38 +++++++++++++++++++ .../statistics/assessments_controller.rb | 13 ++++--- .../assessments/main_statistics.json.jbuilder | 5 +-- .../StudentAttemptCountTable.tsx | 2 +- .../StudentMarksPerQuestionTable.tsx | 2 +- .../course/statistics/assessmentStatistics.ts | 2 +- 7 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 app/controllers/concerns/course/statistics/users_concern.rb diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 054e607f29..ac50d10cfe 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -72,7 +72,7 @@ def answer_statistics_hash SQL ) - submission_answer_statistics.group_by { |answer| answer.submission_id }. + submission_answer_statistics.group_by(&:submission_id). transform_values do |grouped_answers| grouped_answers.sort_by { |answer| @question_order_hash[answer.question_id] } end diff --git a/app/controllers/concerns/course/statistics/users_concern.rb b/app/controllers/concerns/course/statistics/users_concern.rb new file mode 100644 index 0000000000..e5939bcf01 --- /dev/null +++ b/app/controllers/concerns/course/statistics/users_concern.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +module Course::Statistics::UsersConcern + private + + def group_names_hash + group_names = Course::Group.find_by_sql(<<-SQL.squish + WITH course_students AS ( + SELECT cgu.group_id, cgu.course_user_id + FROM course_group_users cgu + JOIN ( + SELECT course_users.id + FROM course_users + WHERE course_users.role = #{CourseUser.roles[:student]} + AND course_users.course_id = #{current_course.id} + ) cu + ON cgu.course_user_id = cu.id + ), + + course_group_names AS ( + SELECT course_groups.id, course_groups.name + FROM course_groups + ) + + SELECT id, ARRAY_AGG(group_name) AS group_names + FROM ( + SELECT + cs.course_user_id as id, + cgn.name as group_name + FROM course_students cs + JOIN course_group_names cgn + ON cs.group_id = cgn.id + ) group_tables + GROUP BY group_tables.id + SQL + ) + group_names.map { |course_user| [course_user.id, course_user.group_names] }.to_h + end +end diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 7f258992b6..ccbcc1adf2 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -2,6 +2,7 @@ class Course::Statistics::AssessmentsController < Course::Statistics::Controller include Course::UsersHelper include Course::Statistics::SubmissionsConcern + include Course::Statistics::UsersConcern def main_statistics @assessment = Course::Assessment.where(id: assessment_params[:id]). @@ -13,11 +14,11 @@ def main_statistics preload(creator: :course_users) @course_users_hash = preload_course_users_hash(@assessment.course) - load_course_user_students + load_course_user_students_info fetch_all_ancestor_assessments create_question_related_hash - @assessment_autograded = @question_auto_gradable_status_hash.any? { |key, value| value } + @assessment_autograded = @question_auto_gradable_status_hash.any? { |_, value| value } @student_submissions_hash = fetch_hash_for_main_assessment(submissions, @all_students) end @@ -31,8 +32,7 @@ def ancestor_statistics where(assessment_id: assessment_params[:id]). calculated(:grade) - load_course_user_students - + @all_students = @assessment.course.course_users.students @student_submissions_hash = fetch_hash_for_ancestor_assessment(submissions, @all_students).compact end @@ -42,8 +42,9 @@ def assessment_params params.permit(:id) end - def load_course_user_students - @all_students = @assessment.course.course_users.students + def load_course_user_students_info + @all_students = current_course.course_users.students + @group_names_hash = group_names_hash end def fetch_all_ancestor_assessments diff --git a/app/views/course/statistics/assessments/main_statistics.json.jbuilder b/app/views/course/statistics/assessments/main_statistics.json.jbuilder index d81bf3cbc1..28418eb725 100644 --- a/app/views/course/statistics/assessments/main_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/main_statistics.json.jbuilder @@ -18,10 +18,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.isPhantom course_user.phantom? end - json.groups course_user.groups do |group| - json.name group.name - end - + json.groups @group_names_hash[course_user.id] json.submissionExists !submission.nil? unless submission.nil? diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx index 646c643c33..a3bcc65293 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -166,7 +166,7 @@ const StudentAttemptCountTable: FC = (props) => { ); const jointGroupsName = (datum: MainSubmissionInfo): string => - datum.groups ? datum.groups.map((g) => g.name).join(', ') : ''; + datum.groups ? datum.groups.join(', ') : ''; const columns: ColumnTemplate[] = [ { diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 63cd1203fc..2b46d53f43 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -165,7 +165,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { ); const jointGroupsName = (datum: MainSubmissionInfo): string => - datum.groups ? datum.groups.map((g) => g.name).join(', ') : ''; + datum.groups ? datum.groups.join(', ') : ''; const columns: ColumnTemplate[] = [ { diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 8f390aa4ad..21f68f94c1 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -49,7 +49,7 @@ export interface MainSubmissionInfo extends SubmissionInfo { attemptStatus?: AttemptInfo[]; answers?: AnswerInfo[]; grader?: UserInfo; - groups: { name: string }[]; + groups: string[]; submissionExists: boolean; } From cf96cb9e35ac31081109d7a2ae2779dcedbfa1e2 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 7 Feb 2024 11:21:01 +0800 Subject: [PATCH 31/45] refactor(reference time): extract end_at from SQL for performance efficiency while getting statistics --- .../statistics/reference_times_concern.rb | 86 +++++++++++++++++++ .../course/statistics/submissions_concern.rb | 14 ++- .../statistics/assessments_controller.rb | 3 +- 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 app/controllers/concerns/course/statistics/reference_times_concern.rb diff --git a/app/controllers/concerns/course/statistics/reference_times_concern.rb b/app/controllers/concerns/course/statistics/reference_times_concern.rb new file mode 100644 index 0000000000..70a44de5a1 --- /dev/null +++ b/app/controllers/concerns/course/statistics/reference_times_concern.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true +module Course::Statistics::ReferenceTimesConcern + private + + def personal_end_at_hash + personal_end_at = Course::PersonalTime.find_by_sql(<<-SQL.squish + WITH course_user_personal_end_at AS ( + SELECT course_user_id, end_at + FROM course_personal_times cpt + JOIN ( + SELECT course_lesson_plan_items.id + FROM course_lesson_plan_items + WHERE course_lesson_plan_items.actable_type = 'Course::Assessment' + AND course_lesson_plan_items.actable_id = #{assessment_params[:id]} + ) clpi + ON cpt.lesson_plan_item_id = clpi.id + ), + + personal_times AS ( + SELECT cu.id AS course_user_id, pt.end_at + FROM ( + SELECT course_users.id + FROM course_users + WHERE course_users.course_id = #{@assessment.course.id} + ) cu + LEFT JOIN ( + SELECT course_user_id, end_at + FROM course_user_personal_end_at + ) pt + ON cu.id = pt.course_user_id + ), + + personal_reference_times AS ( + SELECT cu.id AS course_user_id, crt.end_at + FROM ( + SELECT course_users.id, course_users.reference_timeline_id + FROM course_users + WHERE course_users.course_id = #{@assessment.course.id} AND course_users.role = #{CourseUser.roles[:student]} + ) cu + LEFT JOIN ( + SELECT reference_timeline_id, lesson_plan_item_id, end_at + FROM course_reference_times + ) crt + ON crt.reference_timeline_id = cu.reference_timeline_id + LEFT JOIN ( + SELECT id + FROM course_lesson_plan_items + WHERE course_lesson_plan_items.actable_type = 'Course::Assessment' + AND course_lesson_plan_items.actable_id = #{assessment_params[:id]} + ) clpi + ON crt.lesson_plan_item_id = clpi.id + ) + + SELECT + pt.course_user_id, + CASE WHEN pt.end_at IS NOT NULL THEN pt.end_at ELSE prt.end_at END AS end_at + FROM personal_times pt + LEFT JOIN personal_reference_times prt + ON pt.course_user_id = prt.course_user_id + SQL + ) + personal_end_at.map { |pea| [pea.course_user_id, pea.end_at] }.to_h + end + + def reference_times_hash + reference_times = Course::ReferenceTime.find_by_sql(<<-SQL.squish + SELECT clpi.actable_id AS lesson_plan_item_id, crt.end_at + FROM course_reference_times crt + JOIN ( + SELECT id + FROM course_reference_timelines + WHERE course_id = #{@assessment.course.id} AND "default" = TRUE + ) crtl + ON crt.reference_timeline_id = crtl.id + JOIN ( + SELECT id, actable_id + FROM course_lesson_plan_items + WHERE course_lesson_plan_items.actable_type = 'Course::Assessment' + AND course_lesson_plan_items.actable_id = #{assessment_params[:id]} + ) clpi + ON crt.lesson_plan_item_id = clpi.id + SQL + ) + reference_times.map { |rt| [rt.lesson_plan_item_id, rt.end_at] }.to_h + end +end diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index ac50d10cfe..765194acfa 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true module Course::Statistics::SubmissionsConcern + include Course::Statistics::ReferenceTimesConcern + private def initialize_student_hash(students) @@ -80,26 +82,34 @@ def answer_statistics_hash def populate_hash_including_answers(student_hash, submissions) answers_hash = answer_statistics_hash + fetch_personal_and_reference_timeline_hash submissions.map do |submission| submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first next unless submitter_course_user&.student? answers = answers_hash[submission.id] - end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at + end_at = @personal_end_at_hash[submitter_course_user.id] || @reference_times_hash[@assessment.id] student_hash[submitter_course_user] = [submission, answers, end_at] end end def populate_hash_without_answers(student_hash, submissions) + fetch_personal_and_reference_timeline_hash + submissions.map do |submission| submitter_course_user = submission.creator.course_users.select { |u| u.course_id == @assessment.course_id }.first next unless submitter_course_user&.student? - end_at = @assessment.lesson_plan_item.time_for(submitter_course_user).end_at + end_at = @personal_end_at_hash[submitter_course_user.id] || @reference_times_hash[@assessment.id] student_hash[submitter_course_user] = [submission, end_at] end end + + def fetch_personal_and_reference_timeline_hash + @personal_end_at_hash = personal_end_at_hash + @reference_times_hash = reference_times_hash + end end diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index ccbcc1adf2..27c0b801af 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -7,8 +7,7 @@ class Course::Statistics::AssessmentsController < Course::Statistics::Controller def main_statistics @assessment = Course::Assessment.where(id: assessment_params[:id]). calculated(:maximum_grade, :question_count). - preload(lesson_plan_item: [:reference_times, personal_times: :course_user], - course: :course_users).first + preload(course: :course_users).first submissions = Course::Assessment::Submission.where(assessment_id: assessment_params[:id]). calculated(:grade, :grader_ids). preload(creator: :course_users) From d304444867312881a7f40a97d4f9e33a13d0a2ce Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Fri, 16 Feb 2024 17:29:37 +0800 Subject: [PATCH 32/45] fix(statistics): show grade from last_attempt - previously, it's from current_answer. this will be wrong if current_answer is attempting --- .../course/statistics/submissions_concern.rb | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 765194acfa..5af0963b8e 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -25,12 +25,11 @@ def fetch_hash_for_ancestor_assessment(submissions, students) def answer_statistics_hash submission_answer_statistics = Course::Assessment::Answer.find_by_sql(<<-SQL.squish WITH - statistics_info AS ( + attempt_count AS ( SELECT caa.question_id, caa.submission_id, - COUNT(*) AS attempt_count, - MAX(CASE WHEN caa.current_answer = true THEN caa.grade END) AS grade + COUNT(*) AS attempt_count FROM course_assessment_answers caa JOIN course_assessment_submissions cas ON caa.submission_id = cas.id WHERE cas.assessment_id = #{assessment_params[:id]} @@ -41,12 +40,13 @@ def answer_statistics_hash SELECT caa_ranked.question_id, caa_ranked.submission_id, - jsonb_agg(jsonb_build_array(caa_ranked.correct, caa_ranked.workflow_state)) AS submission_info + jsonb_agg(jsonb_build_array(caa_ranked.grade, caa_ranked.correct, caa_ranked.workflow_state)) AS submission_info FROM ( SELECT caa_inner.question_id, caa_inner.submission_id, caa_inner.correct, + caa_inner.grade, cas_inner.workflow_state, ROW_NUMBER() OVER (PARTITION BY caa_inner.question_id, caa_inner.submission_id ORDER BY caa_inner.created_at DESC) AS row_num FROM @@ -61,16 +61,18 @@ def answer_statistics_hash ) SELECT - statistics_info.question_id, - statistics_info.submission_id, - statistics_info.attempt_count, - statistics_info.grade, - CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>1 != 'attempting' + attempt_count.question_id, + attempt_count.submission_id, + attempt_count.attempt_count, + CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>2 != 'attempting' THEN attempt_info.submission_info->0->>0 ELSE attempt_info.submission_info->1->>0 + END AS grade, + CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>2 != 'attempting' + THEN attempt_info.submission_info->0->>1 ELSE attempt_info.submission_info->1->>1 END AS correct - FROM statistics_info + FROM attempt_count JOIN attempt_info - ON statistics_info.question_id = attempt_info.question_id AND statistics_info.submission_id = attempt_info.submission_id + ON attempt_count.question_id = attempt_info.question_id AND attempt_count.submission_id = attempt_info.submission_id SQL ) From 45381c0df3f0bf6ee11181759bc0a179f9241506 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Thu, 8 Feb 2024 17:27:21 +0800 Subject: [PATCH 33/45] feat(statisticsTable): pass current answer ID to FE - make the grade and attempt count box clickable --- .../assessments/main_statistics.json.jbuilder | 2 ++ .../StudentAttemptCountTable.tsx | 2 +- .../StudentMarksPerQuestionTable.tsx | 13 +++++++++---- .../types/course/statistics/assessmentStatistics.ts | 2 ++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/views/course/statistics/assessments/main_statistics.json.jbuilder b/app/views/course/statistics/assessments/main_statistics.json.jbuilder index 28418eb725..a6801b7d1b 100644 --- a/app/views/course/statistics/assessments/main_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/main_statistics.json.jbuilder @@ -28,6 +28,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.totalGrade submission.grade json.attemptStatus answers.each do |answer| + json.currentAnswerId answer.current_answer_id json.isAutograded @question_auto_gradable_status_hash[answer.question_id] json.attemptCount answer.attempt_count json.correct answer.correct @@ -42,6 +43,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an end json.answers answers.each do |answer| + json.currentAnswerId answer.current_answer_id json.grade answer.grade json.maximumGrade @question_maximum_grade_hash[answer.question_id] end diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx index a3bcc65293..0e7fbd1d97 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -108,7 +108,7 @@ const StudentAttemptCountTable: FC = (props) => { const renderNonNullAttemptCountCell = (attempt: AttemptInfo): ReactNode => { const className = getClassNameForAttemptCountCell(attempt); return ( -
+
{attempt.attemptCount}
); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 2b46d53f43..526ed8a023 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -104,13 +104,16 @@ const StudentMarksPerQuestionTable: FC = (props) => { // the case where the grade is null is handled separately inside the column // (refer to the definition of answerColumns below) - const renderNonNullGradeCell = ( + const renderAnswerGradeCell = ( + currentAnswerId: number | null, grade: number, maxGrade: number, ): ReactNode => { const className = getClassNameForMarkCell(grade, maxGrade); return ( -
+
{grade.toFixed(1)}
); @@ -143,7 +146,8 @@ const StudentMarksPerQuestionTable: FC = (props) => { title: t(translations.questionIndex, { index: index + 1 }), cell: (datum): ReactNode => { return typeof datum.answers?.[index].grade === 'number' - ? renderNonNullGradeCell( + ? renderAnswerGradeCell( + datum.answers?.[index].currentAnswerId, datum.answers?.[index].grade, datum.answers?.[index].maximumGrade, ) @@ -228,7 +232,8 @@ const StudentMarksPerQuestionTable: FC = (props) => { sortable: true, cell: (datum): ReactNode => datum.totalGrade - ? renderNonNullGradeCell( + ? renderAnswerGradeCell( + null, datum.totalGrade ?? null, assessment!.maximumGrade, ) diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 21f68f94c1..2e301fa49b 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -27,11 +27,13 @@ export interface StudentInfo extends UserInfo { } interface AnswerInfo { + currentAnswerId: number; grade: number; maximumGrade: number; } export interface AttemptInfo { + currentAnswerId: number; isAutograded: boolean; attemptCount: number; correct: boolean | null; From c27ab2f4c8d6bddbefcd518144f3354016d690b7 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 13 Feb 2024 15:03:14 +0800 Subject: [PATCH 34/45] feat(statistics): render question details in each answer - each grade and attempt count cell is clickable - after clicking, the box containing question will appear --- .../course/statistics/answers_controller.rb | 13 ++ .../answers/answer_details.json.jbuilder | 6 + .../api/course/Statistics/AnswerStatistics.ts | 15 +++ client/app/api/course/Statistics/index.ts | 2 + .../assessment/operations/statistics.ts | 14 ++- .../AssessmentStatistics/AnswerDisplay.tsx | 36 ++++++ .../StudentAttemptCountTable.tsx | 95 +++++++++++---- .../StudentMarksPerQuestionTable.tsx | 115 ++++++++++++------ .../course/statistics/assessmentStatistics.ts | 12 +- config/routes.rb | 5 +- 10 files changed, 242 insertions(+), 71 deletions(-) create mode 100644 app/controllers/course/statistics/answers_controller.rb create mode 100644 app/views/course/statistics/answers/answer_details.json.jbuilder create mode 100644 client/app/api/course/Statistics/AnswerStatistics.ts create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb new file mode 100644 index 0000000000..a3a281a12b --- /dev/null +++ b/app/controllers/course/statistics/answers_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +class Course::Statistics::AnswersController < Course::Statistics::Controller + def answer_details + answer = Course::Assessment::Answer.find(answer_params[:id]) + @question = answer.question + end + + private + + def answer_params + params.permit(:id) + end +end diff --git a/app/views/course/statistics/answers/answer_details.json.jbuilder b/app/views/course/statistics/answers/answer_details.json.jbuilder new file mode 100644 index 0000000000..101fb1d4fc --- /dev/null +++ b/app/views/course/statistics/answers/answer_details.json.jbuilder @@ -0,0 +1,6 @@ +# frozen_string_literal: true +json.question do + json.id @question.id + json.title @question.title + json.description format_ckeditor_rich_text(@question.description) +end diff --git a/client/app/api/course/Statistics/AnswerStatistics.ts b/client/app/api/course/Statistics/AnswerStatistics.ts new file mode 100644 index 0000000000..326021b295 --- /dev/null +++ b/client/app/api/course/Statistics/AnswerStatistics.ts @@ -0,0 +1,15 @@ +import { AnswerDetails } from 'types/course/statistics/assessmentStatistics'; + +import { APIResponse } from 'api/types'; + +import BaseCourseAPI from '../Base'; + +export default class AnswerStatisticsAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/statistics/answer`; + } + + fetchAnswerDetails(answerId: number): APIResponse { + return this.client.get(`${this.#urlPrefix}/${answerId}`); + } +} diff --git a/client/app/api/course/Statistics/index.ts b/client/app/api/course/Statistics/index.ts index 6098ff0fda..9bd3484fd6 100644 --- a/client/app/api/course/Statistics/index.ts +++ b/client/app/api/course/Statistics/index.ts @@ -1,9 +1,11 @@ +import AnswerStatisticsAPI from './AnswerStatistics'; import AssessmentStatisticsAPI from './AssessmentStatistics'; import CourseStatisticsAPI from './CourseStatistics'; import UserStatisticsAPI from './UserStatistics'; const StatisticsAPI = { assessment: new AssessmentStatisticsAPI(), + answer: new AnswerStatisticsAPI(), course: new CourseStatisticsAPI(), user: new UserStatisticsAPI(), }; diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts index cd60ed57c0..abc44900e9 100644 --- a/client/app/bundles/course/assessment/operations/statistics.ts +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -1,5 +1,8 @@ import { Operation } from 'store'; -import { AncestorAssessmentStats } from 'types/course/statistics/assessmentStatistics'; +import { + AncestorAssessmentStats, + AnswerDetails, +} from 'types/course/statistics/assessmentStatistics'; import CourseAPI from 'api/course'; @@ -32,3 +35,12 @@ export const fetchAncestorStatistics = async ( return response.data; }; + +export const fetchAnswerDetails = async ( + answerId: number, +): Promise => { + const response = + await CourseAPI.statistics.answer.fetchAnswerDetails(answerId); + + return response.data; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx new file mode 100644 index 0000000000..3fe61312d9 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { Divider, Typography } from '@mui/material'; +import { AnswerDetails } from 'types/course/statistics/assessmentStatistics'; + +import { fetchAnswerDetails } from 'course/assessment/operations/statistics'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; + +interface Props { + curAnswerId: number; +} + +const AnswerDisplay: FC = (props) => { + const { curAnswerId } = props; + + const fetchCurrentAnswerDetails = (): Promise => { + return fetchAnswerDetails(curAnswerId); + }; + + return ( + } while={fetchCurrentAnswerDetails}> + {(data): JSX.Element => ( +
+ {data.question.title} + + +
+ )} +
+ ); +}; + +export default AnswerDisplay; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx index 0e7fbd1d97..d72011bc1b 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode } from 'react'; +import { FC, ReactNode, useState } from 'react'; import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Box, Chip } from '@mui/material'; @@ -9,6 +9,7 @@ import { } from 'types/course/statistics/assessmentStatistics'; import { workflowStates } from 'course/assessment/submission/constants'; +import Prompt from 'lib/components/core/dialogs/Prompt'; import Link from 'lib/components/core/Link'; import Note from 'lib/components/core/Note'; import GhostIcon from 'lib/components/icons/GhostIcon'; @@ -17,6 +18,7 @@ import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; +import AnswerDisplay from './AnswerDisplay'; import { getClassNameForAttemptCountCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; @@ -46,6 +48,10 @@ const translations = defineMessages({ id: 'course.assessment.statistics.questionIndex', defaultMessage: 'Q{index}', }, + questionDisplayTitle: { + id: 'course.assessment.statistics.questionDisplayTitle', + defaultMessage: 'Q{index} for {studentName}', + }, noSubmission: { id: 'course.assessment.statistics.noSubmission', defaultMessage: 'No Submission yet', @@ -78,6 +84,12 @@ const StudentAttemptCountTable: FC = (props) => { const { includePhantom } = props; const statistics = useAppSelector(getAssessmentStatistics); + const [openPastAnswers, setOpenPastAnswers] = useState(false); + const [answerInfo, setAnswerInfo] = useState({ + index: 0, + answerId: 0, + studentName: '', + }); const assessment = statistics.assessment; const submissions = statistics.submissions; @@ -105,11 +117,27 @@ const StudentAttemptCountTable: FC = (props) => { // the case where the attempt count is null is handled separately inside the column // (refer to the definition of answerColumns below) - const renderNonNullAttemptCountCell = (attempt: AttemptInfo): ReactNode => { - const className = getClassNameForAttemptCountCell(attempt); + const renderAttemptCountClickableCell = ( + index: number, + datum: MainSubmissionInfo, + ): ReactNode => { + const className = getClassNameForAttemptCountCell( + datum.attemptStatus![index], + ); + return ( -
- {attempt.attemptCount} +
{ + setOpenPastAnswers(true); + setAnswerInfo({ + index: index + 1, + answerId: datum.answers![index].currentAnswerId, + studentName: datum.courseUser.name, + }); + }} + > + {datum.attemptStatus![index].attemptCount}
); }; @@ -147,7 +175,7 @@ const StudentAttemptCountTable: FC = (props) => { title: t(translations.questionIndex, { index: index + 1 }), cell: (datum): ReactNode => { return typeof datum.attemptStatus?.[index].attemptCount === 'number' - ? renderNonNullAttemptCountCell(datum.attemptStatus?.[index]) + ? renderAttemptCountClickableCell(index, datum) : null; }, sortable: true, @@ -224,27 +252,40 @@ const StudentAttemptCountTable: FC = (props) => { ]; return ( -
- `data_${datum.courseUser.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` - } - getRowEqualityData={(datum): MainSubmissionInfo => datum} - getRowId={(datum): string => datum.courseUser.id.toString()} - indexing={{ indices: true }} - pagination={{ - rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], - showAllRows: true, - }} - search={{ searchPlaceholder: t(translations.searchText) }} - toolbar={{ show: true }} - /> + <> +
+ `data_${datum.courseUser.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` + } + getRowEqualityData={(datum): MainSubmissionInfo => datum} + getRowId={(datum): string => datum.courseUser.id.toString()} + indexing={{ indices: true }} + pagination={{ + rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], + showAllRows: true, + }} + search={{ searchPlaceholder: t(translations.searchText) }} + toolbar={{ show: true }} + /> + + setOpenPastAnswers(false)} + open={openPastAnswers} + title={t(translations.questionDisplayTitle, { + index: answerInfo.index, + studentName: answerInfo.studentName, + })} + > + + + ); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 526ed8a023..f0d66a3928 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode } from 'react'; +import { FC, ReactNode, useState } from 'react'; import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Box, Chip } from '@mui/material'; @@ -6,6 +6,7 @@ import palette from 'theme/palette'; import { MainSubmissionInfo } from 'types/course/statistics/assessmentStatistics'; import { workflowStates } from 'course/assessment/submission/constants'; +import Prompt from 'lib/components/core/dialogs/Prompt'; import Link from 'lib/components/core/Link'; import GhostIcon from 'lib/components/icons/GhostIcon'; import Table, { ColumnTemplate } from 'lib/components/table'; @@ -13,6 +14,7 @@ import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; +import AnswerDisplay from './AnswerDisplay'; import { getClassNameForMarkCell } from './classNameUtils'; import { getAssessmentStatistics } from './selectors'; @@ -47,7 +49,7 @@ const translations = defineMessages({ }, questionDisplayTitle: { id: 'course.assessment.statistics.questionDisplayTitle', - defaultMessage: 'Q{index} for {student}', + defaultMessage: 'Q{index} for {studentName}', }, noSubmission: { id: 'course.assessment.statistics.noSubmission', @@ -81,6 +83,12 @@ const StudentMarksPerQuestionTable: FC = (props) => { const { includePhantom } = props; const statistics = useAppSelector(getAssessmentStatistics); + const [openAnswer, setOpenAnswer] = useState(false); + const [answerInfo, setAnswerInfo] = useState({ + index: 0, + answerId: 0, + studentName: '', + }); const assessment = statistics.assessment; const submissions = statistics.submissions; @@ -104,17 +112,39 @@ const StudentMarksPerQuestionTable: FC = (props) => { // the case where the grade is null is handled separately inside the column // (refer to the definition of answerColumns below) - const renderAnswerGradeCell = ( - currentAnswerId: number | null, - grade: number, - maxGrade: number, + const renderAnswerGradeClickableCell = ( + index: number, + datum: MainSubmissionInfo, ): ReactNode => { - const className = getClassNameForMarkCell(grade, maxGrade); + const className = getClassNameForMarkCell( + datum.answers![index].grade, + datum.answers![index].maximumGrade, + ); return (
{ + setOpenAnswer(true); + setAnswerInfo({ + index: index + 1, + answerId: datum.answers![index].currentAnswerId, + studentName: datum.courseUser.name, + }); + }} > - {grade.toFixed(1)} + {datum.answers![index].grade.toFixed(1)} +
+ ); + }; + + const renderTotalGradeCell = ( + totalGrade: number, + maxGrade: number, + ): ReactNode => { + const className = getClassNameForMarkCell(totalGrade, maxGrade); + return ( +
+ {totalGrade.toFixed(1)}
); }; @@ -146,11 +176,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { title: t(translations.questionIndex, { index: index + 1 }), cell: (datum): ReactNode => { return typeof datum.answers?.[index].grade === 'number' - ? renderAnswerGradeCell( - datum.answers?.[index].currentAnswerId, - datum.answers?.[index].grade, - datum.answers?.[index].maximumGrade, - ) + ? renderAnswerGradeClickableCell(index, datum) : null; }, sortable: true, @@ -232,11 +258,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { sortable: true, cell: (datum): ReactNode => datum.totalGrade - ? renderAnswerGradeCell( - null, - datum.totalGrade ?? null, - assessment!.maximumGrade, - ) + ? renderTotalGradeCell(datum.totalGrade, assessment!.maximumGrade) : null, className: 'text-right', sortProps: { @@ -271,27 +293,40 @@ const StudentMarksPerQuestionTable: FC = (props) => { ]; return ( -
- `data_${datum.courseUser.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` - } - getRowEqualityData={(datum): MainSubmissionInfo => datum} - getRowId={(datum): string => datum.courseUser.id.toString()} - indexing={{ indices: true }} - pagination={{ - rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], - showAllRows: true, - }} - search={{ searchPlaceholder: t(translations.searchText) }} - toolbar={{ show: true }} - /> + <> +
+ `data_${datum.courseUser.id} bg-slot-1 hover?:bg-slot-2 slot-1-white slot-2-neutral-100` + } + getRowEqualityData={(datum): MainSubmissionInfo => datum} + getRowId={(datum): string => datum.courseUser.id.toString()} + indexing={{ indices: true }} + pagination={{ + rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], + showAllRows: true, + }} + search={{ searchPlaceholder: t(translations.searchText) }} + toolbar={{ show: true }} + /> + + setOpenAnswer(false)} + open={openAnswer} + title={t(translations.questionDisplayTitle, { + index: answerInfo.index, + studentName: answerInfo.studentName, + })} + > + + + ); }; diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 2e301fa49b..96f5e216f9 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -26,7 +26,7 @@ export interface StudentInfo extends UserInfo { role: 'student'; } -interface AnswerInfo { +export interface AnswerInfo { currentAnswerId: number; grade: number; maximumGrade: number; @@ -84,3 +84,13 @@ export interface AncestorAssessmentStats { export interface AssessmentStatisticsStore extends MainAssessmentStats { isLoading: boolean; } + +interface QuestionDetails { + id: number; + title: string; + description: string; +} + +export interface AnswerDetails { + question: QuestionDetails; +} diff --git a/config/routes.rb b/config/routes.rb index 69d8997118..ef0b828286 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -431,13 +431,14 @@ namespace :statistics do get '/' => 'statistics#index' + get 'answer/:id' => 'answers#answer_details' + get 'assessment/:id/main_statistics' => 'assessments#main_statistics' + get 'assessment/:id/ancestor_statistics' => 'assessments#ancestor_statistics' get 'course/students' => 'aggregate#all_students' get 'course/staff' => 'aggregate#all_staff' get 'course/course/progression' => 'aggregate#course_progression' get 'course/course/performance' => 'aggregate#course_performance' get 'user/:user_id/learning_rate_records' => 'users#learning_rate_records' - get 'assessment/:id/main_statistics' => 'assessments#main_statistics' - get 'assessment/:id/ancestor_statistics' => 'assessments#ancestor_statistics' end scope module: :video do From 39beed61477649b7361df9d481b3e88fcae40427 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 14 Feb 2024 10:28:43 +0800 Subject: [PATCH 35/45] refactor(statistics): modify API to get answer - fetch question and answer details in one API - use Accordion for question to allow for collapsing question - include grade display inside answer box --- .../course/statistics/answers_controller.rb | 9 ++- .../answers/answer_details.json.jbuilder | 6 -- .../question_answer_details.json.jbuilder | 18 +++++ .../api/course/Statistics/AnswerStatistics.ts | 6 +- .../assessment/operations/statistics.ts | 8 +- .../AssessmentStatistics/AnswerDisplay.tsx | 80 ++++++++++++++----- .../StudentAttemptCountTable.tsx | 14 ++-- .../StudentMarksPerQuestionTable.tsx | 14 ++-- .../course/statistics/assessmentStatistics.ts | 10 ++- config/routes.rb | 2 +- 10 files changed, 114 insertions(+), 53 deletions(-) delete mode 100644 app/views/course/statistics/answers/answer_details.json.jbuilder create mode 100644 app/views/course/statistics/answers/question_answer_details.json.jbuilder diff --git a/app/controllers/course/statistics/answers_controller.rb b/app/controllers/course/statistics/answers_controller.rb index a3a281a12b..905218e243 100644 --- a/app/controllers/course/statistics/answers_controller.rb +++ b/app/controllers/course/statistics/answers_controller.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true class Course::Statistics::AnswersController < Course::Statistics::Controller - def answer_details - answer = Course::Assessment::Answer.find(answer_params[:id]) - @question = answer.question + helper Course::Assessment::Submission::SubmissionsHelper.name.sub(/Helper$/, '') + + def question_answer_details + @answer = Course::Assessment::Answer.find(answer_params[:id]) + @submission = @answer.submission + @assessment = @submission.assessment end private diff --git a/app/views/course/statistics/answers/answer_details.json.jbuilder b/app/views/course/statistics/answers/answer_details.json.jbuilder deleted file mode 100644 index 101fb1d4fc..0000000000 --- a/app/views/course/statistics/answers/answer_details.json.jbuilder +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true -json.question do - json.id @question.id - json.title @question.title - json.description format_ckeditor_rich_text(@question.description) -end diff --git a/app/views/course/statistics/answers/question_answer_details.json.jbuilder b/app/views/course/statistics/answers/question_answer_details.json.jbuilder new file mode 100644 index 0000000000..33ef719338 --- /dev/null +++ b/app/views/course/statistics/answers/question_answer_details.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true +question = @answer.question + +json.question do + json.id question.id + json.title question.title + json.maximumGrade question.maximum_grade + json.description format_ckeditor_rich_text(question.description) + json.questionType question.question_type + + json.partial! question, question: question.specific, can_grade: false, answer: @answer +end + +specific_answer = @answer.specific +json.answer do + json.grade @answer.grade + json.partial! specific_answer, answer: specific_answer, can_grade: false +end diff --git a/client/app/api/course/Statistics/AnswerStatistics.ts b/client/app/api/course/Statistics/AnswerStatistics.ts index 326021b295..cee2eee923 100644 --- a/client/app/api/course/Statistics/AnswerStatistics.ts +++ b/client/app/api/course/Statistics/AnswerStatistics.ts @@ -1,4 +1,4 @@ -import { AnswerDetails } from 'types/course/statistics/assessmentStatistics'; +import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; import { APIResponse } from 'api/types'; @@ -9,7 +9,9 @@ export default class AnswerStatisticsAPI extends BaseCourseAPI { return `/courses/${this.courseId}/statistics/answer`; } - fetchAnswerDetails(answerId: number): APIResponse { + fetchQuestionAnswerDetails( + answerId: number, + ): APIResponse { return this.client.get(`${this.#urlPrefix}/${answerId}`); } } diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts index abc44900e9..7c90c922f9 100644 --- a/client/app/bundles/course/assessment/operations/statistics.ts +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -1,7 +1,7 @@ import { Operation } from 'store'; import { AncestorAssessmentStats, - AnswerDetails, + QuestionAnswerDetails, } from 'types/course/statistics/assessmentStatistics'; import CourseAPI from 'api/course'; @@ -36,11 +36,11 @@ export const fetchAncestorStatistics = async ( return response.data; }; -export const fetchAnswerDetails = async ( +export const fetchQuestionAnswerDetails = async ( answerId: number, -): Promise => { +): Promise => { const response = - await CourseAPI.statistics.answer.fetchAnswerDetails(answerId); + await CourseAPI.statistics.answer.fetchQuestionAnswerDetails(answerId); return response.data; }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx index 3fe61312d9..1be8e26b37 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx @@ -1,34 +1,78 @@ import { FC } from 'react'; -import { Divider, Typography } from '@mui/material'; -import { AnswerDetails } from 'types/course/statistics/assessmentStatistics'; +import { defineMessages } from 'react-intl'; +import { Chip, Typography } from '@mui/material'; +import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; -import { fetchAnswerDetails } from 'course/assessment/operations/statistics'; +import { fetchQuestionAnswerDetails } from 'course/assessment/operations/statistics'; +import Accordion from 'lib/components/core/layouts/Accordion'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { getClassNameForMarkCell } from './classNameUtils'; + +const translations = defineMessages({ + questionTitle: { + id: 'course.assessment.statistics.questionTitle', + defaultMessage: 'Question {index}', + }, + gradeDisplay: { + id: 'course.assessment.statistics.gradeDisplay', + defaultMessage: 'Grade: {grade} / {maxGrade}', + }, +}); interface Props { curAnswerId: number; + index: number; } const AnswerDisplay: FC = (props) => { - const { curAnswerId } = props; + const { curAnswerId, index } = props; + const { t } = useTranslation(); - const fetchCurrentAnswerDetails = (): Promise => { - return fetchAnswerDetails(curAnswerId); - }; + const fetchQuestionAndCurrentAnswerDetails = + (): Promise => { + return fetchQuestionAnswerDetails(curAnswerId); + }; return ( - } while={fetchCurrentAnswerDetails}> - {(data): JSX.Element => ( -
- {data.question.title} - - -
- )} + } + while={fetchQuestionAndCurrentAnswerDetails} + > + {(data): JSX.Element => { + const gradeCellColor = getClassNameForMarkCell( + data.answer.grade, + data.question.maximumGrade, + ); + return ( + <> + +
+ {data.question.title} + +
+
+ + + ); + }}
); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx index d72011bc1b..22c68fb342 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -48,10 +48,6 @@ const translations = defineMessages({ id: 'course.assessment.statistics.questionIndex', defaultMessage: 'Q{index}', }, - questionDisplayTitle: { - id: 'course.assessment.statistics.questionDisplayTitle', - defaultMessage: 'Q{index} for {studentName}', - }, noSubmission: { id: 'course.assessment.statistics.noSubmission', defaultMessage: 'No Submission yet', @@ -278,12 +274,12 @@ const StudentAttemptCountTable: FC = (props) => { setOpenPastAnswers(false)} open={openPastAnswers} - title={t(translations.questionDisplayTitle, { - index: answerInfo.index, - studentName: answerInfo.studentName, - })} + title={answerInfo.studentName} > - + ); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index f0d66a3928..0ce02f117c 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -47,10 +47,6 @@ const translations = defineMessages({ id: 'course.assessment.statistics.questionIndex', defaultMessage: 'Q{index}', }, - questionDisplayTitle: { - id: 'course.assessment.statistics.questionDisplayTitle', - defaultMessage: 'Q{index} for {studentName}', - }, noSubmission: { id: 'course.assessment.statistics.noSubmission', defaultMessage: 'No Submission yet', @@ -319,12 +315,12 @@ const StudentMarksPerQuestionTable: FC = (props) => { setOpenAnswer(false)} open={openAnswer} - title={t(translations.questionDisplayTitle, { - index: answerInfo.index, - studentName: answerInfo.studentName, - })} + title={answerInfo.studentName} > - + ); diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 96f5e216f9..427c6b1505 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -1,3 +1,4 @@ +import { QuestionType } from '../assessment/question'; import { WorkflowState } from '../assessment/submission/submission'; interface AssessmentInfo { @@ -89,8 +90,15 @@ interface QuestionDetails { id: number; title: string; description: string; + questionType: QuestionType; + maximumGrade: number; +} + +interface AnswerDetails { + grade: number; } -export interface AnswerDetails { +export interface QuestionAnswerDetails { question: QuestionDetails; + answer: AnswerDetails; } diff --git a/config/routes.rb b/config/routes.rb index ef0b828286..c06bf12ec0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -431,7 +431,7 @@ namespace :statistics do get '/' => 'statistics#index' - get 'answer/:id' => 'answers#answer_details' + get 'answer/:id' => 'answers#question_answer_details' get 'assessment/:id/main_statistics' => 'assessments#main_statistics' get 'assessment/:id/ancestor_statistics' => 'assessments#ancestor_statistics' get 'course/students' => 'aggregate#all_students' From ff702a1fc76f65e7cc9ebccaf27f857d8f82139e Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 14 Feb 2024 14:50:16 +0800 Subject: [PATCH 36/45] refactor(statistics): questionType -> type --- .../statistics/answers/question_answer_details.json.jbuilder | 2 +- client/app/types/course/statistics/assessmentStatistics.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/course/statistics/answers/question_answer_details.json.jbuilder b/app/views/course/statistics/answers/question_answer_details.json.jbuilder index 33ef719338..8acd9f0083 100644 --- a/app/views/course/statistics/answers/question_answer_details.json.jbuilder +++ b/app/views/course/statistics/answers/question_answer_details.json.jbuilder @@ -6,7 +6,7 @@ json.question do json.title question.title json.maximumGrade question.maximum_grade json.description format_ckeditor_rich_text(question.description) - json.questionType question.question_type + json.type question.question_type json.partial! question, question: question.specific, can_grade: false, answer: @answer end diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 427c6b1505..222db3afa8 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -90,7 +90,7 @@ interface QuestionDetails { id: number; title: string; description: string; - questionType: QuestionType; + type: QuestionType; maximumGrade: number; } From 0cfec9dfb7293900cc4c420553367045188d6f5a Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Wed, 14 Feb 2024 14:51:51 +0800 Subject: [PATCH 37/45] refactor(Answer): put only props that are necessary for question --- .../submission/components/answers/Answer.tsx | 4 ++-- .../components/answers/FileUpload/index.jsx | 4 ++-- .../answers/ForumPostResponse/index.jsx | 4 ++-- .../answers/MultipleChoice/index.jsx | 6 +++--- .../answers/MultipleResponse/index.jsx | 6 +++--- .../components/answers/Programming/index.jsx | 4 ++-- .../components/answers/TextResponse/index.jsx | 4 ++-- .../submission/components/answers/index.tsx | 21 +++++++++++-------- .../submission/components/answers/types.ts | 4 ++-- .../course/assessment/submission/propTypes.js | 6 ++++++ .../assessment/submission/question/types.ts | 9 ++++++++ 11 files changed, 45 insertions(+), 27 deletions(-) diff --git a/client/app/bundles/course/assessment/submission/components/answers/Answer.tsx b/client/app/bundles/course/assessment/submission/components/answers/Answer.tsx index e48aa7a803..f94cc43285 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/Answer.tsx +++ b/client/app/bundles/course/assessment/submission/components/answers/Answer.tsx @@ -1,7 +1,7 @@ import { defineMessages } from 'react-intl'; import { Alert, Card, CardContent } from '@mui/material'; import { QuestionType } from 'types/course/assessment/question'; -import { SubmissionQuestionData } from 'types/course/assessment/submission/question/types'; +import { SubmissionQuestionMiniData } from 'types/course/assessment/submission/question/types'; import useTranslation from 'lib/hooks/useTranslation'; @@ -210,7 +210,7 @@ export const AnswerMapper = { interface AnswerComponentProps { answerId: number | null; questionType: T; - question: SubmissionQuestionData; + question: SubmissionQuestionMiniData; answerProps: AnswerPropsMap[T]; } diff --git a/client/app/bundles/course/assessment/submission/components/answers/FileUpload/index.jsx b/client/app/bundles/course/assessment/submission/components/answers/FileUpload/index.jsx index 39d76510d1..a4eae07310 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/FileUpload/index.jsx +++ b/client/app/bundles/course/assessment/submission/components/answers/FileUpload/index.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { useAppSelector } from 'lib/hooks/store'; import UploadedFileView from '../../../containers/UploadedFileView'; -import { questionShape } from '../../../propTypes'; +import { questionMiniShape } from '../../../propTypes'; import { getIsSavingAnswer } from '../../../selectors/answerFlags'; import FileInputField from '../../FileInput'; @@ -35,7 +35,7 @@ const FileUpload = ({ FileUpload.propTypes = { answerId: PropTypes.number.isRequired, handleUploadTextResponseFiles: PropTypes.func.isRequired, - question: questionShape.isRequired, + question: questionMiniShape.isRequired, readOnly: PropTypes.bool.isRequired, }; diff --git a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/index.jsx b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/index.jsx index 12f0042ec4..78280d023e 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/index.jsx +++ b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/index.jsx @@ -3,7 +3,7 @@ import { Controller, useFormContext } from 'react-hook-form'; import { Typography } from '@mui/material'; import PropTypes from 'prop-types'; -import { questionShape } from 'course/assessment/submission/propTypes'; +import { questionMiniShape } from 'course/assessment/submission/propTypes'; import Error from 'lib/components/core/ErrorCard'; import FormRichTextField from 'lib/components/form/fields/RichTextField'; import toast from 'lib/hooks/toast'; @@ -81,7 +81,7 @@ const ForumPostResponse = (props) => { ForumPostResponse.propTypes = { answerId: PropTypes.number.isRequired, - question: questionShape.isRequired, + question: questionMiniShape.isRequired, readOnly: PropTypes.bool.isRequired, saveAnswerAndUpdateClientVersion: PropTypes.func, }; diff --git a/client/app/bundles/course/assessment/submission/components/answers/MultipleChoice/index.jsx b/client/app/bundles/course/assessment/submission/components/answers/MultipleChoice/index.jsx index fb1bf3ccb2..6cc44e4e55 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/MultipleChoice/index.jsx +++ b/client/app/bundles/course/assessment/submission/components/answers/MultipleChoice/index.jsx @@ -6,7 +6,7 @@ import PropTypes from 'prop-types'; import propsAreEqual from 'lib/components/form/fields/utils/propsAreEqual'; -import { questionShape } from '../../../propTypes'; +import { questionMiniShape } from '../../../propTypes'; const MultipleChoiceOptions = ({ readOnly, @@ -47,7 +47,7 @@ const MultipleChoiceOptions = ({ ); MultipleChoiceOptions.propTypes = { - question: questionShape, + question: questionMiniShape, readOnly: PropTypes.bool, showMcqMrqSolution: PropTypes.bool, graderView: PropTypes.bool, @@ -109,7 +109,7 @@ const MultipleChoice = (props) => { MultipleChoice.propTypes = { answerId: PropTypes.number.isRequired, graderView: PropTypes.bool.isRequired, - question: questionShape.isRequired, + question: questionMiniShape.isRequired, readOnly: PropTypes.bool.isRequired, saveAnswerAndUpdateClientVersion: PropTypes.func.isRequired, showMcqMrqSolution: PropTypes.bool.isRequired, diff --git a/client/app/bundles/course/assessment/submission/components/answers/MultipleResponse/index.jsx b/client/app/bundles/course/assessment/submission/components/answers/MultipleResponse/index.jsx index d8968397a8..d334cd2ffd 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/MultipleResponse/index.jsx +++ b/client/app/bundles/course/assessment/submission/components/answers/MultipleResponse/index.jsx @@ -6,7 +6,7 @@ import PropTypes from 'prop-types'; import propsAreEqual from 'lib/components/form/fields/utils/propsAreEqual'; -import { questionShape } from '../../../propTypes'; +import { questionMiniShape } from '../../../propTypes'; const MultipleResponseOptions = ({ readOnly, @@ -58,7 +58,7 @@ const MultipleResponseOptions = ({ ); MultipleResponseOptions.propTypes = { - question: questionShape, + question: questionMiniShape, readOnly: PropTypes.bool, showMcqMrqSolution: PropTypes.bool, graderView: PropTypes.bool, @@ -124,7 +124,7 @@ const MultipleResponse = (props) => { MultipleResponse.propTypes = { answerId: PropTypes.number.isRequired, graderView: PropTypes.bool.isRequired, - question: questionShape.isRequired, + question: questionMiniShape.isRequired, readOnly: PropTypes.bool.isRequired, saveAnswerAndUpdateClientVersion: PropTypes.func.isRequired, showMcqMrqSolution: PropTypes.bool.isRequired, diff --git a/client/app/bundles/course/assessment/submission/components/answers/Programming/index.jsx b/client/app/bundles/course/assessment/submission/components/answers/Programming/index.jsx index 36f7dd9029..64a84dcb81 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/Programming/index.jsx +++ b/client/app/bundles/course/assessment/submission/components/answers/Programming/index.jsx @@ -10,7 +10,7 @@ import 'ace-builds/src-noconflict/theme-github'; import CodaveriFeedbackStatus from '../../../containers/CodaveriFeedbackStatus'; import ProgrammingImportEditor from '../../../containers/ProgrammingImport/ProgrammingImportEditor'; import TestCaseView from '../../../containers/TestCaseView'; -import { questionShape } from '../../../propTypes'; +import { questionMiniShape } from '../../../propTypes'; import { parseLanguages } from '../../../utils'; import ProgrammingFile from './ProgrammingFile'; @@ -94,7 +94,7 @@ const Programming = (props) => { }; Programming.propTypes = { - question: questionShape.isRequired, + question: questionMiniShape.isRequired, readOnly: PropTypes.bool.isRequired, answerId: PropTypes.number.isRequired, saveAnswerAndUpdateClientVersion: PropTypes.func.isRequired, diff --git a/client/app/bundles/course/assessment/submission/components/answers/TextResponse/index.jsx b/client/app/bundles/course/assessment/submission/components/answers/TextResponse/index.jsx index cd133b63e2..91123ca005 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/TextResponse/index.jsx +++ b/client/app/bundles/course/assessment/submission/components/answers/TextResponse/index.jsx @@ -6,7 +6,7 @@ import FormRichTextField from 'lib/components/form/fields/RichTextField'; import { useAppSelector } from 'lib/hooks/store'; import UploadedFileView from '../../../containers/UploadedFileView'; -import { questionShape } from '../../../propTypes'; +import { questionMiniShape } from '../../../propTypes'; import { getIsSavingAnswer } from '../../../selectors/answerFlags'; import FileInputField from '../../FileInput'; import TextResponseSolutions from '../../TextResponseSolutions'; @@ -112,7 +112,7 @@ TextResponse.propTypes = { answerId: PropTypes.number.isRequired, graderView: PropTypes.bool.isRequired, handleUploadTextResponseFiles: PropTypes.func.isRequired, - question: questionShape.isRequired, + question: questionMiniShape.isRequired, readOnly: PropTypes.bool.isRequired, saveAnswerAndUpdateClientVersion: PropTypes.func.isRequired, }; diff --git a/client/app/bundles/course/assessment/submission/components/answers/index.tsx b/client/app/bundles/course/assessment/submission/components/answers/index.tsx index f472d14ca0..4a0f69cc03 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/index.tsx +++ b/client/app/bundles/course/assessment/submission/components/answers/index.tsx @@ -6,7 +6,10 @@ import equal from 'fast-deep-equal'; import { FIELD_LONG_DEBOUNCE_DELAY_MS } from 'lib/constants/sharedConstants'; import { useAppDispatch } from 'lib/hooks/store'; import { useDebounce } from 'lib/hooks/useDebounce'; -import { SubmissionQuestionData } from 'types/course/assessment/submission/question/types'; +import { + SubmissionQuestionData, + SubmissionQuestionMiniData, +} from 'types/course/assessment/submission/question/types'; import { QuestionType } from 'types/course/assessment/question'; import { saveAnswer, updateClientVersion } from '../../actions/answers'; @@ -92,7 +95,7 @@ const SubmissionAnswer = ( const answerPropsMap: AnswerPropsMap = { MultipleChoice: { answerId, - question: question as SubmissionQuestionData<'MultipleChoice'>, + question: question as SubmissionQuestionMiniData<'MultipleChoice'>, readOnly, saveAnswerAndUpdateClientVersion, graderView, @@ -100,7 +103,7 @@ const SubmissionAnswer = ( }, MultipleResponse: { answerId, - question: question as SubmissionQuestionData<'MultipleResponse'>, + question: question as SubmissionQuestionMiniData<'MultipleResponse'>, readOnly, saveAnswerAndUpdateClientVersion, graderView, @@ -108,13 +111,13 @@ const SubmissionAnswer = ( }, Programming: { answerId, - question: question as SubmissionQuestionData<'Programming'>, + question: question as SubmissionQuestionMiniData<'Programming'>, readOnly, saveAnswerAndUpdateClientVersion, }, TextResponse: { answerId, - question: question as SubmissionQuestionData<'TextResponse'>, + question: question as SubmissionQuestionMiniData<'TextResponse'>, readOnly, saveAnswerAndUpdateClientVersion, graderView, @@ -122,7 +125,7 @@ const SubmissionAnswer = ( }, FileUpload: { answerId, - question: question as SubmissionQuestionData<'FileUpload'>, + question: question as SubmissionQuestionMiniData<'FileUpload'>, readOnly, graderView, handleUploadTextResponseFiles, @@ -130,19 +133,19 @@ const SubmissionAnswer = ( Comprehension: {}, VoiceResponse: { answerId, - question: question as SubmissionQuestionData<'VoiceResponse'>, + question: question as SubmissionQuestionMiniData<'VoiceResponse'>, readOnly, saveAnswerAndUpdateClientVersion, }, ForumPostResponse: { answerId, - question: question as SubmissionQuestionData<'ForumPostResponse'>, + question: question as SubmissionQuestionMiniData<'ForumPostResponse'>, readOnly, saveAnswerAndUpdateClientVersion, }, Scribing: { answerId, - question: question as SubmissionQuestionData<'Scribing'>, + question: question as SubmissionQuestionMiniData<'Scribing'>, }, }; diff --git a/client/app/bundles/course/assessment/submission/components/answers/types.ts b/client/app/bundles/course/assessment/submission/components/answers/types.ts index 0d8301199c..57a704ef2b 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/types.ts +++ b/client/app/bundles/course/assessment/submission/components/answers/types.ts @@ -1,9 +1,9 @@ import { QuestionType } from 'types/course/assessment/question'; -import { SubmissionQuestionData } from 'types/course/assessment/submission/question/types'; +import { SubmissionQuestionMiniData } from 'types/course/assessment/submission/question/types'; interface AnswerCommonProps { answerId: number; - question: SubmissionQuestionData; + question: SubmissionQuestionMiniData; readOnly: boolean; saveAnswerAndUpdateClientVersion: (answerId: number) => void; } diff --git a/client/app/bundles/course/assessment/submission/propTypes.js b/client/app/bundles/course/assessment/submission/propTypes.js index 4315798104..14876ea374 100644 --- a/client/app/bundles/course/assessment/submission/propTypes.js +++ b/client/app/bundles/course/assessment/submission/propTypes.js @@ -39,6 +39,12 @@ export const questionShape = PropTypes.shape({ viewHistory: PropTypes.bool, }); +export const questionMiniShape = PropTypes.shape({ + id: PropTypes.number.isRequired, + viewHistory: PropTypes.bool, + type: PropTypes.string.isRequired, +}); + export const historyQuestionShape = PropTypes.shape({ loaded: PropTypes.bool, isLoading: PropTypes.bool.isRequired, diff --git a/client/app/types/course/assessment/submission/question/types.ts b/client/app/types/course/assessment/submission/question/types.ts index 5ab9157f84..a1bb8d230f 100644 --- a/client/app/types/course/assessment/submission/question/types.ts +++ b/client/app/types/course/assessment/submission/question/types.ts @@ -84,5 +84,14 @@ export interface SubmissionQuestionBaseData extends QuestionData { viewHistory?: boolean; } +interface SubmissionQuestionMiniBaseData { + id: number; + viewHistory?: boolean; + type: QuestionType; +} + export type SubmissionQuestionData = SubmissionQuestionBaseData & SpecificQuestionDataMap[T]; + +export type SubmissionQuestionMiniData = + SubmissionQuestionMiniBaseData & SpecificQuestionDataMap[T]; From a2fb2ffbef29fa994b2b5037a74e58c9c2ed2a27 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Fri, 16 Feb 2024 17:07:49 +0800 Subject: [PATCH 38/45] refactor(statistics): minor refactoring in BE and FE --- .../course/statistics/assessments_controller.rb | 9 +++------ .../assessments/main_statistics.json.jbuilder | 9 +++++++-- .../StudentMarksPerQuestionTable.tsx | 10 +++++----- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index 27c0b801af..9ce717d7f8 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -17,7 +17,7 @@ def main_statistics fetch_all_ancestor_assessments create_question_related_hash - @assessment_autograded = @question_auto_gradable_status_hash.any? { |_, value| value } + @assessment_autograded = @question_hash.any? { |_, (_, _, auto_gradable)| auto_gradable } @student_submissions_hash = fetch_hash_for_main_assessment(submissions, @all_students) end @@ -62,11 +62,8 @@ def create_question_related_hash @question_order_hash = @assessment.question_assessments.to_h do |q| [q.question_id, q.weight] end - @question_maximum_grade_hash = @assessment.questions.to_h do |q| - [q.id, q.maximum_grade] - end - @question_auto_gradable_status_hash = @assessment.questions.to_h do |q| - [q.id, q.auto_gradable?] + @question_hash = @assessment.questions.to_h do |q| + [q.id, [q.maximum_grade, q.question_type, q.auto_gradable?]] end end end diff --git a/app/views/course/statistics/assessments/main_statistics.json.jbuilder b/app/views/course/statistics/assessments/main_statistics.json.jbuilder index a6801b7d1b..ce2e7bc530 100644 --- a/app/views/course/statistics/assessments/main_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/main_statistics.json.jbuilder @@ -28,8 +28,10 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.totalGrade submission.grade json.attemptStatus answers.each do |answer| + _, _, auto_gradable = @question_hash[answer.question_id] + json.currentAnswerId answer.current_answer_id - json.isAutograded @question_auto_gradable_status_hash[answer.question_id] + json.isAutograded auto_gradable json.attemptCount answer.attempt_count json.correct answer.correct end @@ -43,9 +45,12 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an end json.answers answers.each do |answer| + maximum_grade, question_type, _ = @question_hash[answer.question_id] + json.currentAnswerId answer.current_answer_id json.grade answer.grade - json.maximumGrade @question_maximum_grade_hash[answer.question_id] + json.maximumGrade maximum_grade + json.questionType question_type end end end diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 0ce02f117c..6288c740af 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -80,7 +80,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { const statistics = useAppSelector(getAssessmentStatistics); const [openAnswer, setOpenAnswer] = useState(false); - const [answerInfo, setAnswerInfo] = useState({ + const [answerDisplayInfo, setAnswerDisplayInfo] = useState({ index: 0, answerId: 0, studentName: '', @@ -121,7 +121,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { className={`cursor-pointer ${className}`} onClick={(): void => { setOpenAnswer(true); - setAnswerInfo({ + setAnswerDisplayInfo({ index: index + 1, answerId: datum.answers![index].currentAnswerId, studentName: datum.courseUser.name, @@ -315,11 +315,11 @@ const StudentMarksPerQuestionTable: FC = (props) => { setOpenAnswer(false)} open={openAnswer} - title={answerInfo.studentName} + title={answerDisplayInfo.studentName} > From 9aa1f9ab2c702cfe45b9e9f38a5003f65cf43cf3 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Fri, 16 Feb 2024 17:42:18 +0800 Subject: [PATCH 39/45] fix(statistics): pass id of last attempt answer to FE --- .../course/statistics/submissions_concern.rb | 14 +++++++++----- .../assessments/main_statistics.json.jbuilder | 4 ++-- .../StudentAttemptCountTable.tsx | 2 +- .../StudentMarksPerQuestionTable.tsx | 2 +- .../course/statistics/assessmentStatistics.ts | 4 ++-- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 5af0963b8e..5ad071ba99 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -40,9 +40,10 @@ def answer_statistics_hash SELECT caa_ranked.question_id, caa_ranked.submission_id, - jsonb_agg(jsonb_build_array(caa_ranked.grade, caa_ranked.correct, caa_ranked.workflow_state)) AS submission_info + jsonb_agg(jsonb_build_array(caa_ranked.id, caa_ranked.grade, caa_ranked.correct, caa_ranked.workflow_state)) AS submission_info FROM ( SELECT + caa_inner.id, caa_inner.question_id, caa_inner.submission_id, caa_inner.correct, @@ -61,14 +62,17 @@ def answer_statistics_hash ) SELECT + CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>3 != 'attempting' + THEN attempt_info.submission_info->0->>0 ELSE attempt_info.submission_info->1->>0 + END AS last_attempt_answer_id, attempt_count.question_id, attempt_count.submission_id, attempt_count.attempt_count, - CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>2 != 'attempting' - THEN attempt_info.submission_info->0->>0 ELSE attempt_info.submission_info->1->>0 - END AS grade, - CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>2 != 'attempting' + CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>3 != 'attempting' THEN attempt_info.submission_info->0->>1 ELSE attempt_info.submission_info->1->>1 + END AS grade, + CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>3 != 'attempting' + THEN attempt_info.submission_info->0->>2 ELSE attempt_info.submission_info->1->>2 END AS correct FROM attempt_count JOIN attempt_info diff --git a/app/views/course/statistics/assessments/main_statistics.json.jbuilder b/app/views/course/statistics/assessments/main_statistics.json.jbuilder index ce2e7bc530..fd87433ef0 100644 --- a/app/views/course/statistics/assessments/main_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/main_statistics.json.jbuilder @@ -30,7 +30,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.attemptStatus answers.each do |answer| _, _, auto_gradable = @question_hash[answer.question_id] - json.currentAnswerId answer.current_answer_id + json.lastAttemptAnswerId answer.last_attempt_answer_id json.isAutograded auto_gradable json.attemptCount answer.attempt_count json.correct answer.correct @@ -47,7 +47,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an json.answers answers.each do |answer| maximum_grade, question_type, _ = @question_hash[answer.question_id] - json.currentAnswerId answer.current_answer_id + json.lastAttemptAnswerId answer.last_attempt_answer_id json.grade answer.grade json.maximumGrade maximum_grade json.questionType question_type diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx index 22c68fb342..5fcc562517 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -128,7 +128,7 @@ const StudentAttemptCountTable: FC = (props) => { setOpenPastAnswers(true); setAnswerInfo({ index: index + 1, - answerId: datum.answers![index].currentAnswerId, + answerId: datum.answers![index].lastAttemptAnswerId, studentName: datum.courseUser.name, }); }} diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index 6288c740af..b4723c92e8 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -123,7 +123,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { setOpenAnswer(true); setAnswerDisplayInfo({ index: index + 1, - answerId: datum.answers![index].currentAnswerId, + answerId: datum.answers![index].lastAttemptAnswerId, studentName: datum.courseUser.name, }); }} diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 222db3afa8..3a70900c00 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -28,13 +28,13 @@ export interface StudentInfo extends UserInfo { } export interface AnswerInfo { - currentAnswerId: number; + lastAttemptAnswerId: number; grade: number; maximumGrade: number; } export interface AttemptInfo { - currentAnswerId: number; + lastAttemptAnswerId: number; isAutograded: boolean; attemptCount: number; correct: boolean | null; From f7caf4847fade7c4c7d787ed2b332cfa44a8ae6d Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 20 Feb 2024 07:20:36 +0800 Subject: [PATCH 40/45] refactor(type): expose all question types for reusing - extend QuestionAnswerDetails to allow polymorphism - reuse existing answer type def for defining its mini version --- .../api/course/Statistics/AnswerStatistics.ts | 3 +- .../assessment/operations/statistics.ts | 3 +- .../submission/components/answers/Answer.tsx | 4 +- .../submission/components/answers/index.tsx | 21 ++- .../submission/components/answers/types.ts | 4 +- .../submission/answer/forumPostResponse.ts | 18 ++- .../submission/answer/multipleResponse.ts | 4 +- .../submission/answer/programming.ts | 6 +- .../assessment/submission/answer/scribing.ts | 2 +- .../submission/answer/textResponse.ts | 4 +- .../submission/answer/voiceResponse.ts | 2 +- .../assessment/submission/question/types.ts | 11 +- client/app/types/course/statistics/answer.ts | 151 ++++++++++++++++++ .../course/statistics/assessmentStatistics.ts | 18 ++- 14 files changed, 198 insertions(+), 53 deletions(-) create mode 100644 client/app/types/course/statistics/answer.ts diff --git a/client/app/api/course/Statistics/AnswerStatistics.ts b/client/app/api/course/Statistics/AnswerStatistics.ts index cee2eee923..aed45b75b0 100644 --- a/client/app/api/course/Statistics/AnswerStatistics.ts +++ b/client/app/api/course/Statistics/AnswerStatistics.ts @@ -1,3 +1,4 @@ +import { QuestionType } from 'types/course/assessment/question'; import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; import { APIResponse } from 'api/types'; @@ -11,7 +12,7 @@ export default class AnswerStatisticsAPI extends BaseCourseAPI { fetchQuestionAnswerDetails( answerId: number, - ): APIResponse { + ): APIResponse> { return this.client.get(`${this.#urlPrefix}/${answerId}`); } } diff --git a/client/app/bundles/course/assessment/operations/statistics.ts b/client/app/bundles/course/assessment/operations/statistics.ts index 7c90c922f9..33878eafba 100644 --- a/client/app/bundles/course/assessment/operations/statistics.ts +++ b/client/app/bundles/course/assessment/operations/statistics.ts @@ -1,4 +1,5 @@ import { Operation } from 'store'; +import { QuestionType } from 'types/course/assessment/question'; import { AncestorAssessmentStats, QuestionAnswerDetails, @@ -38,7 +39,7 @@ export const fetchAncestorStatistics = async ( export const fetchQuestionAnswerDetails = async ( answerId: number, -): Promise => { +): Promise> => { const response = await CourseAPI.statistics.answer.fetchQuestionAnswerDetails(answerId); diff --git a/client/app/bundles/course/assessment/submission/components/answers/Answer.tsx b/client/app/bundles/course/assessment/submission/components/answers/Answer.tsx index f94cc43285..e48aa7a803 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/Answer.tsx +++ b/client/app/bundles/course/assessment/submission/components/answers/Answer.tsx @@ -1,7 +1,7 @@ import { defineMessages } from 'react-intl'; import { Alert, Card, CardContent } from '@mui/material'; import { QuestionType } from 'types/course/assessment/question'; -import { SubmissionQuestionMiniData } from 'types/course/assessment/submission/question/types'; +import { SubmissionQuestionData } from 'types/course/assessment/submission/question/types'; import useTranslation from 'lib/hooks/useTranslation'; @@ -210,7 +210,7 @@ export const AnswerMapper = { interface AnswerComponentProps { answerId: number | null; questionType: T; - question: SubmissionQuestionMiniData; + question: SubmissionQuestionData; answerProps: AnswerPropsMap[T]; } diff --git a/client/app/bundles/course/assessment/submission/components/answers/index.tsx b/client/app/bundles/course/assessment/submission/components/answers/index.tsx index 4a0f69cc03..f472d14ca0 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/index.tsx +++ b/client/app/bundles/course/assessment/submission/components/answers/index.tsx @@ -6,10 +6,7 @@ import equal from 'fast-deep-equal'; import { FIELD_LONG_DEBOUNCE_DELAY_MS } from 'lib/constants/sharedConstants'; import { useAppDispatch } from 'lib/hooks/store'; import { useDebounce } from 'lib/hooks/useDebounce'; -import { - SubmissionQuestionData, - SubmissionQuestionMiniData, -} from 'types/course/assessment/submission/question/types'; +import { SubmissionQuestionData } from 'types/course/assessment/submission/question/types'; import { QuestionType } from 'types/course/assessment/question'; import { saveAnswer, updateClientVersion } from '../../actions/answers'; @@ -95,7 +92,7 @@ const SubmissionAnswer = ( const answerPropsMap: AnswerPropsMap = { MultipleChoice: { answerId, - question: question as SubmissionQuestionMiniData<'MultipleChoice'>, + question: question as SubmissionQuestionData<'MultipleChoice'>, readOnly, saveAnswerAndUpdateClientVersion, graderView, @@ -103,7 +100,7 @@ const SubmissionAnswer = ( }, MultipleResponse: { answerId, - question: question as SubmissionQuestionMiniData<'MultipleResponse'>, + question: question as SubmissionQuestionData<'MultipleResponse'>, readOnly, saveAnswerAndUpdateClientVersion, graderView, @@ -111,13 +108,13 @@ const SubmissionAnswer = ( }, Programming: { answerId, - question: question as SubmissionQuestionMiniData<'Programming'>, + question: question as SubmissionQuestionData<'Programming'>, readOnly, saveAnswerAndUpdateClientVersion, }, TextResponse: { answerId, - question: question as SubmissionQuestionMiniData<'TextResponse'>, + question: question as SubmissionQuestionData<'TextResponse'>, readOnly, saveAnswerAndUpdateClientVersion, graderView, @@ -125,7 +122,7 @@ const SubmissionAnswer = ( }, FileUpload: { answerId, - question: question as SubmissionQuestionMiniData<'FileUpload'>, + question: question as SubmissionQuestionData<'FileUpload'>, readOnly, graderView, handleUploadTextResponseFiles, @@ -133,19 +130,19 @@ const SubmissionAnswer = ( Comprehension: {}, VoiceResponse: { answerId, - question: question as SubmissionQuestionMiniData<'VoiceResponse'>, + question: question as SubmissionQuestionData<'VoiceResponse'>, readOnly, saveAnswerAndUpdateClientVersion, }, ForumPostResponse: { answerId, - question: question as SubmissionQuestionMiniData<'ForumPostResponse'>, + question: question as SubmissionQuestionData<'ForumPostResponse'>, readOnly, saveAnswerAndUpdateClientVersion, }, Scribing: { answerId, - question: question as SubmissionQuestionMiniData<'Scribing'>, + question: question as SubmissionQuestionData<'Scribing'>, }, }; diff --git a/client/app/bundles/course/assessment/submission/components/answers/types.ts b/client/app/bundles/course/assessment/submission/components/answers/types.ts index 57a704ef2b..0d8301199c 100644 --- a/client/app/bundles/course/assessment/submission/components/answers/types.ts +++ b/client/app/bundles/course/assessment/submission/components/answers/types.ts @@ -1,9 +1,9 @@ import { QuestionType } from 'types/course/assessment/question'; -import { SubmissionQuestionMiniData } from 'types/course/assessment/submission/question/types'; +import { SubmissionQuestionData } from 'types/course/assessment/submission/question/types'; interface AnswerCommonProps { answerId: number; - question: SubmissionQuestionMiniData; + question: SubmissionQuestionData; readOnly: boolean; saveAnswerAndUpdateClientVersion: (answerId: number) => void; } diff --git a/client/app/types/course/assessment/submission/answer/forumPostResponse.ts b/client/app/types/course/assessment/submission/answer/forumPostResponse.ts index 06fe478894..e0317b1261 100644 --- a/client/app/types/course/assessment/submission/answer/forumPostResponse.ts +++ b/client/app/types/course/assessment/submission/answer/forumPostResponse.ts @@ -6,7 +6,7 @@ import { AnswerFieldBaseEntity, } from './answer'; -interface PostPack { +export interface PostPack { id: number; text: string; creatorId: number; @@ -19,19 +19,21 @@ interface PostPack { // BE Data Type -interface ForumPostResponseFieldData extends AnswerFieldBaseData { +export interface SelectedPostPack { + forum: { id: string; name: string }; + topic: { id: number; title: string; isDeleted: boolean }; + corePost: PostPack; + parentPost?: PostPack; +} + +export interface ForumPostResponseFieldData extends AnswerFieldBaseData { answer_text: string; + selected_post_packs: SelectedPostPack[]; } export interface ForumPostResponseAnswerData extends AnswerBaseData { questionType: QuestionType.ForumPostResponse; fields: ForumPostResponseFieldData; - selected_post_packs: { - forum: { id: string; name: string }; - topic: { id: number; title: string; isDeleted: boolean }; - corePost: PostPack; - parentPost?: PostPack; - }; explanation: { correct: boolean | null; explanations: string[]; diff --git a/client/app/types/course/assessment/submission/answer/multipleResponse.ts b/client/app/types/course/assessment/submission/answer/multipleResponse.ts index 36b8ebe76e..a8ab077df2 100644 --- a/client/app/types/course/assessment/submission/answer/multipleResponse.ts +++ b/client/app/types/course/assessment/submission/answer/multipleResponse.ts @@ -8,7 +8,7 @@ import { // BE Data Type -interface MultipleResponseFieldData extends AnswerFieldBaseData { +export interface MultipleResponseFieldData extends AnswerFieldBaseData { option_ids: number[]; } @@ -22,7 +22,7 @@ export interface MultipleResponseAnswerData extends AnswerBaseData { latestAnswer?: MultipleResponseAnswerData; } -interface MultipleChoiceFieldData extends AnswerFieldBaseData { +export interface MultipleChoiceFieldData extends AnswerFieldBaseData { option_ids: number[]; } diff --git a/client/app/types/course/assessment/submission/answer/programming.ts b/client/app/types/course/assessment/submission/answer/programming.ts index 589c29c07c..0f48f8878d 100644 --- a/client/app/types/course/assessment/submission/answer/programming.ts +++ b/client/app/types/course/assessment/submission/answer/programming.ts @@ -15,9 +15,9 @@ export interface ProgrammingContent { highlightedContent: string | null; } -type TestCaseType = 'public_test' | 'private_test' | 'evaluation_test'; +export type TestCaseType = 'public_test' | 'private_test' | 'evaluation_test'; -interface TestCaseResult { +export interface TestCaseResult { identifier?: string; expression: string; expected: string; @@ -27,7 +27,7 @@ interface TestCaseResult { // BE Data Type -interface ProgrammingFieldData extends AnswerFieldBaseData { +export interface ProgrammingFieldData extends AnswerFieldBaseData { files_attributes: ProgrammingContent[]; } diff --git a/client/app/types/course/assessment/submission/answer/scribing.ts b/client/app/types/course/assessment/submission/answer/scribing.ts index 000df7ee53..5932739ded 100644 --- a/client/app/types/course/assessment/submission/answer/scribing.ts +++ b/client/app/types/course/assessment/submission/answer/scribing.ts @@ -8,7 +8,7 @@ import { // BE Data Type -interface ScribingFieldData extends AnswerFieldBaseData {} +export interface ScribingFieldData extends AnswerFieldBaseData {} export interface ScribingAnswerData extends AnswerBaseData { questionType: QuestionType.Scribing; diff --git a/client/app/types/course/assessment/submission/answer/textResponse.ts b/client/app/types/course/assessment/submission/answer/textResponse.ts index 7750da2e46..9e021b0e96 100644 --- a/client/app/types/course/assessment/submission/answer/textResponse.ts +++ b/client/app/types/course/assessment/submission/answer/textResponse.ts @@ -8,7 +8,7 @@ import { // BE Data Type -interface TextResponseFieldData extends AnswerFieldBaseData { +export interface TextResponseFieldData extends AnswerFieldBaseData { answer_text: string; } @@ -23,7 +23,7 @@ export interface TextResponseAnswerData extends AnswerBaseData { latestAnswer?: TextResponseAnswerData; } -interface FileUploadFieldData extends AnswerFieldBaseData {} +export interface FileUploadFieldData extends AnswerFieldBaseData {} export interface FileUploadAnswerData extends AnswerBaseData { questionType: QuestionType.FileUpload; diff --git a/client/app/types/course/assessment/submission/answer/voiceResponse.ts b/client/app/types/course/assessment/submission/answer/voiceResponse.ts index 7c3d29bfb1..c5310e6221 100644 --- a/client/app/types/course/assessment/submission/answer/voiceResponse.ts +++ b/client/app/types/course/assessment/submission/answer/voiceResponse.ts @@ -8,7 +8,7 @@ import { // BE Data Type -interface VoiceResponseFieldData extends AnswerFieldBaseData { +export interface VoiceResponseFieldData extends AnswerFieldBaseData { file: { url: string | null; name: string }; } diff --git a/client/app/types/course/assessment/submission/question/types.ts b/client/app/types/course/assessment/submission/question/types.ts index a1bb8d230f..63e6399f8b 100644 --- a/client/app/types/course/assessment/submission/question/types.ts +++ b/client/app/types/course/assessment/submission/question/types.ts @@ -61,7 +61,7 @@ interface ForumPostResponseQuestionData { maxPosts: boolean; } -interface SpecificQuestionDataMap { +export interface SpecificQuestionDataMap { MultipleChoice: MultipleResponseQuestionData; MultipleResponse: MultipleResponseQuestionData; Programming: ProgrammingQuestionData; @@ -84,14 +84,5 @@ export interface SubmissionQuestionBaseData extends QuestionData { viewHistory?: boolean; } -interface SubmissionQuestionMiniBaseData { - id: number; - viewHistory?: boolean; - type: QuestionType; -} - export type SubmissionQuestionData = SubmissionQuestionBaseData & SpecificQuestionDataMap[T]; - -export type SubmissionQuestionMiniData = - SubmissionQuestionMiniBaseData & SpecificQuestionDataMap[T]; diff --git a/client/app/types/course/statistics/answer.ts b/client/app/types/course/statistics/answer.ts new file mode 100644 index 0000000000..54221023cc --- /dev/null +++ b/client/app/types/course/statistics/answer.ts @@ -0,0 +1,151 @@ +import { JobStatus, JobStatusResponse } from 'types/jobs'; + +import { QuestionType } from '../assessment/question'; +import { ForumPostResponseFieldData } from '../assessment/submission/answer/forumPostResponse'; +import { + MultipleChoiceFieldData, + MultipleResponseFieldData, +} from '../assessment/submission/answer/multipleResponse'; +import { + ProgrammingFieldData, + TestCaseResult, + TestCaseType, +} from '../assessment/submission/answer/programming'; +import { ScribingFieldData } from '../assessment/submission/answer/scribing'; +import { + FileUploadFieldData, + TextResponseFieldData, +} from '../assessment/submission/answer/textResponse'; +import { VoiceResponseFieldData } from '../assessment/submission/answer/voiceResponse'; + +interface AnswerCommonDetails { + grade: number; + questionType: T; +} + +export interface McqAnswerDetails + extends AnswerCommonDetails<'MultipleChoice'> { + fields: MultipleChoiceFieldData; + explanation: { + correct?: boolean | null; + explanations?: string[]; + }; + latestAnswer?: McqAnswerDetails; +} + +export interface MrqAnswerDetails + extends AnswerCommonDetails<'MultipleResponse'> { + fields: MultipleResponseFieldData; + explanation: { + correct?: boolean | null; + explanations?: string[]; + }; + latestAnswer?: MrqAnswerDetails; +} + +export interface ProgrammingAnswerDetails + extends AnswerCommonDetails<'Programming'> { + fields: ProgrammingFieldData; + explanation: { + correct?: boolean; + explanation: string[]; + failureType: TestCaseType; + }; + testCases: { + canReadTests: boolean; + public_test?: TestCaseResult[]; + private_test?: TestCaseResult[]; + evaluation_test?: TestCaseResult[]; + stdout?: string; + stderr?: string; + }; + attemptsLeft?: number; + autograding?: JobStatusResponse & { + path?: string; + }; + codaveriFeedback?: { + jobId: string; + jobStatus: keyof typeof JobStatus; + jobUrl?: string; + errorMessage?: string; + }; + latestAnswer?: ProgrammingAnswerDetails & { + annotations: { + fileId: number; + topics: { + id: number; + postIds: number[]; + line: string; + }[]; + }; + }; +} + +export interface TextResponseAnswerDetails + extends AnswerCommonDetails<'TextResponse'> { + fields: TextResponseFieldData; + attachments: { id: string; name: string }[]; + explanation: { + correct: boolean | null; + explanations: string[]; + }; + latestAnswer?: TextResponseAnswerDetails; +} + +export interface FileUploadAnswerDetails + extends AnswerCommonDetails<'FileUpload'> { + fields: FileUploadFieldData; + attachments: { id: string; name: string }[]; + explanation: { + correct: boolean | null; + explanations: string[]; + }; + latestAnswer?: FileUploadAnswerDetails; +} + +export interface ComprehensionAnswerDetails + extends AnswerCommonDetails<'Comprehension'> {} + +export interface ScribingAnswerDetails extends AnswerCommonDetails<'Scribing'> { + fields: ScribingFieldData; + explanation: { + correct: boolean | null; + explanations: string[]; + }; + scribing_answer: { + image_url: string; + user_id: number; + answer_id: number; + scribbles: { content: string; creator_name: string; creator_id: number }[]; + }; +} + +export interface VoiceResponseAnswerDetails + extends AnswerCommonDetails<'VoiceResponse'> { + fields: VoiceResponseFieldData; + explanation: { + correct: boolean | null; + explanations: string[]; + }; +} + +export interface ForumPostResponseAnswerDetails + extends AnswerCommonDetails<'ForumPostResponse'> { + fields: ForumPostResponseFieldData; + explanation: { + correct: boolean | null; + explanations: string[]; + }; +} + +export interface AnswerDetailsMap { + MultipleChoice: McqAnswerDetails; + MultipleResponse: MrqAnswerDetails; + Programming: ProgrammingAnswerDetails; + TextResponse: TextResponseAnswerDetails; + FileUpload: FileUploadAnswerDetails; + Comprehension: ComprehensionAnswerDetails; + Scribing: ScribingAnswerDetails; + VoiceResponse: VoiceResponseAnswerDetails; + ForumPostResponse: ForumPostResponseAnswerDetails; +} diff --git a/client/app/types/course/statistics/assessmentStatistics.ts b/client/app/types/course/statistics/assessmentStatistics.ts index 3a70900c00..692fc991bc 100644 --- a/client/app/types/course/statistics/assessmentStatistics.ts +++ b/client/app/types/course/statistics/assessmentStatistics.ts @@ -1,6 +1,9 @@ import { QuestionType } from '../assessment/question'; +import { SpecificQuestionDataMap } from '../assessment/submission/question/types'; import { WorkflowState } from '../assessment/submission/submission'; +import { AnswerDetailsMap } from './answer'; + interface AssessmentInfo { id: number; title: string; @@ -86,19 +89,18 @@ export interface AssessmentStatisticsStore extends MainAssessmentStats { isLoading: boolean; } -interface QuestionDetails { +interface QuestionBasicDetails { id: number; title: string; description: string; - type: QuestionType; + type: T; maximumGrade: number; } -interface AnswerDetails { - grade: number; -} +export type QuestionDetails = + QuestionBasicDetails & SpecificQuestionDataMap[T]; -export interface QuestionAnswerDetails { - question: QuestionDetails; - answer: AnswerDetails; +export interface QuestionAnswerDetails { + question: QuestionDetails; + answer: AnswerDetailsMap[T]; } From 755eb9bb2effad7628be6b24fe5f9b9d71188977 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 20 Feb 2024 07:32:48 +0800 Subject: [PATCH 41/45] feat(statistics): display answer for some question types - Multiple Choice - Multiple Response - Text Response (with/without attachments) - File Upload --- .../question_answer_details.json.jbuilder | 1 + .../AnswerDetails/AnswerDetails.tsx | 81 +++++++++++++++++++ .../AnswerDetails/AttachmentDetails.tsx | 60 ++++++++++++++ .../AnswerDetails/FileUploadDetails.tsx | 17 ++++ .../AnswerDetails/MultipleChoiceDetails.tsx | 43 ++++++++++ .../AnswerDetails/MultipleResponseDetails.tsx | 45 +++++++++++ .../AnswerDetails/TextResponseDetails.tsx | 26 ++++++ .../AssessmentStatistics/AnswerDisplay.tsx | 12 ++- 8 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AttachmentDetails.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/FileUploadDetails.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleChoiceDetails.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleResponseDetails.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/TextResponseDetails.tsx diff --git a/app/views/course/statistics/answers/question_answer_details.json.jbuilder b/app/views/course/statistics/answers/question_answer_details.json.jbuilder index 8acd9f0083..9b07b44536 100644 --- a/app/views/course/statistics/answers/question_answer_details.json.jbuilder +++ b/app/views/course/statistics/answers/question_answer_details.json.jbuilder @@ -14,5 +14,6 @@ end specific_answer = @answer.specific json.answer do json.grade @answer.grade + json.questionType question.question_type json.partial! specific_answer, answer: specific_answer, can_grade: false end diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx new file mode 100644 index 0000000000..6be88de8ff --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx @@ -0,0 +1,81 @@ +import { defineMessages } from 'react-intl'; +import { Card, CardContent } from '@mui/material'; +import { QuestionType } from 'types/course/assessment/question'; +import { AnswerDetailsMap } from 'types/course/statistics/answer'; +import { QuestionDetails } from 'types/course/statistics/assessmentStatistics'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import FileUploadDetails from './FileUploadDetails'; +import MultipleChoiceDetails from './MultipleChoiceDetails'; +import MultipleResponseDetails from './MultipleResponseDetails'; +import TextResponseDetails from './TextResponseDetails'; + +const translations = defineMessages({ + rendererNotImplemented: { + id: 'course.assessment.submission.Answer.rendererNotImplemented', + defaultMessage: + 'The display for this question type has not been implemented yet.', + }, +}); + +interface AnswerDetailsProps { + question: QuestionDetails; + answer: AnswerDetailsMap[T]; +} + +const AnswerNotImplemented = (): JSX.Element => { + const { t } = useTranslation(); + + return ( + + {t(translations.rendererNotImplemented)} + + ); +}; + +export const AnswerDetailsMapper = { + MultipleChoice: ( + props: AnswerDetailsProps<'MultipleChoice'>, + ): JSX.Element => , + MultipleResponse: ( + props: AnswerDetailsProps<'MultipleResponse'>, + ): JSX.Element => , + TextResponse: (props: AnswerDetailsProps<'TextResponse'>): JSX.Element => ( + + ), + FileUpload: (props: AnswerDetailsProps<'FileUpload'>): JSX.Element => ( + + ), + // TODO: define component for Forum Post, Programming, Voice Response, Scribing + ForumPostResponse: ( + _props: AnswerDetailsProps<'ForumPostResponse'>, + ): JSX.Element => , + Programming: (_props: AnswerDetailsProps<'Programming'>): JSX.Element => ( + + ), + VoiceResponse: (_props: AnswerDetailsProps<'VoiceResponse'>): JSX.Element => ( + + ), + Scribing: (_props: AnswerDetailsProps<'Scribing'>): JSX.Element => ( + + ), + Comprehension: (_props: AnswerDetailsProps<'Comprehension'>): JSX.Element => ( + + ), +}; + +const AnswerDetails = ( + props: AnswerDetailsProps, +): JSX.Element => { + const Component = AnswerDetailsMapper[props.answer.questionType]; + + // "Any" type is used here as the props are dynamically generated + // depending on the different answer type and typescript + // does not support union typing for the elements. + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Component({ ...props } as any); +}; + +export default AnswerDetails; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AttachmentDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AttachmentDetails.tsx new file mode 100644 index 0000000000..db12168444 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AttachmentDetails.tsx @@ -0,0 +1,60 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Chip, Typography } from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + attachments: { + id: string; + name: string; + }[]; +} + +const translations = defineMessages({ + uploadedFiles: { + id: 'course.assessment.submission.UploadedFileView.uploadedFiles', + defaultMessage: 'Uploaded Files', + }, + noFiles: { + id: 'course.assessment.submission.UploadedFileView.noFiles', + defaultMessage: 'No files uploaded.', + }, +}); + +const AttachmentDetails: FC = (props) => { + const { attachments } = props; + const { t } = useTranslation(); + + const AttachmentComponent = (): JSX.Element => ( +
+ {attachments.map((attachment) => ( + + {attachment.name} + + } + /> + ))} +
+ ); + + return ( +
+ {t(translations.uploadedFiles)} + {attachments.length > 0 ? ( + + ) : ( + + {t(translations.noFiles)} + + )} +
+ ); +}; + +export default AttachmentDetails; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/FileUploadDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/FileUploadDetails.tsx new file mode 100644 index 0000000000..d2efcc18c8 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/FileUploadDetails.tsx @@ -0,0 +1,17 @@ +import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; + +import AttachmentDetails from './AttachmentDetails'; + +const FileUploadDetails = ( + props: QuestionAnswerDetails<'FileUpload'>, +): JSX.Element => { + const { answer } = props; + + return ( +
+ +
+ ); +}; + +export default FileUploadDetails; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleChoiceDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleChoiceDetails.tsx new file mode 100644 index 0000000000..0758d2caa3 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleChoiceDetails.tsx @@ -0,0 +1,43 @@ +import { FormControlLabel, Radio, Typography } from '@mui/material'; +import { green } from '@mui/material/colors'; +import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; + +const MultipleChoiceDetails = ( + props: QuestionAnswerDetails<'MultipleChoice'>, +): JSX.Element => { + const { question, answer } = props; + return ( + <> + {question.options.map((option) => ( + 0 && + answer.fields.option_ids.includes(option.id) + } + className="w-full" + control={} + disabled + label={ + + + + } + /> + ))} + + ); +}; + +export default MultipleChoiceDetails; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleResponseDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleResponseDetails.tsx new file mode 100644 index 0000000000..3147eb10d4 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/MultipleResponseDetails.tsx @@ -0,0 +1,45 @@ +import { Checkbox, FormControlLabel, Typography } from '@mui/material'; +import { green } from '@mui/material/colors'; +import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; + +const MultipleResponseDetails = ( + props: QuestionAnswerDetails<'MultipleResponse'>, +): JSX.Element => { + const { question, answer } = props; + return ( + <> + {question.options.map((option) => { + return ( + 0 && + answer.fields.option_ids.indexOf(option.id) !== -1 + } + className="w-full" + control={} + disabled + label={ + + + + } + /> + ); + })} + + ); +}; + +export default MultipleResponseDetails; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/TextResponseDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/TextResponseDetails.tsx new file mode 100644 index 0000000000..8f24598022 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/TextResponseDetails.tsx @@ -0,0 +1,26 @@ +import { Typography } from '@mui/material'; +import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; + +import AttachmentDetails from './AttachmentDetails'; + +const TextResponseDetails = ( + props: QuestionAnswerDetails<'TextResponse'>, +): JSX.Element => { + const { question, answer } = props; + + return ( + <> + + {question.allowAttachment && ( +
+ +
+ )} + + ); +}; + +export default TextResponseDetails; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx index 1be8e26b37..9dc0b3ce69 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDisplay.tsx @@ -1,6 +1,7 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { Chip, Typography } from '@mui/material'; +import { QuestionType } from 'types/course/assessment/question'; import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; import { fetchQuestionAnswerDetails } from 'course/assessment/operations/statistics'; @@ -9,6 +10,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; import useTranslation from 'lib/hooks/useTranslation'; +import AnswerDetails from './AnswerDetails/AnswerDetails'; import { getClassNameForMarkCell } from './classNameUtils'; const translations = defineMessages({ @@ -31,10 +33,11 @@ const AnswerDisplay: FC = (props) => { const { curAnswerId, index } = props; const { t } = useTranslation(); - const fetchQuestionAndCurrentAnswerDetails = - (): Promise => { - return fetchQuestionAnswerDetails(curAnswerId); - }; + const fetchQuestionAndCurrentAnswerDetails = (): Promise< + QuestionAnswerDetails + > => { + return fetchQuestionAnswerDetails(curAnswerId); + }; return ( = (props) => { /> + Date: Tue, 20 Feb 2024 07:42:40 +0800 Subject: [PATCH 42/45] refactor(statistics page): cancel -> close in answer details box --- .../statistics/assessments/main_statistics.json.jbuilder | 2 +- .../pages/AssessmentStatistics/StudentAttemptCountTable.tsx | 5 +++++ .../AssessmentStatistics/StudentMarksPerQuestionTable.tsx | 5 +++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/views/course/statistics/assessments/main_statistics.json.jbuilder b/app/views/course/statistics/assessments/main_statistics.json.jbuilder index fd87433ef0..1b01a0d714 100644 --- a/app/views/course/statistics/assessments/main_statistics.json.jbuilder +++ b/app/views/course/statistics/assessments/main_statistics.json.jbuilder @@ -45,7 +45,7 @@ json.submissions @student_submissions_hash.each do |course_user, (submission, an end json.answers answers.each do |answer| - maximum_grade, question_type, _ = @question_hash[answer.question_id] + maximum_grade, question_type, = @question_hash[answer.question_id] json.lastAttemptAnswerId answer.last_attempt_answer_id json.grade answer.grade diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx index 5fcc562517..3360eb5c5a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentAttemptCountTable.tsx @@ -60,6 +60,10 @@ const translations = defineMessages({ id: 'course.assessment.statistics.filename', defaultMessage: 'Question-level Attempt Statistics for {assessment}', }, + close: { + id: 'course.assessment.statistics.close', + defaultMessage: 'Close', + }, }); interface Props { @@ -272,6 +276,7 @@ const StudentAttemptCountTable: FC = (props) => { /> setOpenPastAnswers(false)} open={openPastAnswers} title={answerInfo.studentName} diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx index b4723c92e8..30a140bf3a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/StudentMarksPerQuestionTable.tsx @@ -59,6 +59,10 @@ const translations = defineMessages({ id: 'course.assessment.statistics.filename', defaultMessage: 'Question-level Marks Statistics for {assessment}', }, + close: { + id: 'course.assessment.statistics.close', + defaultMessage: 'Close', + }, }); interface Props { @@ -313,6 +317,7 @@ const StudentMarksPerQuestionTable: FC = (props) => { /> setOpenAnswer(false)} open={openAnswer} title={answerDisplayInfo.studentName} From 8419bdf6e6ad28a0b2360911d9d0f7d30d007e8d Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 20 Feb 2024 10:15:46 +0800 Subject: [PATCH 43/45] feat(answer_stats): implement forum answer view --- .../AnswerDetails/AnswerDetails.tsx | 7 +- .../ParentPostPack.tsx | 35 +++++ .../PostContent.tsx | 83 ++++++++++++ .../ForumPostResponseComponent/PostPack.tsx | 128 ++++++++++++++++++ .../ForumPostResponseDetails.tsx | 40 ++++++ 5 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/ParentPostPack.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostContent.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostPack.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseDetails.tsx diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx index 6be88de8ff..6f55dcf844 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx @@ -7,6 +7,7 @@ import { QuestionDetails } from 'types/course/statistics/assessmentStatistics'; import useTranslation from 'lib/hooks/useTranslation'; import FileUploadDetails from './FileUploadDetails'; +import ForumPostResponseDetails from './ForumPostResponseDetails'; import MultipleChoiceDetails from './MultipleChoiceDetails'; import MultipleResponseDetails from './MultipleResponseDetails'; import TextResponseDetails from './TextResponseDetails'; @@ -47,10 +48,10 @@ export const AnswerDetailsMapper = { FileUpload: (props: AnswerDetailsProps<'FileUpload'>): JSX.Element => ( ), - // TODO: define component for Forum Post, Programming, Voice Response, Scribing ForumPostResponse: ( - _props: AnswerDetailsProps<'ForumPostResponse'>, - ): JSX.Element => , + props: AnswerDetailsProps<'ForumPostResponse'>, + ): JSX.Element => , + // TODO: define component for Programming, Voice Response, Scribing Programming: (_props: AnswerDetailsProps<'Programming'>): JSX.Element => ( ), diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/ParentPostPack.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/ParentPostPack.tsx new file mode 100644 index 0000000000..50f90098fd --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/ParentPostPack.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; +import { defineMessages, FormattedMessage } from 'react-intl'; +import { Typography } from '@mui/material'; +import { PostPack } from 'types/course/assessment/submission/answer/forumPostResponse'; + +import Labels from 'course/assessment/submission/components/answers/ForumPostResponse/Labels'; + +import PostContent from './PostContent'; + +const translations = defineMessages({ + postMadeInResponseTo: { + id: 'course.assessment.submission.answers.ForumPostResponse.ParentPost.postMadeInResponseTo', + defaultMessage: 'Post made in response to:', + }, +}); + +interface Props { + post: PostPack; +} + +const ParentPostPack: FC = (props) => { + const { post } = props; + + return ( +
+ + + + + +
+ ); +}; + +export default ParentPostPack; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostContent.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostContent.tsx new file mode 100644 index 0000000000..a9c4998686 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostContent.tsx @@ -0,0 +1,83 @@ +import { FC, useLayoutEffect, useRef, useState } from 'react'; +import { defineMessages, FormattedMessage } from 'react-intl'; +import { + Avatar, + Button, + Card, + CardContent, + CardHeader, + Divider, + Typography, +} from '@mui/material'; +import { PostPack } from 'types/course/assessment/submission/answer/forumPostResponse'; + +import { formatLongDateTime } from 'lib/moment'; + +interface Props { + post: PostPack; + isExpandable?: boolean; +} + +const MAX_POST_HEIGHT = 60; + +export const translations = defineMessages({ + showMore: { + id: 'course.assessment.submission.answers.ForumPostResponse.ForumPost.showMore', + defaultMessage: 'SHOW MORE', + }, + showLess: { + id: 'course.assessment.submission.answers.ForumPostResponse.ForumPost.showLess', + defaultMessage: 'SHOW LESS', + }, +}); + +const PostContent: FC = (props) => { + const { post, isExpandable } = props; + const [renderedHeight, setRenderedHeight] = useState(0); + + const contentIsExpandable = isExpandable && renderedHeight > MAX_POST_HEIGHT; + const [isExpanded, setIsExpanded] = useState(!isExpandable); + + const postRef = useRef(null); + + useLayoutEffect(() => { + if (postRef.current) { + setRenderedHeight(postRef.current.clientHeight); + } + }, [post]); + + return ( +
+ + } + subheader={formatLongDateTime(post.updatedAt)} + title={post.userName} + /> + + + + {contentIsExpandable && ( + + )} + + +
+ ); +}; + +export default PostContent; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostPack.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostPack.tsx new file mode 100644 index 0000000000..49c6761de1 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostPack.tsx @@ -0,0 +1,128 @@ +import { FC, useState } from 'react'; +import { defineMessages, FormattedMessage } from 'react-intl'; +import { ChevronRight, ExpandMore } from '@mui/icons-material'; +import { Typography } from '@mui/material'; +import { SelectedPostPack } from 'types/course/assessment/submission/answer/forumPostResponse'; + +import Labels from 'course/assessment/submission/components/answers/ForumPostResponse/Labels'; +import Link from 'lib/components/core/Link'; +import { getForumTopicURL, getForumURL } from 'lib/helpers/url-builders'; +import { getCourseId } from 'lib/helpers/url-helpers'; + +import ParentPostPack from './ParentPostPack'; +import PostContent from './PostContent'; + +const translations = defineMessages({ + cannotRetrieveForumPosts: { + id: 'course.assessment.submission.answers.ForumPostResponse.ForumPostSelect.cannotRetrieveForumPosts', + defaultMessage: + 'Oops! Unable to retrieve your forum posts. Please try refreshing this page.', + }, + cannotRetrieveSelectedPostPacks: { + id: 'course.assessment.submission.answers.ForumPostResponse.ForumPostSelect.cannotRetrieveSelectedPostPacks', + defaultMessage: + 'Oops! Unable to retrieve your selected posts. Please try refreshing this page.', + }, + submittedInstructions: { + id: 'course.assessment.submission.answers.ForumPostResponse.ForumPostSelect.submittedInstructions', + defaultMessage: + '{numPosts, plural, =0 {No posts were} one {# post was} other {# posts were}} submitted.', + }, + selectInstructions: { + id: 'course.assessment.submission.answers.ForumPostResponse.ForumPostSelect.selectInstructions', + defaultMessage: + 'Select {maxPosts} forum {maxPosts, plural, one {post} other {posts}}. ' + + 'You have selected {numPosts} {numPosts, plural, one {post} other {posts}}.', + }, + selectPostsButton: { + id: 'course.assessment.submission.answers.ForumPostResponse.ForumPostSelect.selectPostsButton', + defaultMessage: 'Select Forum {maxPosts, plural, one {Post} other {Posts}}', + }, + topicDeleted: { + id: 'course.assessment.submission.answers.ForumPostResponse.SelectedPostCard.topicDeleted', + defaultMessage: 'Post made under a topic that was subsequently deleted.', + }, + postMadeUnder: { + id: 'course.assessment.submission.answers.ForumPostResponse.SelectedPostCard.postMadeUnder', + defaultMessage: 'Post made under {topicUrl} in {forumUrl}', + }, +}); + +interface Props { + postPack: SelectedPostPack; +} + +const MAX_NAME_LENGTH = 30; + +const generateLink = (url: string, name: string): JSX.Element => { + const renderedName = + name.length > MAX_NAME_LENGTH + ? `${name.slice(0, MAX_NAME_LENGTH)}...` + : name; + return ( + + {renderedName} + + ); +}; + +const PostPack: FC = (props) => { + const { postPack } = props; + const courseId = getCourseId(); + const [isExpanded, setIsExpanded] = useState(false); + + const ForumPostLabel = (): JSX.Element => { + const { forum, topic } = postPack; + return ( +
+ {isExpanded ? ( + + ) : ( + + )} + + {topic.isDeleted ? ( + + ) : ( + + )} + +
+ ); + }; + + return ( +
+
setIsExpanded(!isExpanded)} + > + +
+ {isExpanded && ( + <> + + + {postPack.parentPost && } + + )} +
+ ); +}; + +export default PostPack; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseDetails.tsx new file mode 100644 index 0000000000..9c24f63068 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseDetails.tsx @@ -0,0 +1,40 @@ +import { defineMessages, FormattedMessage } from 'react-intl'; +import { Typography } from '@mui/material'; +import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; + +import PostPack from './ForumPostResponseComponent/PostPack'; + +const translations = defineMessages({ + submittedInstructions: { + id: 'course.assessment.submission.answers.ForumPostResponse.ForumPostSelect.submittedInstructions', + defaultMessage: + '{numPosts, plural, =0 {No posts were} one {# post was} other {# posts were}} submitted.', + }, +}); + +const ForumPostResponseDetails = ( + props: QuestionAnswerDetails<'ForumPostResponse'>, +): JSX.Element => { + const { answer } = props; + const postPacks = answer.fields.selected_post_packs; + + return ( + <> + + + + {postPacks.length > 0 && + postPacks.map((postPack) => ( + + ))} + + ); +}; + +export default ForumPostResponseDetails; From 90a3d483dc76cbec1810ea9b30f239e213a9d9ed Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 20 Feb 2024 12:23:26 +0800 Subject: [PATCH 44/45] feat(answer_stats): implement programming answer view --- .../question_answer_details.json.jbuilder | 1 + .../AnswerDetails/AnswerDetails.tsx | 7 +- .../ProgrammingAnswerDetails.tsx | 30 +++ .../CodaveriFeedbackStatus.tsx | 72 +++++++ .../ProgrammingComponent/FileContent.tsx | 47 +++++ .../ProgrammingComponent/TestCaseRow.tsx | 69 +++++++ .../ProgrammingComponent/TestCases.tsx | 190 ++++++++++++++++++ client/app/types/course/statistics/answer.ts | 53 +++-- 8 files changed, 444 insertions(+), 25 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingAnswerDetails.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/CodaveriFeedbackStatus.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/FileContent.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCaseRow.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx diff --git a/app/views/course/statistics/answers/question_answer_details.json.jbuilder b/app/views/course/statistics/answers/question_answer_details.json.jbuilder index 9b07b44536..935573e71c 100644 --- a/app/views/course/statistics/answers/question_answer_details.json.jbuilder +++ b/app/views/course/statistics/answers/question_answer_details.json.jbuilder @@ -13,6 +13,7 @@ end specific_answer = @answer.specific json.answer do + json.id @answer.id json.grade @answer.grade json.questionType question.question_type json.partial! specific_answer, answer: specific_answer, can_grade: false diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx index 6f55dcf844..5a8dd5cc15 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/AnswerDetails.tsx @@ -10,6 +10,7 @@ import FileUploadDetails from './FileUploadDetails'; import ForumPostResponseDetails from './ForumPostResponseDetails'; import MultipleChoiceDetails from './MultipleChoiceDetails'; import MultipleResponseDetails from './MultipleResponseDetails'; +import ProgrammingAnswerDetails from './ProgrammingAnswerDetails'; import TextResponseDetails from './TextResponseDetails'; const translations = defineMessages({ @@ -51,10 +52,10 @@ export const AnswerDetailsMapper = { ForumPostResponse: ( props: AnswerDetailsProps<'ForumPostResponse'>, ): JSX.Element => , - // TODO: define component for Programming, Voice Response, Scribing - Programming: (_props: AnswerDetailsProps<'Programming'>): JSX.Element => ( - + Programming: (props: AnswerDetailsProps<'Programming'>): JSX.Element => ( + ), + // TODO: define component for Voice Response, Scribing VoiceResponse: (_props: AnswerDetailsProps<'VoiceResponse'>): JSX.Element => ( ), diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingAnswerDetails.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingAnswerDetails.tsx new file mode 100644 index 0000000000..451dffc8ad --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingAnswerDetails.tsx @@ -0,0 +1,30 @@ +import { Annotation } from 'types/course/statistics/answer'; +import { QuestionAnswerDetails } from 'types/course/statistics/assessmentStatistics'; + +import CodaveriFeedbackStatus from './ProgrammingComponent/CodaveriFeedbackStatus'; +import FileContent from './ProgrammingComponent/FileContent'; +import TestCases from './ProgrammingComponent/TestCases'; + +const ProgrammingAnswerDetails = ( + props: QuestionAnswerDetails<'Programming'>, +): JSX.Element => { + const { answer } = props; + const annotations = answer.latestAnswer?.annotations ?? ([] as Annotation[]); + + return ( + <> + {answer.fields.files_attributes.map((file) => ( + + ))} + + + + ); +}; + +export default ProgrammingAnswerDetails; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/CodaveriFeedbackStatus.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/CodaveriFeedbackStatus.tsx new file mode 100644 index 0000000000..98626a3646 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/CodaveriFeedbackStatus.tsx @@ -0,0 +1,72 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Paper, Typography } from '@mui/material'; +import { CodaveriFeedback } from 'types/course/statistics/answer'; + +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + codaveriFeedbackStatus: { + id: 'course.assessment.submission.CodaveriFeedbackStatus.codaveriFeedbackStatus', + defaultMessage: 'Codaveri Feedback Status', + }, + loadingFeedbackGeneration: { + id: 'course.assessment.submission.CodaveriFeedbackStatus.loadingFeedbackGeneration', + defaultMessage: 'Generating Feedback. Please wait...', + }, + successfulFeedbackGeneration: { + id: 'course.assessment.submission.CodaveriFeedbackStatus.successfulFeedbackGeneration', + defaultMessage: 'Feedback has been successfully generated.', + }, + failedFeedbackGeneration: { + id: 'course.assessment.submission.CodaveriFeedbackStatus.failedFeedbackGeneration', + defaultMessage: 'Failed to generate feedback. Please try again later.', + }, +}); + +const codaveriJobDisplay = { + submitted: { + feedbackBgColor: 'bg-orange-100', + feedbackDescription: translations.loadingFeedbackGeneration, + }, + completed: { + feedbackBgColor: 'bg-green-100', + feedbackDescription: translations.successfulFeedbackGeneration, + }, + errored: { + feedbackBgColor: 'bg-red-100', + feedbackDescription: translations.failedFeedbackGeneration, + }, +}; + +interface Props { + status?: CodaveriFeedback; +} + +const CodaveriFeedbackStatus: FC = (props) => { + const { t } = useTranslation(); + const { status } = props; + + if (!status) { + return null; + } + + const { feedbackBgColor, feedbackDescription } = + codaveriJobDisplay[status.jobStatus]; + + return ( + + + {t(translations.codaveriFeedbackStatus)} + + + {t(feedbackDescription)} + + + ); +}; + +export default CodaveriFeedbackStatus; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/FileContent.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/FileContent.tsx new file mode 100644 index 0000000000..ea7cb0fdfa --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/FileContent.tsx @@ -0,0 +1,47 @@ +import { FC } from 'react'; +import { defineMessages, FormattedMessage } from 'react-intl'; +import { Warning } from '@mui/icons-material'; +import { Paper, Typography } from '@mui/material'; +import { ProgrammingContent } from 'types/course/assessment/submission/answer/programming'; +import { Annotation, AnnotationTopic } from 'types/course/statistics/answer'; + +import ProgrammingFileDownloadLink from 'course/assessment/submission/components/answers/Programming/ProgrammingFileDownloadLink'; +import ReadOnlyEditor from 'course/assessment/submission/components/ReadOnlyEditor'; + +const translations = defineMessages({ + sizeTooBig: { + id: 'course.assessment.submission.answers.Programming.ProgrammingFile.sizeTooBig', + defaultMessage: 'The file is too big and cannot be displayed.', + }, +}); + +interface Props { + answerId: number; + annotations: Annotation[]; + file: ProgrammingContent; +} + +const FileContent: FC = (props) => { + const { answerId, annotations, file } = props; + const fileAnnotation = annotations.find((a) => a.fileId === file.id); + + return file.highlightedContent ? ( + + ) : ( + <> + + + + + + + + + ); +}; + +export default FileContent; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCaseRow.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCaseRow.tsx new file mode 100644 index 0000000000..3266022da8 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCaseRow.tsx @@ -0,0 +1,69 @@ +import { FC, Fragment } from 'react'; +import { Clear, Done } from '@mui/icons-material'; +import { TableCell, TableRow, Typography } from '@mui/material'; +import { TestCaseResult } from 'types/course/assessment/submission/answer/programming'; + +import ExpandableCode from 'lib/components/core/ExpandableCode'; + +interface Props { + result: TestCaseResult; +} + +const TestCaseClassName = { + unattempted: '', + correct: 'bg-green-50', + wrong: 'bg-red-50', +}; + +const TestCaseRow: FC = (props) => { + const { result } = props; + + const nameRegex = /\/?(\w+)$/; + const idMatch = result.identifier?.match(nameRegex); + const truncatedIdentifier = idMatch ? idMatch[1] : ''; + + let testCaseResult = 'unattempted'; + let testCaseIcon; + if (result.passed !== undefined) { + testCaseResult = result.passed ? 'correct' : 'wrong'; + testCaseIcon = result.passed ? ( + + ) : ( + + ); + } + + return ( + + + + + {truncatedIdentifier} + + + + + + + {result.expression} + + + + {result.expected || ''} + + + + {result.output || ''} + + + {testCaseIcon} + + + ); +}; + +export default TestCaseRow; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx new file mode 100644 index 0000000000..e3ff536943 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx @@ -0,0 +1,190 @@ +import { FC } from 'react'; +import { defineMessages, FormattedMessage } from 'react-intl'; +import { Done } from '@mui/icons-material'; +import { + Chip, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from '@mui/material'; +import { TestCaseResult } from 'types/course/assessment/submission/answer/programming'; +import { TestCase } from 'types/course/statistics/answer'; + +import Accordion from 'lib/components/core/layouts/Accordion'; +import useTranslation from 'lib/hooks/useTranslation'; + +import TestCaseRow from './TestCaseRow'; + +const translations = defineMessages({ + expression: { + id: 'course.assessment.submission.TestCaseView.experession', + defaultMessage: 'Expression', + }, + expected: { + id: 'course.assessment.submission.TestCaseView.expected', + defaultMessage: 'Expected', + }, + output: { + id: 'course.assessment.submission.TestCaseView.output', + defaultMessage: 'Output', + }, + allPassed: { + id: 'course.assessment.submission.TestCaseView.allPassed', + defaultMessage: 'All passed', + }, + publicTestCases: { + id: 'course.assessment.submission.TestCaseView.publicTestCases', + defaultMessage: 'Public Test Cases', + }, + privateTestCases: { + id: 'course.assessment.submission.TestCaseView.privateTestCases', + defaultMessage: 'Private Test Cases', + }, + evaluationTestCases: { + id: 'course.assessment.submission.TestCaseView.evaluationTestCases', + defaultMessage: 'Evaluation Test Cases', + }, + staffOnlyTestCases: { + id: 'course.assessment.submission.TestCaseView.staffOnlyTestCases', + defaultMessage: 'Only staff can see this.', + }, + staffOnlyOutputStream: { + id: 'course.assessment.submission.TestCaseView.staffOnlyOutputStream', + defaultMessage: + "Only staff can see this. Students can't see output streams.", + }, + standardOutput: { + id: 'course.assessment.submission.TestCaseView.standardOutput', + defaultMessage: 'Standard Output', + }, + standardError: { + id: 'course.assessment.submission.TestCaseView.standardError', + defaultMessage: 'Standard Error', + }, + autogradeProgress: { + id: 'course.assessment.submission.TestCaseView.autogradeProgress', + defaultMessage: + 'The answer is currently being evaluated, come back after a while \ + to see the latest results.', + }, + noOutputs: { + id: 'course.assessment.submission.TestCaseView.noOutputs', + defaultMessage: 'No outputs', + }, +}); + +interface Props { + testCase: TestCase; +} + +const TestCaseComponent = ( + testCaseResults: TestCaseResult[], + testCaseType: string, +): JSX.Element => { + const { t } = useTranslation(); + const passedTestCases = testCaseResults.reduce( + (passed, testCase) => passed && testCase?.passed, + true, + ); + + return ( + } + label={t(translations.allPassed)} + size="small" + variant="outlined" + /> + ) + } + id={testCaseType} + title={t(translations[testCaseType])} + > +
+ + + + + + + + + + + + + + + + + + + + {testCaseResults.map((result) => ( + + ))} + +
+ + ); +}; + +const OutputStream = ( + outputStreamType: 'standardOutput' | 'standardError', + output?: string, +): JSX.Element => { + const { t } = useTranslation(); + return ( + } + size="small" + variant="outlined" + /> + ) + } + id={outputStreamType} + title={t(translations[outputStreamType])} + > +
{output}
+
+ ); +}; + +const TestCases: FC = (props) => { + const { testCase } = props; + + return ( +
+ {testCase.public_test && + testCase.public_test.length > 0 && + TestCaseComponent(testCase.public_test, 'publicTestCases')} + + {testCase.private_test && + testCase.private_test.length > 0 && + TestCaseComponent(testCase.private_test, 'privateTestCases')} + + {testCase.evaluation_test && + testCase.evaluation_test.length > 0 && + TestCaseComponent(testCase.evaluation_test, 'evaluationTestCases')} + + {OutputStream('standardOutput', testCase.stdout)} + {OutputStream('standardError', testCase.stderr)} +
+ ); +}; + +export default TestCases; diff --git a/client/app/types/course/statistics/answer.ts b/client/app/types/course/statistics/answer.ts index 54221023cc..f5f76af5cb 100644 --- a/client/app/types/course/statistics/answer.ts +++ b/client/app/types/course/statistics/answer.ts @@ -19,6 +19,7 @@ import { import { VoiceResponseFieldData } from '../assessment/submission/answer/voiceResponse'; interface AnswerCommonDetails { + id: number; grade: number; questionType: T; } @@ -43,6 +44,33 @@ export interface MrqAnswerDetails latestAnswer?: MrqAnswerDetails; } +export interface AnnotationTopic { + id: number; + postIds: number[]; + line: string; +} + +export interface Annotation { + fileId: number; + topics: AnnotationTopic[]; +} + +export interface TestCase { + canReadTests: boolean; + public_test?: TestCaseResult[]; + private_test?: TestCaseResult[]; + evaluation_test?: TestCaseResult[]; + stdout?: string; + stderr?: string; +} + +export interface CodaveriFeedback { + jobId: string; + jobStatus: keyof typeof JobStatus; + jobUrl?: string; + errorMessage?: string; +} + export interface ProgrammingAnswerDetails extends AnswerCommonDetails<'Programming'> { fields: ProgrammingFieldData; @@ -51,33 +79,14 @@ export interface ProgrammingAnswerDetails explanation: string[]; failureType: TestCaseType; }; - testCases: { - canReadTests: boolean; - public_test?: TestCaseResult[]; - private_test?: TestCaseResult[]; - evaluation_test?: TestCaseResult[]; - stdout?: string; - stderr?: string; - }; + testCases: TestCase; attemptsLeft?: number; autograding?: JobStatusResponse & { path?: string; }; - codaveriFeedback?: { - jobId: string; - jobStatus: keyof typeof JobStatus; - jobUrl?: string; - errorMessage?: string; - }; + codaveriFeedback?: CodaveriFeedback; latestAnswer?: ProgrammingAnswerDetails & { - annotations: { - fileId: number; - topics: { - id: number; - postIds: number[]; - line: string; - }[]; - }; + annotations: Annotation[]; }; } From 9e9c7a717a4fd0a195ba99dd14726e8a7bcedcf4 Mon Sep 17 00:00:00 2001 From: bivanalhar Date: Tue, 20 Feb 2024 14:15:36 +0800 Subject: [PATCH 45/45] chore(answer_stats): remove unnecessary translations --- .../ForumPostResponseComponent/PostPack.tsx | 25 ------------------- .../ProgrammingComponent/TestCases.tsx | 15 ----------- 2 files changed, 40 deletions(-) diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostPack.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostPack.tsx index 49c6761de1..5cd8a881c2 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostPack.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ForumPostResponseComponent/PostPack.tsx @@ -13,31 +13,6 @@ import ParentPostPack from './ParentPostPack'; import PostContent from './PostContent'; const translations = defineMessages({ - cannotRetrieveForumPosts: { - id: 'course.assessment.submission.answers.ForumPostResponse.ForumPostSelect.cannotRetrieveForumPosts', - defaultMessage: - 'Oops! Unable to retrieve your forum posts. Please try refreshing this page.', - }, - cannotRetrieveSelectedPostPacks: { - id: 'course.assessment.submission.answers.ForumPostResponse.ForumPostSelect.cannotRetrieveSelectedPostPacks', - defaultMessage: - 'Oops! Unable to retrieve your selected posts. Please try refreshing this page.', - }, - submittedInstructions: { - id: 'course.assessment.submission.answers.ForumPostResponse.ForumPostSelect.submittedInstructions', - defaultMessage: - '{numPosts, plural, =0 {No posts were} one {# post was} other {# posts were}} submitted.', - }, - selectInstructions: { - id: 'course.assessment.submission.answers.ForumPostResponse.ForumPostSelect.selectInstructions', - defaultMessage: - 'Select {maxPosts} forum {maxPosts, plural, one {post} other {posts}}. ' + - 'You have selected {numPosts} {numPosts, plural, one {post} other {posts}}.', - }, - selectPostsButton: { - id: 'course.assessment.submission.answers.ForumPostResponse.ForumPostSelect.selectPostsButton', - defaultMessage: 'Select Forum {maxPosts, plural, one {Post} other {Posts}}', - }, topicDeleted: { id: 'course.assessment.submission.answers.ForumPostResponse.SelectedPostCard.topicDeleted', defaultMessage: 'Post made under a topic that was subsequently deleted.', diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx index e3ff536943..d862064771 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/AnswerDetails/ProgrammingComponent/TestCases.tsx @@ -46,15 +46,6 @@ const translations = defineMessages({ id: 'course.assessment.submission.TestCaseView.evaluationTestCases', defaultMessage: 'Evaluation Test Cases', }, - staffOnlyTestCases: { - id: 'course.assessment.submission.TestCaseView.staffOnlyTestCases', - defaultMessage: 'Only staff can see this.', - }, - staffOnlyOutputStream: { - id: 'course.assessment.submission.TestCaseView.staffOnlyOutputStream', - defaultMessage: - "Only staff can see this. Students can't see output streams.", - }, standardOutput: { id: 'course.assessment.submission.TestCaseView.standardOutput', defaultMessage: 'Standard Output', @@ -63,12 +54,6 @@ const translations = defineMessages({ id: 'course.assessment.submission.TestCaseView.standardError', defaultMessage: 'Standard Error', }, - autogradeProgress: { - id: 'course.assessment.submission.TestCaseView.autogradeProgress', - defaultMessage: - 'The answer is currently being evaluated, come back after a while \ - to see the latest results.', - }, noOutputs: { id: 'course.assessment.submission.TestCaseView.noOutputs', defaultMessage: 'No outputs',