diff --git a/client/app/bundles/course/assessment/submission/actions/answers/index.js b/client/app/bundles/course/assessment/submission/actions/answers/index.js index 44331bb351f..991a48886f7 100644 --- a/client/app/bundles/course/assessment/submission/actions/answers/index.js +++ b/client/app/bundles/course/assessment/submission/actions/answers/index.js @@ -230,19 +230,57 @@ export function initializeLiveFeedback(questionId) { }); } -export function generateLiveFeedback(submissionId, answerId, questionId) { +// if status returned 200, populate feedback array if has feedback, otherwise return error +const handleFeedbackOKResponse = ({ + dispatch, + response, + answerId, + questionId, + successMessage, + noFeedbackMessage, +}) => { + const feedbackFiles = response.data?.data?.feedbackFiles ?? []; + const success = response.data?.success; + if (success && feedbackFiles.length) { + dispatch({ + type: actionTypes.LIVE_FEEDBACK_SUCCESS, + payload: { + questionId, + answerId, + feedbackFiles, + }, + }); + dispatch(setNotification(successMessage)); + } else { + dispatch({ + type: actionTypes.LIVE_FEEDBACK_FAILURE, + payload: { + questionId, + }, + }); + dispatch(setNotification(noFeedbackMessage)); + } +}; + +export function generateLiveFeedback({ + submissionId, + answerId, + questionId, + successMessage, + noFeedbackMessage, +}) { return (dispatch) => CourseAPI.assessment.submissions .generateLiveFeedback(submissionId, { answer_id: answerId }) .then((response) => { if (response.status === 200) { - dispatch({ - type: actionTypes.LIVE_FEEDBACK_SUCCESS, - payload: { - questionId, - answerId, - feedbackFiles: response.data?.data?.feedbackFiles ?? {}, - }, + handleFeedbackOKResponse({ + dispatch, + response, + answerId, + questionId, + successMessage, + noFeedbackMessage, }); } else { // 201, save feedback signed token @@ -267,26 +305,26 @@ export function generateLiveFeedback(submissionId, answerId, questionId) { }); } -// TODO should each answer/question store its own feedback array? -export function fetchLiveFeedback( +export function fetchLiveFeedback({ answerId, questionId, feedbackUrl, feedbackToken, -) { + successMessage, + noFeedbackMessage, +}) { return (dispatch) => CourseAPI.assessment.submissions .fetchLiveFeedback(feedbackUrl, feedbackToken) .then((response) => { - // if 200, go straight to LIVE_FEEDBACK_SUCCESS if (response.status === 200) { - dispatch({ - type: actionTypes.LIVE_FEEDBACK_SUCCESS, - payload: { - questionId, - answerId, - feedbackFiles: response.data?.data?.feedbackFiles ?? {}, - }, + handleFeedbackOKResponse({ + dispatch, + response, + answerId, + questionId, + successMessage, + noFeedbackMessage, }); } }) 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 2c3319c626c..b3508607794 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 @@ -1,5 +1,6 @@ import { useRef, useState } from 'react'; import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; +import { defineMessages } from 'react-intl'; import { Close, ThumbDown, ThumbUp } from '@mui/icons-material'; import { Box, @@ -8,6 +9,7 @@ import { CardContent, Drawer, IconButton, + Tooltip, Typography, } from '@mui/material'; import { green, grey, orange, red, yellow } from '@mui/material/colors'; @@ -32,19 +34,25 @@ const styles = { card: { marginBottom: 1, borderStyle: 'solid', - borderWidth: 0.2, + borderWidth: 1.0, borderColor: grey[400], borderRadius: 2, + boxShadow: 'none', minWidth: '300px', maxWidth: '300px', }, - header: { + cardActions: { + px: 0, + paddingTop: 0.5, + paddingBottom: 0, display: 'flex', - backgroundColor: orange[100], - borderRadius: 2, - padding: 1, - borderBottomLeftRadius: 0, - borderBottomRightRadius: 0, + }, + cardContent: { + px: 1, + paddingTop: 0, + '&:last-child': { + paddingBottom: 1, + }, }, cardSelected: { backgroundColor: yellow[100], @@ -53,15 +61,53 @@ const styles = { backgroundColor: orange.A100, }, cardResolved: { - opacity: 0.6, - backgroundColor: green['100'], + borderColor: '#cecece', + backgroundColor: green[100], + color: grey[600], }, cardDismissed: { - opacity: 0.6, - backgroundColor: red['100'], + borderColor: '#cecece', + backgroundColor: red[100], + color: grey[600], + }, + cardActionButton: { + opacity: 1.0, + marginX: -0.5, + padding: 0.4, + transform: 'scale(0.86)', + transformOrigin: 'right', + }, + cardActionButtonHighlightOnResolve: { + '&:disabled': { + color: green.A700, + }, + }, + cardActionButtonHighlightOnDismiss: { + '&:disabled': { + color: red.A700, + }, }, }; +const translations = defineMessages({ + lineHeader: { + id: 'course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemLineHeading', + defaultMessage: 'Line {linenum}', + }, + likeItem: { + id: 'course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemLike', + defaultMessage: 'Like', + }, + dislikeItem: { + id: 'course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemDislike', + defaultMessage: 'Dislike', + }, + deleteItem: { + id: 'course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemDelete', + defaultMessage: 'Dismiss', + }, +}); + const ProgrammingFiles = ({ readOnly, questionId, @@ -95,32 +141,6 @@ const ProgrammingFiles = ({ } }; - // const editorKeyboardHandler = { - // handleKeyboard: (data, hash, keyString) => { - // const selectedRow = editorRef.current?.editor?.selection?.cursor?.row; - // const lastRow = - // (editorRef.current?.editor?.session?.getLength() ?? 1) - 1; - // if (selectedRow || selectedRow === 0) { - // if (keyString === 'up') { - // setSelectedLine(Math.max(selectedRow - 1, 0) + 1); - // } else if (keyString === 'down') { - // setSelectedLine(Math.min(selectedRow + 1, lastRow) + 1); - // } - // } - // }, - // }; - - // useEffect(() => { - // editorRef.current?.editor?.keyBinding?.addKeyboardHandler( - // editorKeyboardHandler, - // ); - // return () => { - // editorRef.current?.editor?.keyBinding?.removeKeyboardHandler( - // editorKeyboardHandler, - // ); - // }; - // }); - const renderFeedbackCard = (feedbackItem) => { let cardStyle = styles.card; if (feedbackItem.state === 'resolved') { @@ -131,6 +151,22 @@ const ProgrammingFiles = ({ cardStyle = { ...styles.card, ...styles.cardSelected }; } + const feedbackTooltipProps = { + placement: 'top', + slotProps: { + popper: { + modifiers: [ + { + name: 'offset', + options: { + offset: [0, -12], + }, + }, + ], + }, + }, + }; + const focusEditorOnFeedbackLine = () => { editorRef.current?.editor?.gotoLine(feedbackItem.linenum, 0); editorRef.current?.editor?.selection?.setAnchor( @@ -144,85 +180,94 @@ const ProgrammingFiles = ({ editorRef.current?.editor?.focus(); }; + const renderLikeButton = () => ( + + { + dispatch({ + type: actionTypes.LIVE_FEEDBACK_ITEM_MARK_RESOLVED, + payload: { + questionId, + path: 'main.py', + lineId: feedbackItem.id, + }, + }); + }} + sx={{ + ...styles.cardActionButton, + ...styles.cardActionButtonHighlightOnResolve, + }} + > + + + + ); + + const renderDislikeButton = () => ( + + { + dispatch({ + type: actionTypes.LIVE_FEEDBACK_ITEM_MARK_DISMISSED, + payload: { + questionId, + path: 'main.py', + lineId: feedbackItem.id, + }, + }); + }} + sx={{ + ...styles.cardActionButton, + ...styles.cardActionButtonHighlightOnDismiss, + }} + > + + + + ); + + const renderDeleteButton = () => ( + + { + dispatch({ + type: actionTypes.LIVE_FEEDBACK_ITEM_DELETE, + payload: { + questionId, + path: 'main.py', + lineId: feedbackItem.id, + }, + }); + }} + sx={{ ...styles.cardActionButton, marginRight: 1 }} + > + + + + ); + return ( - - {feedbackItem.feedback} - - L{feedbackItem.linenum} + {t(translations.lineHeader, { linenum: feedbackItem.linenum })} - {feedbackItem.state === 'resolved' && ( - - {t({ - id: 'course.assessment.submission.answers.Programming.liveFeedbackItemResolved', - defaultMessage: 'Item resolved.', - })} - - )} - {feedbackItem.state === 'dismissed' && ( - - {t({ - id: 'course.assessment.submission.answers.Programming.liveFeedbackItemDismissed', - defaultMessage: 'Item dismissed.', - })} - - )} - { - // TODO: expose BE route to Codaveri feedback rating endpoint and call here - dispatch({ - type: actionTypes.LIVE_FEEDBACK_ITEM_MARK_RESOLVED, - payload: { - questionId, - path: 'main.py', - lineId: feedbackItem.id, - }, - }); - }} - size="small" - > - - - { - dispatch({ - type: actionTypes.LIVE_FEEDBACK_ITEM_MARK_DISMISSED, - payload: { - questionId, - path: 'main.py', - lineId: feedbackItem.id, - }, - }); - }} - size="small" - > - - - { - dispatch({ - type: actionTypes.LIVE_FEEDBACK_ITEM_DELETE, - payload: { - questionId, - path: 'main.py', - lineId: feedbackItem.id, - }, - }); - }} - size="small" - > - - + {renderLikeButton()} + {renderDislikeButton()} + {renderDeleteButton()} + + {feedbackItem.feedback} + ); }; @@ -249,9 +294,7 @@ const ProgrammingFiles = ({ annotations = feedbackFiles['main.py'] ?? []; } const keyString = `editor-container-${index}`; - const shouldOpenDrawer = annotations?.some( - (feedbackItem) => feedbackItem.state === 'pending', - ); + const shouldOpenDrawer = annotations.length > 0; return (
@@ -275,7 +318,14 @@ const ProgrammingFiles = ({ style: { alignContent: 'start', position: 'absolute' }, }} open={shouldOpenDrawer} - PaperProps={{ style: { position: 'absolute' } }} + PaperProps={{ + style: { + position: 'absolute', + width: '315px', + alignContent: 'start', + border: 0, + }, + }} variant="persistent" >
{annotations.map(renderFeedbackCard)}
@@ -322,7 +372,6 @@ const Programming = (props) => { saveAnswerAndUpdateClientVersion={saveAnswerAndUpdateClientVersion} /> )} - {/* */}
); diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditStepForm.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditStepForm.jsx index a2a845caf73..a13dc2312ad 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditStepForm.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditStepForm.jsx @@ -577,20 +577,6 @@ const SubmissionEditStepForm = (props) => { /> ); - const renderProgrammingQuestionActions = () => { - const id = questionIds[stepIndex]; - const question = questions[id]; - - return ( -
- - {isCodaveriEnabled && - question.isCodaveri && - renderGetLiveFeedbackButton()} -
- ); - }; - const renderStepQuestion = () => { const id = questionIds[stepIndex]; const question = questions[id]; @@ -613,15 +599,17 @@ const SubmissionEditStepForm = (props) => { }} /> {attempting && ( - <> +
{renderResetButton()} {renderSubmitButton()} {renderContinueButton()} - + + {question.type === questionTypes.Programming && + isCodaveriEnabled && + question.isCodaveri && + renderGetLiveFeedbackButton()} +
)} - {attempting && - question.type === questionTypes.Programming && - renderProgrammingQuestionActions()} {renderAutogradingErrorPanel(id)} {renderExplanationPanel(question)} diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx index a4376f89009..fbcf31af419 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx @@ -1,5 +1,5 @@ import { Component } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, injectIntl } from 'react-intl'; import { connect } from 'react-redux'; import InsertDriveFile from '@mui/icons-material/InsertDriveFile'; import { @@ -204,17 +204,33 @@ class VisibleSubmissionEditIndex extends Component { }; onFetchLiveFeedback = (answerId, questionId) => { - const { dispatch, liveFeedback } = this.props; + const { + intl, + dispatch, + liveFeedback, + assessment: { questionIds }, + } = this.props; - const feedbackRequestToken = + const feedbackToken = liveFeedback?.feedbackByQuestion?.[questionId].pendingFeedbackToken; + const questionIndex = questionIds.findIndex((id) => id === questionId) + 1; + const successMessage = intl.formatMessage( + translations.liveFeedbackSuccess, + { questionIndex }, + ); + const noFeedbackMessage = intl.formatMessage( + translations.liveFeedbackNoneGenerated, + { questionIndex }, + ); dispatch( - fetchLiveFeedback( + fetchLiveFeedback({ answerId, questionId, - liveFeedback?.feedbackUrl, - feedbackRequestToken, - ), + feedbackUrl: liveFeedback?.feedbackUrl, + feedbackToken, + successMessage, + noFeedbackMessage, + }), ); }; @@ -229,10 +245,30 @@ class VisibleSubmissionEditIndex extends Component { onGenerateLiveFeedback = (answerId, questionId) => { const { dispatch, + intl, + assessment: { questionIds }, match: { params }, } = this.props; + const questionIndex = questionIds.findIndex((id) => id === questionId) + 1; + const successMessage = intl.formatMessage( + translations.liveFeedbackSuccess, + { questionIndex }, + ); + const noFeedbackMessage = intl.formatMessage( + translations.liveFeedbackNoneGenerated, + { questionIndex }, + ); + dispatch(initializeLiveFeedback(questionId)); - dispatch(generateLiveFeedback(params.submissionId, answerId, questionId)); + dispatch( + generateLiveFeedback({ + submissionId: params.submissionId, + answerId, + questionId, + successMessage, + noFeedbackMessage, + }), + ); }; allConsideredCorrect() { @@ -495,6 +531,7 @@ VisibleSubmissionEditIndex.propTypes = { questions: PropTypes.objectOf(questionShape), historyAnswers: PropTypes.objectOf(answerShape), historyQuestions: PropTypes.objectOf(historyQuestionShape), + intl: PropTypes.object.isRequired, questionsFlags: PropTypes.objectOf(questionFlagsShape), submission: submissionShape, topics: PropTypes.objectOf(topicShape), @@ -539,7 +576,9 @@ function mapStateToProps({ assessments: { submission } }) { const handle = assessmentsTranslations.attempt; const SubmissionEditIndex = withRouter( - withHeartbeatWorker(connect(mapStateToProps)(VisibleSubmissionEditIndex)), + withHeartbeatWorker( + connect(mapStateToProps)(injectIntl(VisibleSubmissionEditIndex)), + ), ); export default Object.assign(SubmissionEditIndex, { handle }); diff --git a/client/app/bundles/course/assessment/submission/translations.ts b/client/app/bundles/course/assessment/submission/translations.ts index 96c8f2e6e3e..33badc10173 100644 --- a/client/app/bundles/course/assessment/submission/translations.ts +++ b/client/app/bundles/course/assessment/submission/translations.ts @@ -355,6 +355,15 @@ const translations = defineMessages({ Try submitting your code again in a couple of minutes \ or check the error message in the network response.', }, + liveFeedbackNoneGenerated: { + id: 'course.assessment.submission.liveFeedbackNoneGenerated', + defaultMessage: 'Question {questionIndex}: No feedback generated.', + }, + liveFeedbackSuccess: { + id: 'course.assessment.submission.liveFeedbackSuccess', + defaultMessage: + 'Question {questionIndex}: Feedback successfully generated.', + }, autogradeSubmissionSuccess: { id: 'course.assessment.submission.autogradeSubmissionSuccess', defaultMessage: 'All answers have been evaluated.', diff --git a/client/locales/en.json b/client/locales/en.json index fc63af7882f..38a944114a1 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -2795,12 +2795,6 @@ "course.assessment.submission.answers.ForumPostResponse.TopicCard.viewTopicInNewTab": { "defaultMessage": "View Topic" }, - "course.assessment.submission.answers.Programming.liveFeedbackItemDismissed": { - "defaultMessage": "Item dismissed." - }, - "course.assessment.submission.answers.Programming.liveFeedbackItemResolved": { - "defaultMessage": "Item resolved." - }, "course.assessment.submission.answers.Programming.ProgrammingFile.downloadFile": { "defaultMessage": "Download File" }, @@ -2831,6 +2825,12 @@ "course.assessment.submission.codaveriAutogradeFailure": { "defaultMessage": "There is an error while evaluating your code in Codaveri. Try submitting your code again in a couple of minutes or check the error message in the network response." }, + "course.assessment.submission.liveFeedbackNoneGenerated": { + "defaultMessage": "Question {questionIndex}: No feedback generated." + }, + "course.assessment.submission.liveFeedbackSuccess": { + "defaultMessage": "Question {questionIndex}: Feedback successfully generated." + }, "course.assessment.submission.comment.CodaveriCommentCard.finalise": { "defaultMessage": "Finalise and Post Feedback" }, @@ -2873,6 +2873,18 @@ "course.assessment.submission.comments": { "defaultMessage": "Comments" }, + "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemDelete": { + "defaultMessage": "Dismiss" + }, + "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemDislike": { + "defaultMessage": "Dislike" + }, + "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemLike": { + "defaultMessage": "Like" + }, + "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemLineHeading": { + "defaultMessage": "Line {linenum}" + }, "course.assessment.submission.continue": { "defaultMessage": "Continue" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 444517bf3fa..0673adafd48 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -2768,12 +2768,6 @@ "course.assessment.submission.answers.ForumPostResponse.TopicCard.viewTopicInNewTab": { "defaultMessage": "查看主题" }, - "course.assessment.submission.answers.Programming.liveFeedbackItemDismissed": { - "defaultMessage": "项目已忽略。" - }, - "course.assessment.submission.answers.Programming.liveFeedbackItemResolved": { - "defaultMessage": "项目已解决。" - }, "course.assessment.submission.answers.Programming.ProgrammingFile.downloadFile": { "defaultMessage": "下载文件" }, @@ -2804,6 +2798,12 @@ "course.assessment.submission.codaveriAutogradeFailure": { "defaultMessage": "(T_T) 抱歉,codaveri 自动打分气罢工了。尝试在几分钟后再次提交你的代码或检查网络响应中的错误消息。" }, + "course.assessment.submission.liveFeedbackNoneGenerated": { + "defaultMessage": "问题 {questionIndex}:未生成反馈。" + }, + "course.assessment.submission.liveFeedbackSuccess": { + "defaultMessage": "问题 {questionIndex}:反馈生成成功。" + }, "course.assessment.submission.comment.CodaveriCommentCard.finalise": { "defaultMessage": "完成并发布反馈" }, @@ -2846,6 +2846,18 @@ "course.assessment.submission.comments": { "defaultMessage": "注释" }, + "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemDelete": { + "defaultMessage": "忽略" + }, + "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemDislike": { + "defaultMessage": "不喜欢" + }, + "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemLike": { + "defaultMessage": "喜欢" + }, + "course.assessment.submission.answers.Programming.ProgrammingFiles.liveFeedbackItemLineHeading": { + "defaultMessage": "线 {linenum}" + }, "course.assessment.submission.continue": { "defaultMessage": "继续" },