From b18d72201065d93729c1ee0f46346e0a17284134 Mon Sep 17 00:00:00 2001 From: Leangseu Kim Date: Tue, 24 Oct 2023 13:05:02 -0400 Subject: [PATCH] feat: implement grade route --- src/App.jsx | 3 + .../CollapsibleFeedback/Feedback.jsx | 53 +++++++++++++ src/components/CollapsibleFeedback/index.jsx | 52 +++++++++++++ .../CollapsibleFeedback/messages.js | 31 ++++++++ .../FileRenderer/FileCard/index.jsx | 5 +- .../components/FileRenderer/index.jsx | 9 ++- src/components/FilePreview/index.jsx | 4 +- src/components/FileUpload/index.jsx | 5 +- src/components/Prompt/hooks.js | 4 +- src/components/Prompt/index.jsx | 9 ++- src/data/services/lms/constants.js | 2 +- src/data/services/lms/fakeData/constants.js | 2 +- .../lms/fakeData/pageData/assessments.js | 38 ++++----- src/data/services/lms/hooks/data.ts | 33 ++++---- src/data/services/lms/types/pageData.ts | 10 ++- src/routes.ts | 2 + src/views/GradeView/Content.jsx | 46 +++++++++++ src/views/GradeView/FinalGrade.jsx | 77 +++++++++++++++++++ src/views/GradeView/index.jsx | 30 ++++++++ src/views/GradeView/index.scss | 14 ++++ src/views/GradeView/messages.js | 41 ++++++++++ 21 files changed, 416 insertions(+), 54 deletions(-) create mode 100644 src/components/CollapsibleFeedback/Feedback.jsx create mode 100644 src/components/CollapsibleFeedback/index.jsx create mode 100644 src/components/CollapsibleFeedback/messages.js create mode 100644 src/views/GradeView/Content.jsx create mode 100644 src/views/GradeView/FinalGrade.jsx create mode 100644 src/views/GradeView/index.jsx create mode 100644 src/views/GradeView/index.scss create mode 100644 src/views/GradeView/messages.js diff --git a/src/App.jsx b/src/App.jsx index 6a3cc588..d135fb0e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,6 +10,7 @@ import SelfAssessmentView from 'views/SelfAssessmentView'; import StudentTrainingView from 'views/StudentTrainingView'; import SubmissionView from 'views/SubmissionView'; import XBlockView from 'views/XBlockView'; +import GradeView from 'views/GradeView'; import AppContainer from 'components/AppContainer'; import ModalContainer from 'components/ModalContainer'; @@ -49,6 +50,7 @@ const RouterRoot = () => { modalRoute(routes.selfAssessmentEmbed, SelfAssessmentView, 'ORA Self Assessment'), modalRoute(routes.studentTrainingEmbed, StudentTrainingView, 'ORA Student Training'), modalRoute(routes.submissionEmbed, SubmissionView, 'ORA Submission'), + modalRoute(routes.gradedEmbed, GradeView, 'My Grade'), } />, ]; const baseRoutes = [ @@ -57,6 +59,7 @@ const RouterRoot = () => { modalRoute(routes.selfAssessment, SelfAssessmentView, 'Assess yourself'), modalRoute(routes.studentTraining, StudentTrainingView, 'Practice grading'), modalRoute(routes.submission, SubmissionView, 'Your response'), + modalRoute(routes.graded, GradeView, 'My Grade'), } />, ]; diff --git a/src/components/CollapsibleFeedback/Feedback.jsx b/src/components/CollapsibleFeedback/Feedback.jsx new file mode 100644 index 00000000..7eddbe52 --- /dev/null +++ b/src/components/CollapsibleFeedback/Feedback.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Collapsible, Icon } from '@edx/paragon'; +import { ExpandMore, ExpandLess } from '@edx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +const Feedback = ({ criterion, selectedOption, selectedPoints, commentHeader, commentBody, defaultOpen }) => { + const [isExpanded, setIsExpanded] = React.useState(defaultOpen); + const { formatMessage } = useIntl(); + + return ( + <> +
+
{criterion.name}
+ {selectedOption &&

{selectedOption} -- {selectedPoints} points

} +
+
+ setIsExpanded(!isExpanded)} + > + +
{commentHeader} Comment
+ {isExpanded ? ( +
+ {formatMessage(messages.readLess)} + +
+ ) : ( +
+ {formatMessage(messages.readMore)} + +
+ )} +
+ +

{commentBody}

+
+
+
+ + ); +}; +Feedback.defaultProps = { + defaultOpen: false, +}; +Feedback.propTypes = { + defaultOpen: PropTypes.bool, +}; + +export default Feedback; diff --git a/src/components/CollapsibleFeedback/index.jsx b/src/components/CollapsibleFeedback/index.jsx new file mode 100644 index 00000000..33521816 --- /dev/null +++ b/src/components/CollapsibleFeedback/index.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Collapsible } from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import Feedback from './Feedback'; +import messages from './messages'; + +const CollapsibleFeedback = ({ assessment, stepScore, stepName }) => { + const assessmentCriterions = assessment.assessmentCriterions; + const { formatMessage } = useIntl(); + + return ( + + {formatMessage(messages.grade, { + stepName, + })} + {stepScore && formatMessage(messages.gradePoints, stepScore)} + + } + > + {assessmentCriterions.map((criterion) => { + return ( + + ); + })} + + + ); +}; +CollapsibleFeedback.defaultProps = {}; +CollapsibleFeedback.propTypes = { + // assessment: PropTypes.shape({ + // overallFeedback: PropTypes.string, + // }), + stepName: PropTypes.string.isRequired, +}; + +export default CollapsibleFeedback; diff --git a/src/components/CollapsibleFeedback/messages.js b/src/components/CollapsibleFeedback/messages.js new file mode 100644 index 00000000..ed38e668 --- /dev/null +++ b/src/components/CollapsibleFeedback/messages.js @@ -0,0 +1,31 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + readMore: { + id: 'ora-collapsible-comment.readMore', + defaultMessage: 'Read more', + description: 'Read more button text', + }, + readLess: { + id: 'ora-collapsible-comment.readLess', + defaultMessage: 'Read less', + description: 'Read less button text', + }, + grade: { + id: 'ora-collapsible-comment.grade', + defaultMessage: '{stepName} Grade:', + description: 'Grade button text', + }, + gradePoints: { + id: 'ora-collapsible-comment.gradePoints', + defaultMessage: '{earned} / {possible}', + description: 'Grade points button text', + }, + notWeightedGradeLabel: { + id: 'ora-collapsible-comment.notWeightedGradeLabel', + defaultMessage: '(Not weighted toward final grade))', + description: 'Not weighted grade label', + }, +}); + +export default messages; diff --git a/src/components/FilePreview/components/FileRenderer/FileCard/index.jsx b/src/components/FilePreview/components/FileRenderer/FileCard/index.jsx index 9b622883..d84be372 100644 --- a/src/components/FilePreview/components/FileRenderer/FileCard/index.jsx +++ b/src/components/FilePreview/components/FileRenderer/FileCard/index.jsx @@ -8,11 +8,11 @@ import './FileCard.scss'; /** * */ -const FileCard = ({ file, children }) => ( +const FileCard = ({ file, children, defaultOpen }) => ( {file.fileName}} >
@@ -24,6 +24,7 @@ const FileCard = ({ file, children }) => ( FileCard.propTypes = { file: PropTypes.shape({ fileName: PropTypes.string.isRequired }).isRequired, children: PropTypes.node.isRequired, + defaultOpen: PropTypes.bool.isRequired, }; export default FileCard; diff --git a/src/components/FilePreview/components/FileRenderer/index.jsx b/src/components/FilePreview/components/FileRenderer/index.jsx index 679e664e..5120ea82 100644 --- a/src/components/FilePreview/components/FileRenderer/index.jsx +++ b/src/components/FilePreview/components/FileRenderer/index.jsx @@ -10,7 +10,7 @@ import { useRenderData } from './hooks'; /** * */ -export const FileRenderer = ({ file }) => { +export const FileRenderer = ({ file, defaultOpen }) => { const { formatMessage } = useIntl(); const { Renderer, @@ -21,7 +21,7 @@ export const FileRenderer = ({ file }) => { } = useRenderData({ file, formatMessage }); return ( - + {isLoading && } {errorStatus ? ( @@ -32,12 +32,15 @@ export const FileRenderer = ({ file }) => { ); }; -FileRenderer.defaultProps = {}; +FileRenderer.defaultProps = { + defaultOpen: true, +}; FileRenderer.propTypes = { file: PropTypes.shape({ fileName: PropTypes.string, fileUrl: PropTypes.string, }).isRequired, + defaultOpen: PropTypes.bool, // injected // intl: intlShape.isRequired, }; diff --git a/src/components/FilePreview/index.jsx b/src/components/FilePreview/index.jsx index 38a32520..599d8641 100644 --- a/src/components/FilePreview/index.jsx +++ b/src/components/FilePreview/index.jsx @@ -3,12 +3,12 @@ import React from 'react'; import { useResponseData } from 'data/services/lms/hooks/selectors'; import { FileRenderer, isSupported } from './components'; -const FilePreview = () => { +const FilePreview = ({ defaultCollapsePreview }) => { const { uploadedFiles } = useResponseData(); return (
{uploadedFiles.filter(isSupported).map((file) => ( - + ))}
); diff --git a/src/components/FileUpload/index.jsx b/src/components/FileUpload/index.jsx index 8aa3b12a..b3d49d16 100644 --- a/src/components/FileUpload/index.jsx +++ b/src/components/FileUpload/index.jsx @@ -24,6 +24,7 @@ const FileUpload = ({ uploadedFiles, onFileUploaded, onDeletedFile, + defaultCollapsePreview, }) => { const { formatMessage } = useIntl(); @@ -39,7 +40,7 @@ const FileUpload = ({ return (

File Upload

- {isReadOnly && } + {isReadOnly && } {uploadedFiles.length > 0 && ( <> Uploaded Files @@ -91,6 +92,7 @@ FileUpload.defaultProps = { uploadedFiles: [], onFileUploaded: nullMethod, onDeletedFile: nullMethod, + defaultCollapsePreview: false, }; FileUpload.propTypes = { isReadOnly: PropTypes.bool, @@ -103,6 +105,7 @@ FileUpload.propTypes = { ), onFileUploaded: PropTypes.func, onDeletedFile: PropTypes.func, + defaultCollapsePreview: PropTypes.bool, }; export default FileUpload; diff --git a/src/components/Prompt/hooks.js b/src/components/Prompt/hooks.js index 2cc77124..a688665f 100644 --- a/src/components/Prompt/hooks.js +++ b/src/components/Prompt/hooks.js @@ -1,7 +1,7 @@ import { useState } from 'react'; -const usePromptHooks = () => { - const [open, setOpen] = useState(true); +const usePromptHooks = ({ defaultOpen }) => { + const [open, setOpen] = useState(defaultOpen); const toggleOpen = () => setOpen(!open); diff --git a/src/components/Prompt/index.jsx b/src/components/Prompt/index.jsx index 4e37a4ba..4931cba4 100644 --- a/src/components/Prompt/index.jsx +++ b/src/components/Prompt/index.jsx @@ -5,8 +5,8 @@ import { Collapsible } from '@edx/paragon'; import usePromptHooks from './hooks'; -const Prompt = ({ prompt }) => { - const { open, toggleOpen } = usePromptHooks(); +const Prompt = ({ prompt, defaultOpen }) => { + const { open, toggleOpen } = usePromptHooks({ defaultOpen }); return (
@@ -14,7 +14,12 @@ const Prompt = ({ prompt }) => { ); }; +Prompt.defaultProps = { + defaultOpen: true, +}; + Prompt.propTypes = { + defaultOpen: PropTypes.bool, prompt: PropTypes.string.isRequired, }; diff --git a/src/data/services/lms/constants.js b/src/data/services/lms/constants.js index 50585d91..da3fb68c 100644 --- a/src/data/services/lms/constants.js +++ b/src/data/services/lms/constants.js @@ -50,7 +50,7 @@ export const routeSteps = StrictDict({ peer_assessment: stepNames.peer, self_assessment: stepNames.self, student_training: stepNames.studentTraining, - my_grades: stepNames.done, + graded: stepNames.done, }); export const stepRoutes = StrictDict(Object.keys(routeSteps).reduce( diff --git a/src/data/services/lms/fakeData/constants.js b/src/data/services/lms/fakeData/constants.js index 00dacbcb..d95b5852 100644 --- a/src/data/services/lms/fakeData/constants.js +++ b/src/data/services/lms/fakeData/constants.js @@ -7,7 +7,7 @@ export const viewKeys = StrictDict({ studentTraining: 'student_training', self: 'self_assessment', peer: 'peer_assessment', - done: 'my_grades', + done: 'graded', }); export const progressKeys = StrictDict({ diff --git a/src/data/services/lms/fakeData/pageData/assessments.js b/src/data/services/lms/fakeData/pageData/assessments.js index abef9a96..fa255753 100644 --- a/src/data/services/lms/fakeData/pageData/assessments.js +++ b/src/data/services/lms/fakeData/pageData/assessments.js @@ -2,36 +2,20 @@ import { stepNames } from 'data/services/lms/constants'; import { progressKeys } from '../constants'; export const createAssessmentState = ({ - options_selected = [], - criterion_feedback, + assessment_criterions = [], overall_feedback = '', }) => ({ - options_selected, - criterion_feedback, + assessment_criterions, overall_feedback, }); -export const emptySelections = { - 'Criterion 1 name': null, - 'Criterion 2 name': null, - 'Criterion 3 name': null, - 'Criterion 4 name': null, -}; -export const filledSelections = { - 'Criterion 1 name': 'Option 4 name', - 'Criterion 2 name': 'Option 3 name', - 'Criterion 3 name': 'Option 2 name', - 'Criterion 4 name': 'Option 1 name', -}; - const gradedState = createAssessmentState({ - options_selected: filledSelections, - criterion_feedback: { - 'Criterion 1 name': 'feedback 1', - 'Criterion 2 name': 'feedback 2', - 'Criterion 3 name': 'feedback 3', - 'Criterion 4 name': 'feedback 4', - }, + assessment_criterions: new Array(4).fill(0).map((_, i) => ({ + name: `Criterion ${i + 1} name`, + selectedOption: `Option ${i + 1} name`, + selectedPoints: i, + feedback: `feedback ${i + 1}`, + })), overall_feedback: 'nice job', }); @@ -66,6 +50,12 @@ export const getAssessmentState = ({ progressKey, stepConfig }) => { ], }; } + if (stepConfig.includes(stepNames.self)) { + out.self = { + stepScore: { earned: 10, possible: 10 }, + assessment: gradedState, + }; + } return out; }; diff --git a/src/data/services/lms/hooks/data.ts b/src/data/services/lms/hooks/data.ts index 01d04d19..90fb4cbc 100644 --- a/src/data/services/lms/hooks/data.ts +++ b/src/data/services/lms/hooks/data.ts @@ -1,9 +1,6 @@ import { useQuery, useMutation } from '@tanstack/react-query'; -import { - useParams, - useLocation, -} from 'react-router-dom'; +import { useParams, useLocation } from 'react-router-dom'; import { camelCaseObject } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -25,17 +22,19 @@ export const useORAConfig = (): types.QueryData => { ({ data }) => camelCaseObject(data) ); */ - return Promise.resolve(camelCaseObject(fakeData.oraConfig.assessmentTinyMCE)); + return Promise.resolve( + camelCaseObject(fakeData.oraConfig.assessmentTinyMCE) + ); }, }); -} +}; export const usePageData = (): types.QueryData => { const location = useLocation(); const { progressKey } = useParams(); const view = location.pathname.split('/')[1]; const pageDataUrl = usePageDataUrl(view); - + return useQuery({ queryKey: [queryKeys.pageData], queryFn: () => { @@ -44,14 +43,20 @@ export const usePageData = (): types.QueryData => { ({ data }) => camelCaseObject(data) ); */ - return Promise.resolve(camelCaseObject(loadState({ view, progressKey }))); + // const data = camelCaseObject(loadState({ view, progressKey })) + const data = loadState({ view, progressKey }); + const result = { + ...camelCaseObject(data), + }; + return Promise.resolve(result); }, }); }; -export const useSubmitResponse = () => useMutation({ - mutationFn: (response) => { - console.log({ submit: response }); - return Promise.resolve(); - }, -}); +export const useSubmitResponse = () => + useMutation({ + mutationFn: (response) => { + console.log({ submit: response }); + return Promise.resolve(); + }, + }); diff --git a/src/data/services/lms/types/pageData.ts b/src/data/services/lms/types/pageData.ts index fdc8d362..4401d7b0 100644 --- a/src/data/services/lms/types/pageData.ts +++ b/src/data/services/lms/types/pageData.ts @@ -78,6 +78,12 @@ export interface ResponseData { export interface AssessmentData { optionsSelected: { [key: string]: string | null }, criterionFeedback: { [key: string]: string }, + assessmentCriterions: { + name: string, + selectedOption: string | null, + selectedPoints: number | null, + feedback: string, + }[], overallFeedback: string | null, } @@ -88,10 +94,10 @@ export interface AssessmentsData { stepScore: { earned: number, possible: number }, assessment: AssessmentData, }, - peer?: { + peers?: { stepScore: { earned: number, possible: number }, assessments: AssessmentData[], - }, + }[], peerUnweighted?: { stepScore: null, assessmenst: AssessmentData[], diff --git a/src/routes.ts b/src/routes.ts index 0101274d..a5e09b2d 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -4,11 +4,13 @@ export default { selfAssessmentEmbed: 'self_assessment/embedded/:courseId/:xblockId/:progressKey?', studentTrainingEmbed: 'student_training/embedded/:courseId/:xblockId/:progressKey?', submissionEmbed: 'submission/embedded/:courseId/:xblockId/:progressKey?', + gradedEmbed: 'graded/embedded/:courseId/:xblockId/:progressKey?', rootEmbed: 'embedded/*', xblock: 'xblock/:courseId/:xblockId/:progressKey?', peerAssessment: 'peer_assessment/:courseId/:xblockId/:progressKey?', selfAssessment: 'self_assessment/:courseId/:xblockId/:progressKey?', studentTraining: 'student_training/:courseId/:xblockId/:progressKey?', submission: 'submission/:courseId/:xblockId/:progressKey?', + graded: 'graded/:courseId/:xblockId/:progressKey?', root: '/*', }; diff --git a/src/views/GradeView/Content.jsx b/src/views/GradeView/Content.jsx new file mode 100644 index 00000000..c7851ebb --- /dev/null +++ b/src/views/GradeView/Content.jsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { + usePrompts, + useResponseData, +} from 'data/services/lms/hooks/selectors'; + +import FileUpload from 'components/FileUpload'; +import Prompt from 'components/Prompt'; +import TextResponse from 'components/TextResponse'; +import messages from './messages'; + +const Content = () => { + const prompts = usePrompts(); + const response = useResponseData(); + const { formatMessage } = useIntl(); + return ( +
+ {formatMessage(messages.aboutYourGrade)} +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea + commodo consequat. +

+
+ { + prompts.map((prompt, index) => ( +
+ + +
+ )) + } + +
+
+ ); +}; + +Content.defaultProps = {}; +Content.propTypes = {}; + +export default Content; diff --git a/src/views/GradeView/FinalGrade.jsx b/src/views/GradeView/FinalGrade.jsx new file mode 100644 index 00000000..77458dc0 --- /dev/null +++ b/src/views/GradeView/FinalGrade.jsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import CollapsibleFeedback from 'components/CollapsibleFeedback'; +import { useAssessmentsData } from 'data/services/lms/hooks/selectors'; +import messages from './messages'; + +const FinalGrade = () => { + const { formatMessage } = useIntl(); + const assessments = useAssessmentsData(); + + const result = []; + let finalStepScore = null; + if (assessments.staff) { + finalStepScore = assessments.staff.stepScore; + result.push( + + ); + } + if (assessments.peer) { + finalStepScore = finalStepScore || assessments.peer.stepScore; + result.push( +
+ {assessments.peer.assessment?.map((peer, index) => ( + + ))} +
+ ); + } + if (assessments.peerUnweighted) { + result.push( +
+ {assessments.peerUnweighted.assessment?.map((peer, index) => ( + + ))} +
+ ); + } + if (assessments.self) { + finalStepScore = finalStepScore || assessments.self.stepScore; + result.push( + + ); + } + + const [finalGrade, ...rest] = result; + + return ( +
+

{formatMessage(messages.yourFinalGrade, finalStepScore)}

+ {finalGrade} +
+

{formatMessage(messages.unweightedGrades)}

+ {rest} +
+ ); +}; + +FinalGrade.defaultProps = {}; +FinalGrade.propTypes = {}; + +export default FinalGrade; diff --git a/src/views/GradeView/index.jsx b/src/views/GradeView/index.jsx new file mode 100644 index 00000000..a5516bfd --- /dev/null +++ b/src/views/GradeView/index.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ActionRow, Col, Row } from '@edx/paragon'; + +import { AssessmentContextProvider } from 'components/AssessmentContext'; + +import FinalGrade from './FinalGrade'; +import Content from './Content'; + +import './index.scss'; + +const GradeView = ({}) => ( + +
+ +
+ +
+
+ +
+
+
+
+); +GradeView.defaultProps = {}; +GradeView.propTypes = {}; + +export default GradeView; diff --git a/src/views/GradeView/index.scss b/src/views/GradeView/index.scss new file mode 100644 index 00000000..22fb8803 --- /dev/null +++ b/src/views/GradeView/index.scss @@ -0,0 +1,14 @@ +@import "@edx/paragon/scss/core/core"; + +.grade-view-body { + max-width: $max-width-xl; + flex-wrap: nowrap !important; + gap: 1em; +} + +@include media-breakpoint-down(md) { + .grade-view-body { + flex-wrap: wrap !important; + gap: 0; + } +} diff --git a/src/views/GradeView/messages.js b/src/views/GradeView/messages.js new file mode 100644 index 00000000..7db1be84 --- /dev/null +++ b/src/views/GradeView/messages.js @@ -0,0 +1,41 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + aboutYourGrade: { + id: 'ora-grade-view.aboutYourGrade', + defaultMessage: 'About your grade: ', + description: 'About your grade', + }, + yourFinalGrade: { + id: 'ora-grade-view.yourFinalGrade', + defaultMessage: 'Your final grade: {earned}/{possible}', + description: 'Your final grade', + }, + unweightedGrades: { + id: 'ora-grade-view.unweightedGrades', + defaultMessage: 'Unweighted Grades', + description: 'Unweighted grades', + }, + selfStep: { + id: 'ora-grade-view.selfStep', + defaultMessage: 'Self', + description: 'Self step', + }, + peerStep: { + id: 'ora-grade-view.peerStep', + defaultMessage: 'Peer', + description: 'Peer step', + }, + staffStep: { + id: 'ora-grade-view.staffStep', + defaultMessage: 'Staff', + description: 'Staff step', + }, + unweightedPeerStep: { + id: 'ora-grade-view.unweightedPeerStep', + defaultMessage: 'Unweighted Peer', + description: 'Unweighted peer step', + }, +}); + +export default messages;