diff --git a/playground/data/expert_evaluation/.gitkeep b/playground/data/expert_evaluation/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/playground/package-lock.json b/playground/package-lock.json index 8a5bd8bf7..400348ddf 100644 --- a/playground/package-lock.json +++ b/playground/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "dependencies": { "@blueprintjs/core": "5.5.1", + "@fortawesome/fontawesome-svg-core": "6.6.0", + "@fortawesome/free-solid-svg-icons": "6.6.0", + "@fortawesome/react-fontawesome": "0.2.2", "@ls1intum/apollon": "3.3.5", "@monaco-editor/react": "4.6.0", "@radix-ui/react-collapsible": "1.0.3", @@ -23,10 +26,10 @@ "jszip": "3.10.1", "monaco-editor": "0.44.0", "next": "14.1.1", - "postcss": "8.4.31", "react": "18.2.0", "react-complex-tree": "2.2.2", "react-complex-tree-blueprintjs-renderers": "2.2.2", + "react-confetti": "6.1.0", "react-dom": "18.2.0", "react-full-screen": "1.1.1", "react-markdown": "9.0.0", @@ -747,6 +750,48 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", + "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", + "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", + "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", + "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -5923,9 +5968,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "funding": [ { "type": "github", @@ -5990,6 +6035,33 @@ } } }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -6375,9 +6447,9 @@ "integrity": "sha512-5yHVB9OHqKd9fr/OIsn8ss0NgThQ9buaqrEuwr9Or5YjPp6h+WTDKWZI+xZLaBGZCtODTnFtlSHNmhFsq67THg==" }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -6407,9 +6479,9 @@ } }, "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "funding": [ { "type": "opencollective", @@ -6425,9 +6497,9 @@ } ], "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -6659,6 +6731,20 @@ "resolved": "https://registry.npmjs.org/react-complex-tree-blueprintjs-renderers/-/react-complex-tree-blueprintjs-renderers-2.2.2.tgz", "integrity": "sha512-6VRIjYhMcxLB3HxzpGLnz2sRHM50YpMLuIAutPpbByjYW5RVsB1EUPvtk7ZO4rhj5MLFzRhqHgCt1exFGZJ/Sw==" }, + "node_modules/react-confetti": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.1.0.tgz", + "integrity": "sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==", + "dependencies": { + "tween-functions": "^1.2.0" + }, + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "react": "^16.3.0 || ^17.0.1 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -7392,9 +7478,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -7904,6 +7990,11 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/tween-functions": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tween-functions/-/tween-functions-1.2.0.tgz", + "integrity": "sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/playground/package.json b/playground/package.json index c15b46272..fda7a83fa 100644 --- a/playground/package.json +++ b/playground/package.json @@ -14,6 +14,9 @@ }, "dependencies": { "@blueprintjs/core": "5.5.1", + "@fortawesome/fontawesome-svg-core": "6.6.0", + "@fortawesome/free-solid-svg-icons": "6.6.0", + "@fortawesome/react-fontawesome": "0.2.2", "@ls1intum/apollon": "3.3.5", "@monaco-editor/react": "4.6.0", "@radix-ui/react-collapsible": "1.0.3", @@ -31,6 +34,7 @@ "react": "18.2.0", "react-complex-tree": "2.2.2", "react-complex-tree-blueprintjs-renderers": "2.2.2", + "react-confetti": "6.1.0", "react-dom": "18.2.0", "react-full-screen": "1.1.1", "react-markdown": "9.0.0", diff --git a/playground/src/assets/evaluation_backgrounds/congratulations.jpeg b/playground/src/assets/evaluation_backgrounds/congratulations.jpeg new file mode 100644 index 000000000..18f136431 Binary files /dev/null and b/playground/src/assets/evaluation_backgrounds/congratulations.jpeg differ diff --git a/playground/src/assets/evaluation_backgrounds/exercise.jpeg b/playground/src/assets/evaluation_backgrounds/exercise.jpeg new file mode 100644 index 000000000..d2f7f16ac Binary files /dev/null and b/playground/src/assets/evaluation_backgrounds/exercise.jpeg differ diff --git a/playground/src/assets/evaluation_backgrounds/save-progress.jpeg b/playground/src/assets/evaluation_backgrounds/save-progress.jpeg new file mode 100644 index 000000000..cf40d481d Binary files /dev/null and b/playground/src/assets/evaluation_backgrounds/save-progress.jpeg differ diff --git a/playground/src/assets/evaluation_backgrounds/welcome-screen.jpeg b/playground/src/assets/evaluation_backgrounds/welcome-screen.jpeg new file mode 100644 index 000000000..8ce600884 Binary files /dev/null and b/playground/src/assets/evaluation_backgrounds/welcome-screen.jpeg differ diff --git a/playground/src/assets/evaluation_tutorial/continue_later.gif b/playground/src/assets/evaluation_tutorial/continue_later.gif new file mode 100644 index 000000000..eb17d4abe Binary files /dev/null and b/playground/src/assets/evaluation_tutorial/continue_later.gif differ diff --git a/playground/src/assets/evaluation_tutorial/evaluate_metrics.gif b/playground/src/assets/evaluation_tutorial/evaluate_metrics.gif new file mode 100644 index 000000000..f742c5f0c Binary files /dev/null and b/playground/src/assets/evaluation_tutorial/evaluate_metrics.gif differ diff --git a/playground/src/assets/evaluation_tutorial/exercise_details.gif b/playground/src/assets/evaluation_tutorial/exercise_details.gif new file mode 100644 index 000000000..be056f5fe Binary files /dev/null and b/playground/src/assets/evaluation_tutorial/exercise_details.gif differ diff --git a/playground/src/assets/evaluation_tutorial/metrics-explanation.gif b/playground/src/assets/evaluation_tutorial/metrics-explanation.gif new file mode 100644 index 000000000..5a3f37d53 Binary files /dev/null and b/playground/src/assets/evaluation_tutorial/metrics-explanation.gif differ diff --git a/playground/src/assets/evaluation_tutorial/read_submission.gif b/playground/src/assets/evaluation_tutorial/read_submission.gif new file mode 100644 index 000000000..2636f751a Binary files /dev/null and b/playground/src/assets/evaluation_tutorial/read_submission.gif differ diff --git a/playground/src/assets/evaluation_tutorial/view-next.gif b/playground/src/assets/evaluation_tutorial/view-next.gif new file mode 100644 index 000000000..38c375acb Binary files /dev/null and b/playground/src/assets/evaluation_tutorial/view-next.gif differ diff --git a/playground/src/components/details/editor/file_editor.tsx b/playground/src/components/details/editor/file_editor.tsx index d75f33e74..7a7b85a69 100644 --- a/playground/src/components/details/editor/file_editor.tsx +++ b/playground/src/components/details/editor/file_editor.tsx @@ -25,6 +25,7 @@ type FileEditorProps = { createNewFeedback?: () => Feedback; manualRatings?: ManualRating[]; onManualRatingsChange?: (manualRatings: ManualRating[]) => void; + hideFeedbackDetails?: boolean; }; export default function FileEditor({ @@ -38,6 +39,7 @@ export default function FileEditor({ createNewFeedback, manualRatings, onManualRatingsChange, + hideFeedbackDetails }: FileEditorProps) { const monaco = useMonaco(); const editorRef = useRef(); @@ -358,6 +360,7 @@ export default function FileEditor({ } model={model} className="mr-4" + hideDetails={hideFeedbackDetails} /> ) diff --git a/playground/src/components/details/editor/inline_feedback.tsx b/playground/src/components/details/editor/inline_feedback.tsx index 7e5b16d29..764471e74 100644 --- a/playground/src/components/details/editor/inline_feedback.tsx +++ b/playground/src/components/details/editor/inline_feedback.tsx @@ -19,6 +19,7 @@ type InlineFeedbackProps = { onManualRatingChange?: (manualRating: ManualRating) => void; model?: editor.ITextModel; className?: string; + hideDetails?: boolean; }; export default function InlineFeedback({ @@ -28,6 +29,7 @@ export default function InlineFeedback({ onManualRatingChange, model, className, + hideDetails, }: InlineFeedbackProps) { const [isEditing, setIsEditing] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); @@ -145,29 +147,31 @@ export default function InlineFeedback({ onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} > -
-
- {referenceType === "unreferenced" && "Unreferenced"} - {"file_path" in feedback && - referenceType === "unreferenced_file" && - `References ${feedback.file_path}`} - {referenceType === "referenced" && - `References ${formatReference(feedback)}`} -
-
- {feedback.structured_grading_instruction_id && ( - - Grading Instruction  - {feedback.structured_grading_instruction_id} - - )} - {feedback.isSuggestion && ( - - Suggestion - - )} + {hideDetails ? null : ( +
+
+ {referenceType === "unreferenced" && "Unreferenced"} + {"file_path" in feedback && + referenceType === "unreferenced_file" && + `References ${feedback.file_path}`} + {referenceType === "referenced" && + `References ${formatReference(feedback)}`} +
+
+ {feedback.structured_grading_instruction_id && ( + + Grading Instruction  + {feedback.structured_grading_instruction_id} + + )} + {feedback.isSuggestion && ( + + Suggestion + + )} +
-
+ )}
{isEditing && onFeedbackChange ? ( { switch (exercise.type) { case "text": - return ; + return ; case "programming": return ; default: @@ -29,7 +29,7 @@ export default function ExerciseDetail({ })(); return hideDisclosure ? ( -
+
{specificExerciseDetail}
@@ -40,7 +40,7 @@ export default function ExerciseDetail({ openedInitially={openedInitially} > <> - + {specificExerciseDetail} diff --git a/playground/src/components/details/submission_detail/text.tsx b/playground/src/components/details/submission_detail/text.tsx index d461f10dc..8d032ef7f 100644 --- a/playground/src/components/details/submission_detail/text.tsx +++ b/playground/src/components/details/submission_detail/text.tsx @@ -14,6 +14,7 @@ type TextSubmissionDetailProps = { onFeedbacksChange?: (feedback: Feedback[]) => void; manualRatings?: ManualRating[]; onManualRatingsChange?: (manualRatings: ManualRating[]) => void; + hideFeedbackDetails?: boolean; }; export default function TextSubmissionDetail({ @@ -23,6 +24,7 @@ export default function TextSubmissionDetail({ onFeedbacksChange, manualRatings, onManualRatingsChange, + hideFeedbackDetails, }: TextSubmissionDetailProps) { const unreferencedFeedbacks = feedbacks?.filter( (feedback) => getFeedbackReferenceType(feedback) === "unreferenced" @@ -41,6 +43,7 @@ export default function TextSubmissionDetail({ manualRatings={manualRatings} onManualRatingsChange={onManualRatingsChange} createNewFeedback={() => createNewFeedback(submission)} + hideFeedbackDetails={hideFeedbackDetails} />
{((unreferencedFeedbacks && unreferencedFeedbacks.length > 0) || @@ -64,6 +67,7 @@ export default function TextSubmissionDetail({ onManualRatingsChange && createManualRatingItemUpdater(feedback.id, manualRatings, onManualRatingsChange) } + hideDetails={hideFeedbackDetails} /> ) )} diff --git a/playground/src/components/expert_evaluation/expert_evaluation_buttons.tsx b/playground/src/components/expert_evaluation/expert_evaluation_buttons.tsx new file mode 100644 index 000000000..7d92b7ffd --- /dev/null +++ b/playground/src/components/expert_evaluation/expert_evaluation_buttons.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCircleInfo } from '@fortawesome/free-solid-svg-icons'; + +const buttonBase = "px-4 py-2 rounded focus:outline-none transition-all"; +const buttonPrimary = `${buttonBase} bg-blue-500 text-white hover:bg-blue-600`; +const buttonSecondary = `${buttonBase} bg-gray-300 text-gray-700 hover:bg-gray-400`; +const buttonFinish = `${buttonBase} bg-green-600 text-white hover:bg-green-700`; + + +interface NextButtonProps { + onClick?: () => void; + isFinish?: boolean; + isInline?: boolean; + className?: string; +} + +export function NextButton(nextButtonProps: NextButtonProps) { + const { onClick, isFinish, isInline, className } = nextButtonProps; + return +} + + +interface SecondaryButtonProps { + onClick?: () => void; + isInline?: boolean; + className?: string; + text: string; + isDisabled?: boolean; +} + +export function SecondaryButton(secondaryButtonProps: SecondaryButtonProps) { + const { onClick, isInline, className, text, isDisabled } = secondaryButtonProps; + return +} + + +interface PrimaryButtonProps { + onClick?: () => void; + isInline?: boolean; + isDisabled?: boolean, + className?: string; + text: string; +} + +export function PrimaryButton(primaryButtonProps: PrimaryButtonProps) { + const { onClick, isInline, isDisabled, className, text } = primaryButtonProps; + return +} + + +interface InfoIconButtonProps { + onClick?: () => void; + className?: string; +} + +export function InfoIconButton(infoIconButtonProps: InfoIconButtonProps) { + const { onClick, className } = infoIconButtonProps; + return + + +} diff --git a/playground/src/components/expert_evaluation/expert_view/congratulation_screen.tsx b/playground/src/components/expert_evaluation/expert_view/congratulation_screen.tsx new file mode 100644 index 000000000..b00a5e3ca --- /dev/null +++ b/playground/src/components/expert_evaluation/expert_view/congratulation_screen.tsx @@ -0,0 +1,44 @@ +import React, { useEffect, useState } from 'react'; +import Confetti from 'react-confetti'; +import background_image from "@/assets/evaluation_backgrounds/congratulations.jpeg"; + +export default function CongratulationScreen() { + const [windowSize, setWindowSize] = useState({ width: window.innerWidth, height: window.innerHeight }); + + useEffect(() => { + const handleResize = () => { + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight + }); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return ( +
+ {/* Confetti effect */} + + +
+

Congratulations!

+

+ You have successfully completed the expert evaluation. Thank you for your hard work! +

+
+
+ ); +}; diff --git a/playground/src/components/expert_evaluation/expert_view/continue_later_screen.tsx b/playground/src/components/expert_evaluation/expert_view/continue_later_screen.tsx new file mode 100644 index 000000000..f15e37436 --- /dev/null +++ b/playground/src/components/expert_evaluation/expert_view/continue_later_screen.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import background_image from "@/assets/evaluation_backgrounds/save-progress.jpeg"; +import { PrimaryButton } from "@/components/expert_evaluation/expert_evaluation_buttons"; + + +interface ContinueLaterScreenProps { + onClose: () => void; +} + +export default function ContinueLaterScreen(continueLaterScreenProps: ContinueLaterScreenProps) { + const { onClose } = continueLaterScreenProps; + + return ( +
+
+

Continue Later

+

+ Your progress has been saved. You can continue the evaluation later using the same link. +

+ +
+
+ ); +}; diff --git a/playground/src/components/expert_evaluation/expert_view/exercise_detail_popup.tsx b/playground/src/components/expert_evaluation/expert_view/exercise_detail_popup.tsx new file mode 100644 index 000000000..b182996e1 --- /dev/null +++ b/playground/src/components/expert_evaluation/expert_view/exercise_detail_popup.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Popup from "@/components/expert_evaluation/expert_view/popup"; +import ExerciseDetail from "@/components/details/exercise_detail"; +import { Exercise } from "@/model/exercise"; + +interface ExerciseDetailPopupProps { + isOpen: boolean; + onClose: () => void; + exercise: Exercise; +} + +export default function ExerciseDetailPopup(exerciseDetailPopupProps: ExerciseDetailPopupProps) { + const { isOpen, onClose, exercise } = exerciseDetailPopupProps; + + return ( + + + + ); +}; diff --git a/playground/src/components/expert_evaluation/expert_view/exercise_screen.tsx b/playground/src/components/expert_evaluation/expert_view/exercise_screen.tsx new file mode 100644 index 000000000..4b7e21d6d --- /dev/null +++ b/playground/src/components/expert_evaluation/expert_view/exercise_screen.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import ExerciseDetailPopup from "@/components/expert_evaluation/expert_view/exercise_detail_popup"; +import { Exercise } from "@/model/exercise"; +import { SecondaryButton } from "@/components/expert_evaluation/expert_evaluation_buttons"; +import background_image from "@/assets/evaluation_backgrounds/exercise.jpeg"; + +interface ExerciseScreenProps { + onCloseExerciseDetail: () => void; + onOpenContinueLater: () => void; + exercise: Exercise; + currentExerciseIndex: number; + totalExercises: number; +} + +export default function ExerciseScreen(exerciseScreenProps: ExerciseScreenProps) { + const { + onCloseExerciseDetail, + onOpenContinueLater, + exercise, + currentExerciseIndex, + totalExercises, + } = exerciseScreenProps; + const [isExerciseDetailOpen, setExerciseDetailOpen] = useState(false); + + const closeExerciseDetail = () => { + setExerciseDetailOpen(false); + onCloseExerciseDetail(); + } + + return ( +
+
+

You have + evaluated {currentExerciseIndex}/{totalExercises} exercises!

+

+ Great job! Feel free to take a break and + continue later. Your progress has been saved. +

+

+ When you are ready, continue with the next exercise: {exercise.title}. +

+
+ + + setExerciseDetailOpen(true)} + text="📄 Exercise Details" + className="ml-4 px-6 py-3" + /> +
+
+ {/* ExerciseDetailPopup that shows up when isExerciseDetailOpen is true */} + +
+ ); +}; diff --git a/playground/src/components/expert_evaluation/expert_view/likert_scale.tsx b/playground/src/components/expert_evaluation/expert_view/likert_scale.tsx new file mode 100644 index 000000000..675fcee52 --- /dev/null +++ b/playground/src/components/expert_evaluation/expert_view/likert_scale.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState } from 'react'; +import Popup from "@/components/expert_evaluation/expert_view/popup"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import { InfoIconButton } from "@/components/expert_evaluation/expert_evaluation_buttons"; + +interface SingleChoiceLikertScaleProps { + title: string; + summary: string; + description: string; + passedValue: number | null; + onLikertChange: (value: number) => void; + isHighlighted: boolean; +} + +export default function SingleChoiceLikertScale(singleChoiceLikertScale: SingleChoiceLikertScaleProps) { + const { + title, + summary, + description, + passedValue, + onLikertChange, + isHighlighted, + } = singleChoiceLikertScale; + const [selectedValue, setSelectedValue] = useState(null); + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const handleInfoClick = () => { + setIsPopupOpen(true); + }; + + useEffect(() => { + setSelectedValue(passedValue); + }, [passedValue]); + + const handleChange = (value: number) => { + setSelectedValue(value); + onLikertChange(value); + }; + + const closePopup = () => { + setIsPopupOpen(false); + }; + + const borderColors = [ + 'border-gray-500', // Not Applicable + 'border-red-600', // Strongly Disagree + 'border-orange-500', // Disagree + 'border-yellow-400', // Neutral + 'border-green-400', // Agree + 'border-green-700', // Strongly Agree + ]; + + const selectedBgColors = [ + 'bg-gray-200', + 'bg-red-200', + 'bg-orange-200', + 'bg-yellow-200', + 'bg-green-200', + 'bg-green-300', + ]; + + const scaleLabels = [ + 'Not Ratable', + 'Strongly Disagree', + 'Disagree', + 'Neutral', + 'Agree', + 'Strongly Agree', + ]; + + return ( + <> + {/* Title and Info Section */} +
+

{title}

+ +
+ + + {description} + + + + {/* Summary */} +

{summary}

+ + {/* Single Choice Likert Scale */} +
+ {scaleLabels.map((label, index) => ( + + ))} +
+ + ); +}; diff --git a/playground/src/components/expert_evaluation/expert_view/likert_scale_form.tsx b/playground/src/components/expert_evaluation/expert_view/likert_scale_form.tsx new file mode 100644 index 000000000..1e33ab42e --- /dev/null +++ b/playground/src/components/expert_evaluation/expert_view/likert_scale_form.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import SingleChoiceLikertScale from "@/components/expert_evaluation/expert_view/likert_scale"; +import TextSubmissionDetail from "@/components/details/submission_detail/text"; +import type { TextSubmission } from "@/model/submission"; +import { CategorizedFeedback } from "@/model/feedback"; +import { Exercise } from "@/model/exercise"; +import { Metric } from "@/model/metric"; + +interface LikertScaleFormProps { + submission: TextSubmission; + exercise: Exercise; + feedback: CategorizedFeedback; + metrics: Metric[]; + selectedValues: { // Selected values for each exercise, submission, and feedback type + [exerciseId: string]: { + [submissionId: string]: { + [feedbackType: string]: { + [metricId: string]: number; // The Likert scale value for a metric + }; + }; + }; + }; + onLikertValueChange: (feedbackType: string, metricId: string, value: number) => void; + isMarkMissingValue: boolean +} + + +export default function LikertScaleForm(likertScaleFormProps: LikertScaleFormProps) { + const { + submission, + exercise, + feedback, + metrics, + selectedValues, + onLikertValueChange, + isMarkMissingValue, + } = likertScaleFormProps; + + if (!exercise || !submission) { + return
Loading...
; + } + + return ( +
+
+ {Object.entries(feedback).map(([feedbackType, feedbackList]) => ( +
+ {/* Render TextSubmissionDetail */} +
+ +
+ + {/* Render SingleChoiceLikertScale components */} +
+ {metrics.map((metric) => { + const selectedValue = + selectedValues?.[exercise.id]?.[submission.id]?.[feedbackType]?.[metric.id] ?? null; + const isHighlighted = isMarkMissingValue && (selectedValue === null); + + return ( +
+ + onLikertValueChange(feedbackType, metric.id, value) + } + isHighlighted={isHighlighted} + /> +
+ ); + })} +
+
+ ))} +
+
+ ); +}; diff --git a/playground/src/components/expert_evaluation/expert_view/popup.tsx b/playground/src/components/expert_evaluation/expert_view/popup.tsx new file mode 100644 index 000000000..f41c23aab --- /dev/null +++ b/playground/src/components/expert_evaluation/expert_view/popup.tsx @@ -0,0 +1,43 @@ +import React, { ReactNode } from 'react'; + +interface PopupProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + disableCloseOnOutsideClick?: boolean; +} + +export default function Popup(popupProps: PopupProps) { + const { isOpen, onClose, title, children, disableCloseOnOutsideClick } = popupProps; + if (!isOpen) return null; + + return ( +
{ + if (!disableCloseOnOutsideClick) { + onClose(); + } + }} + > +
e.stopPropagation()} + > +
+

{title}

+ +
+ {/* Scrollable content wrapper */} +
+ {children} +
+
+
+ ); +}; diff --git a/playground/src/components/expert_evaluation/expert_view/side_by_side_header.tsx b/playground/src/components/expert_evaluation/expert_view/side_by_side_header.tsx new file mode 100644 index 000000000..689e3e599 --- /dev/null +++ b/playground/src/components/expert_evaluation/expert_view/side_by_side_header.tsx @@ -0,0 +1,107 @@ +import React, { useState } from 'react'; +import Popup from "@/components/expert_evaluation/expert_view/popup"; +import { Metric } from "@/model/metric"; +import rehypeRaw from "rehype-raw"; +import ReactMarkdown from "react-markdown"; +import ExerciseDetailPopup from "@/components/expert_evaluation/expert_view/exercise_detail_popup"; +import TutorialPopup from "@/components/expert_evaluation/expert_view/tutorial_popup"; +import { SecondaryButton, NextButton, PrimaryButton } from "@/components/expert_evaluation/expert_evaluation_buttons"; + +type SideBySideHeaderProps = { + exercise: any; + globalSubmissionIndex: number; + totalSubmissions: number; + metrics: Metric[]; + onNext: () => void; + onPrevious: () => void; + onContinueLater: () => void; +} + +export default function SideBySideHeader(sideBySideHeaderProps: SideBySideHeaderProps) { + const { + exercise, + globalSubmissionIndex, + totalSubmissions, + metrics, + onNext, + onPrevious, + onContinueLater, + } = sideBySideHeaderProps; + const [isExerciseDetailOpen, setIsExerciseDetailOpen] = useState(false); + const [isMetricDetailOpen, setIsMetricDetailOpen] = useState(false); + const [isEvaluationTutorialOpen, setIsEvaluationTutorialOpen] = useState(false); + + const openExerciseDetail = () => setIsExerciseDetailOpen(true); + const closeExerciseDetail = () => setIsExerciseDetailOpen(false); + const openMetricDetail = () => setIsMetricDetailOpen(true); + const closeMetricDetail = () => setIsMetricDetailOpen(false); + const openEvaluationTutorial = () => setIsEvaluationTutorialOpen(true); + const closeEvaluationTutorial = () => setIsEvaluationTutorialOpen(false); + + if (!exercise) { + return
Loading...
; + } + + return ( +
{/* Sticky header */} + {/* Subtitle and Details Buttons Section */} +
+ + {/* Align heading to the top */} +
+

+ Evaluation: {exercise.title} +

+ + {/* Details Buttons */} +
+ + + + + +
+ {metrics.map((metric, index) => ( +
+

{metric.title}

+ + {metric.description} + +
+ ))} +
+
+ + + +
+
+ + {/* Align buttons to the end */} +
+ + + {/* Wrapping buttons to match the width */} +
+ + +
+
+
+ + {/* Progress Bar */} +
+
+
+ + {globalSubmissionIndex + 1} / {totalSubmissions} + +
+ ); +} diff --git a/playground/src/components/expert_evaluation/expert_view/tutorial_popup.tsx b/playground/src/components/expert_evaluation/expert_view/tutorial_popup.tsx new file mode 100644 index 000000000..06fcba90f --- /dev/null +++ b/playground/src/components/expert_evaluation/expert_view/tutorial_popup.tsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; +import Popup from "@/components/expert_evaluation/expert_view/popup"; +import exerciseDetails from "@/assets/evaluation_tutorial/exercise_details.gif"; +import readSubmission from "@/assets/evaluation_tutorial/read_submission.gif"; +import evaluateMetrics from "@/assets/evaluation_tutorial/evaluate_metrics.gif"; +import metricsExplanation from "@/assets/evaluation_tutorial/metrics-explanation.gif"; +import viewNext from "@/assets/evaluation_tutorial/view-next.gif"; +import continueLater from "@/assets/evaluation_tutorial/continue_later.gif"; +import { + InfoIconButton, + NextButton, + PrimaryButton, + SecondaryButton +} from "@/components/expert_evaluation/expert_evaluation_buttons"; +import { Exercise } from "@/model/exercise"; +import ExerciseDetail from "@/components/details/exercise_detail"; + +const baseTutorialSteps = [ + { + image: exerciseDetails.src, + description: ( + <> + 1. Read the + + + ), + }, + { + image: readSubmission.src, + description: "2. Read the Submission and the corresponding feedback" + }, + { + image: evaluateMetrics.src, + description: "3. Evaluate the feedback based on the metrics" + }, + { + image: metricsExplanation.src, + description: ( + <> + 4. If unsure what a metric means, press the + + or look at the + + + ), + }, + { + image: viewNext.src, + description: ( + <> + 5. After evaluating all metrics for all feedbacks, click on the + button to view the next submission. + ), + }, + { + image: continueLater.src, + description: ( + <> + 6. When you are ready to take a break, click on the + + + ), + }, +]; + +interface TutorialPopupProps { + isOpen: boolean; + onClose: () => void; + disableCloseOnOutsideClick?: boolean; + exercise?: Exercise; +} + +export default function TutorialPopup(tutorialPopupProps: TutorialPopupProps) { + const { isOpen, onClose, disableCloseOnOutsideClick, exercise } = tutorialPopupProps; + const [currentStep, setCurrentStep] = useState(0); + + // If the tutorial was opened in welcome window, add a last step involving the first exercise + const tutorialSteps = exercise + ? [ + ...baseTutorialSteps, + { + image: "", + description: ( + <> +
+ Now you are ready to start the evaluation! Read the description of the first exercise + + +
+ ), + }, + ] + : baseTutorialSteps; + + const handleNext = () => { + if (currentStep < tutorialSteps.length - 1) { + setCurrentStep(currentStep + 1); + } + }; + + const handlePrevious = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + const { image, description } = tutorialSteps[currentStep]; + + const isLastStep = currentStep === tutorialSteps.length - 1; + return ( + +
+ {/* Display the current GIF */} + {image && {`Tutorial} + + {/* Render the description directly, which may include text and button */} +
+ {description} +
+ + {/* Navigation buttons */} +
+ + {`${currentStep + 1} / ${tutorialSteps.length}`} + +
+
+
+ ); +}; diff --git a/playground/src/components/expert_evaluation/expert_view/welcome_screen.tsx b/playground/src/components/expert_evaluation/expert_view/welcome_screen.tsx new file mode 100644 index 000000000..dda857b74 --- /dev/null +++ b/playground/src/components/expert_evaluation/expert_view/welcome_screen.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import TutorialPopup from "@/components/expert_evaluation/expert_view/tutorial_popup"; +import background_image from "@/assets/evaluation_backgrounds/welcome-screen.jpeg"; +import { PrimaryButton } from "@/components/expert_evaluation/expert_evaluation_buttons"; +import { Exercise } from "@/model/exercise"; + +interface WelcomeScreenProps { + exercise: Exercise; + onClose: () => void; +} + +export default function WelcomeScreen(welcomeScreenProps: WelcomeScreenProps) { + const { exercise, onClose } = welcomeScreenProps; + const [isTutorialOpen, setTutorialOpen] = useState(false); + + const closeTutorial = () => { + setTutorialOpen(false); + onClose(); + } + + const startTutorial = () => { + setTutorialOpen(true); + } + + return ( +
+
+

Welcome to the Expert Evaluation!

+

+ Thank you for taking the time to participate. Your input is valuable to us, and we appreciate your + effort. +

+ +
+ +
+ ); +}; diff --git a/playground/src/components/selectors/evaluation_config_selector.tsx b/playground/src/components/selectors/evaluation_config_selector.tsx new file mode 100644 index 000000000..5f2e6df99 --- /dev/null +++ b/playground/src/components/selectors/evaluation_config_selector.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { ExpertEvaluationConfig } from "@/model/expert_evaluation_config"; + +type EvaluationConfigSelectorProps = { + selectedConfigId: string; + setSelectedConfigId: (id: string) => void; + expertEvaluationConfigs: ExpertEvaluationConfig[]; +}; + +export default function EvaluationConfigSelector(evaluationConfigSelectorProps: EvaluationConfigSelectorProps) { + const { selectedConfigId, setSelectedConfigId, expertEvaluationConfigs } = evaluationConfigSelectorProps; + return ( +
+ Evaluation + +
+ ); +}; diff --git a/playground/src/components/view_mode/evaluation_mode/expert_evaluation/evaluation_management.tsx b/playground/src/components/view_mode/evaluation_mode/expert_evaluation/evaluation_management.tsx new file mode 100644 index 000000000..fc9c57527 --- /dev/null +++ b/playground/src/components/view_mode/evaluation_mode/expert_evaluation/evaluation_management.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { downloadJSONFile } from "@/helpers/download"; +import { twMerge } from "tailwind-merge"; +import MetricsForm from "@/components/view_mode/evaluation_mode/expert_evaluation/metrics_form"; +import { ExpertEvaluationConfig } from "@/model/expert_evaluation_config"; +import EvaluationConfigSelector from "@/components/selectors/evaluation_config_selector"; +import { + EvaluationManagementExportImport +} from "@/components/view_mode/evaluation_mode/expert_evaluation/evaluation_management_export_import"; +import { + fetchAllExpertEvaluationConfigs, + saveExpertEvaluationConfig as externalSaveExpertEvaluationConfig +} from "@/hooks/playground/expert_evaluation_config"; +import ExpertLinks from "@/components/view_mode/evaluation_mode/expert_evaluation/expert_links"; +import ExerciseImport from "@/components/view_mode/evaluation_mode/expert_evaluation/exercise_import"; +import useDownloadExpertEvaluationData from "@/hooks/playground/expert_evaluation"; + +const createNewEvaluationConfig = (name = ""): ExpertEvaluationConfig => ({ + type: "evaluation_config", + id: "new", + name, + started: false, + creationDate: new Date(), + metrics: [], + exercises: [], + expertIds: [], +}); + +export default function EvaluationManagement() { + const [expertEvaluationConfigs, setExpertEvaluationConfigs] = useState([]); + const [selectedConfig, setSelectedConfig] = useState(createNewEvaluationConfig()); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const dataMode = "expert_evaluation"; + + useEffect(() => { + const fetchData = async () => { + const savedConfigs = await fetchAllExpertEvaluationConfigs(dataMode); + setExpertEvaluationConfigs(savedConfigs); + }; + fetchData(); + }, []); + + const handleSelectConfig = (id: string) => { + if (id === "new") { + setSelectedConfig(createNewEvaluationConfig()); + } else { + const existingConfig = expertEvaluationConfigs.find((config) => config.id === id); + if (existingConfig) { + setSelectedConfig(existingConfig); + } + } + setHasUnsavedChanges(false); + }; + + const updateSelectedConfig = (updatedFields: Partial): ExpertEvaluationConfig => { + const isUpdatingOnlyExpertIds = + Object.keys(updatedFields).length === 1 && updatedFields.hasOwnProperty('expertIds'); + const newConfig = { ...selectedConfig, ...updatedFields }; + + if (!selectedConfig.started || isUpdatingOnlyExpertIds) { + setSelectedConfig(newConfig); + setHasUnsavedChanges(true); + } + + return newConfig; + }; + + const saveExpertEvaluationConfig = (configToSave = selectedConfig) => { + const isNewConfig = configToSave.id === "new"; + const newConfig = isNewConfig ? { ...configToSave, id: uuidv4() } : configToSave; + setExpertEvaluationConfigs((prevConfigs) => { + const existingIndex = prevConfigs.findIndex((config) => config.id === newConfig.id); + if (existingIndex !== -1) { + const updatedConfigs = [...prevConfigs]; + updatedConfigs[existingIndex] = newConfig; + return updatedConfigs; + } else { + return [...prevConfigs, newConfig]; + } + }); + + setSelectedConfig(newConfig); + externalSaveExpertEvaluationConfig(dataMode, newConfig, isNewConfig); + setHasUnsavedChanges(false); + }; + + const handleExport = () => { + downloadJSONFile(`evaluation_config_${selectedConfig.name}_${selectedConfig.id}`, selectedConfig); + }; + + const handleImport = async (fileContent: string) => { + const importedConfig = JSON.parse(fileContent) as ExpertEvaluationConfig; + if (importedConfig.type !== "evaluation_config") { + alert("Invalid config type"); + return; + } + importedConfig.id = "new"; + importedConfig.creationDate = new Date(); + importedConfig.started = false; + importedConfig.expertIds = []; + setSelectedConfig(importedConfig); + }; + + const { mutate: downloadEvaluationData, isLoading: isExporting } = useDownloadExpertEvaluationData({ + onSuccess: (blob: Blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.style.display = "none"; + a.href = url; + a.download = `evaluation_${selectedConfig.id}.zip`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + }, + onError: (error) => { + console.error("Download failed:", error.message); + alert("An error occurred during download. Please try again later."); + }, + }); + + const startEvaluation = () => { + if (confirm("Are you sure you want to start the evaluation? Once started, you can add new expert links but no other changes can be made to the configuration!")) { + const updatedConfig = updateSelectedConfig({ started: true }); + saveExpertEvaluationConfig(updatedConfig); + } + }; + + const resetChanges = () => { + if (selectedConfig.id === "new") { + setSelectedConfig(createNewEvaluationConfig()); + } else { + const existingConfig = expertEvaluationConfigs.find((config) => config.id === selectedConfig.id); + if (existingConfig) { + setSelectedConfig(existingConfig); + } + } + setHasUnsavedChanges(false); + }; + + const inputDisabledStyle = selectedConfig.started + ? "bg-gray-100 text-gray-500 cursor-not-allowed" + : ""; + + return ( +
+
+

Manage Evaluations

+ +
+ + + + + + updateSelectedConfig({ exercises: newExercises })} + disabled={selectedConfig.started} + /> + + updateSelectedConfig({ metrics: newMetrics })} + disabled={selectedConfig.started} + /> + + updateSelectedConfig({ expertIds: newExpertIds })} + configId={selectedConfig.id} + started={selectedConfig.started} + /> + +
+ + + + + {selectedConfig.started && ( + + )} + + {selectedConfig.id !== "new" && !selectedConfig.started && ( + + )} +
+
+ ); +} diff --git a/playground/src/components/view_mode/evaluation_mode/expert_evaluation/evaluation_management_export_import.tsx b/playground/src/components/view_mode/evaluation_mode/expert_evaluation/evaluation_management_export_import.tsx new file mode 100644 index 000000000..f8ae50628 --- /dev/null +++ b/playground/src/components/view_mode/evaluation_mode/expert_evaluation/evaluation_management_export_import.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +type EvaluationManagementExportImportProps = { + definedExpertEvaluationConfig: any; + handleExport: () => void; + handleImport: (fileContent: string) => void; +}; + +export function EvaluationManagementExportImport(evaluationManagementExportImportProps: EvaluationManagementExportImportProps) { + const { definedExpertEvaluationConfig, handleExport, handleImport } = evaluationManagementExportImportProps; + + const onFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target && typeof e.target.result === "string") { + handleImport(e.target.result); + } + }; + reader.readAsText(file); + } + e.target.value = ""; // Reset file input + }; + + return ( +
+ + +
+ ); +} diff --git a/playground/src/components/view_mode/evaluation_mode/expert_evaluation/exercise_import.tsx b/playground/src/components/view_mode/evaluation_mode/expert_evaluation/exercise_import.tsx new file mode 100644 index 000000000..b47d58ddb --- /dev/null +++ b/playground/src/components/view_mode/evaluation_mode/expert_evaluation/exercise_import.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { Exercise } from "@/model/exercise"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons"; + +type ExerciseImportProps = { + exercises: Exercise[]; + setExercises: (exercises: Exercise[]) => void; + disabled: boolean; +}; + +export default function ExerciseImport(exerciseImportProps: ExerciseImportProps) { + const { exercises, setExercises, disabled } = exerciseImportProps; + + const handleExerciseImport = async (fileContents: string[]) => { + const importedExercises = fileContents.map((fileContent) => JSON.parse(fileContent) as Exercise); + setExercises([...exercises, ...importedExercises]); + }; + + const onFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + const files = Array.from(e.target.files); + const fileReaders = files.map(file => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target && typeof event.target.result === "string") { + resolve(event.target.result); + } else { + reject(new Error("File reading failed")); + } + }; + reader.readAsText(file); + }); + }); + + Promise.all(fileReaders) + .then((fileContents) => { + handleExerciseImport(fileContents); + }) + .catch((error) => { + console.error("Error importing exercises:", error); + }); + } + e.target.value = ""; // Reset file input + }; + + const removeExercise = (exerciseId: number) => { + setExercises(exercises.filter((exercise) => exercise.id !== exerciseId)); + }; + + return ( +
+
+ {/* Heading */} + Exercises + + {/* Import New Exercises Button */} + {!disabled && ( + + )} +
+ + {/* List of Imported Exercises */} +
    + {exercises.length === 0 ? ( +
  • + No exercises added! Please add exercises with submissions and categorized feedback. +
  • + ) : ( + exercises.map((exercise) => ( +
  • + {exercise.title} + +
    + {!disabled && ( + + )} +
    +
  • + )) + )} +
+
+ ); +}; diff --git a/playground/src/components/view_mode/evaluation_mode/expert_evaluation/expert_links.tsx b/playground/src/components/view_mode/evaluation_mode/expert_evaluation/expert_links.tsx new file mode 100644 index 000000000..5f3f6ed7b --- /dev/null +++ b/playground/src/components/view_mode/evaluation_mode/expert_evaluation/expert_links.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheck, faCopy, faPlus, faSyncAlt, faTrash } from '@fortawesome/free-solid-svg-icons'; +import baseUrl from '@/helpers/base_url'; +import { fetchExpertEvaluationProgressStats } from '@/hooks/playground/expert_evaluation_progress_stats'; +import { ExpertEvaluationProgressStats } from "@/model/expert_evaluation_progress_stats"; + +type ExpertLinksProps = { + expertIds: string[]; + setExpertIds: (newExpertIds: string[]) => void; + started: boolean; + configId: string; +}; + +export default function ExpertLinks(props: ExpertLinksProps) { + const { expertIds, setExpertIds, started, configId } = props; + + const [copiedLink, setCopiedLink] = useState(null); + const [progressStats, setProgressStats] = useState(); + const [loading, setLoading] = useState(false); + + const updateProgressStats = async () => { + setLoading(true); + const minimumDelay = new Promise((resolve) => setTimeout(resolve, 1000)); + try { + const statsPromise = fetchExpertEvaluationProgressStats(configId); + const [stats] = await Promise.all([statsPromise, minimumDelay]); + setProgressStats(stats); + } catch (error) { + console.error('Failed to fetch progress stats:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (started) updateProgressStats(); + }, [configId, started]); + + const addExpertId = () => setExpertIds([...expertIds, uuidv4()]); + + const copyLink = (link: string) => { + navigator.clipboard.writeText(link); + setCopiedLink(link); + setTimeout(() => setCopiedLink(null), 2000); + }; + + const deleteExpertId = (id: string) => setExpertIds(expertIds.filter((expertId) => expertId !== id)); + + return ( +
+
+ Expert Links + {/* Add New Expert and Conditional Update Stats Button */} +
+ + {started && ( + + )} +
+
+ + {/* List of Expert Links */} +
    + {expertIds.length ? ( + expertIds.map((expertId) => { + const expertLink = `${window.location.origin}${baseUrl}/${configId}/${expertId}/expert_view`; + const completed = progressStats?.[expertId] ?? 0; + const total = progressStats?.totalSubmissions ?? 0; + const progressPercentage = total ? (completed / total) * 100 : 0; + + return ( +
  • +
    + + {expertLink} + +
    + {/* Copy Link Button */} + + {/* Delete Button */} + {!started && ( + + )} +
    +
    + + {/* Progress Bar */} + {started && ( + <> +
    +
    +
    + + {completed === 0 ? 'Not started' : completed === total ? 'Finished 🏁' : `${completed} / ${total} completed`} + + + )} +
  • + ); + }) + ) : ( +
  • No expert links added
  • + )} +
+
+ ); +} diff --git a/playground/src/components/view_mode/evaluation_mode/expert_evaluation/metrics_form.tsx b/playground/src/components/view_mode/evaluation_mode/expert_evaluation/metrics_form.tsx new file mode 100644 index 000000000..b82c2ddbe --- /dev/null +++ b/playground/src/components/view_mode/evaluation_mode/expert_evaluation/metrics_form.tsx @@ -0,0 +1,240 @@ +import { useState } from "react"; +import { v4 as uuidv4 } from 'uuid'; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEdit, faPlus, faSave, faTrash } from "@fortawesome/free-solid-svg-icons"; +import ReactMarkdown from 'react-markdown'; +import rehypeRaw from "rehype-raw"; +import { Metric } from "@/model/metric"; + +type MetricsFormProps = { + metrics: Metric[]; + setMetrics: (metrics: Metric[]) => void; + disabled: boolean; +}; + +export default function MetricsForm(metricsFormProps: MetricsFormProps) { + const { metrics, setMetrics, disabled } = metricsFormProps; + + const [editIndex, setEditIndex] = useState(null); + const [editingMetric, setEditingMetric] = useState({ + id: "", + title: "", + summary: "", + description: "", + }); + const [newMetric, setNewMetric] = useState({ + id: "", + title: "", + summary: "", + description: "", + }); + + const handleChange = ( + e: React.ChangeEvent, + isEditing: boolean = false + ) => { + const { name, value } = e.target; + if (isEditing) { + setEditingMetric((prevMetric) => ({ + ...prevMetric, + [name]: value, + })); + } else { + setNewMetric((prevMetric) => ({ + ...prevMetric, + [name]: value, + })); + } + }; + + const addMetric = () => { + if (!newMetric.title.trim() || !newMetric.summary.trim() || !newMetric.description.trim()) { + return; + } + + const metricWithId = { ...newMetric, id: uuidv4() }; + setMetrics([...metrics, metricWithId]); + setNewMetric({ id: "", title: "", summary: "", description: "" }); // Reset form + }; + + const startEditMetric = (index: number) => { + setEditIndex(index); + setEditingMetric(metrics[index]); + }; + + const saveMetric = () => { + if (editIndex !== null) { + const updatedMetrics = metrics.map((metric, index) => + index === editIndex ? editingMetric : metric + ); + setMetrics(updatedMetrics); + setEditIndex(null); // Exit editing mode + } + }; + + const removeMetric = (index: number) => { + setMetrics(metrics.filter((_, i) => i !== index)); + }; + + const inputDisabledStyle = disabled + ? "bg-gray-100 text-gray-500 cursor-not-allowed" + : ""; + + const buttonDisabledStyle = disabled ? "hidden" : ""; + + return ( +
+ Metrics +
+ + {/* List of Metrics */} + {metrics.map((metric, index) => ( +
+ {editIndex === index ? ( +
+ handleChange(e, true)} + className={`border border-gray-300 rounded-md p-2 w-full ${inputDisabledStyle}`} + placeholder="Metric Title" + disabled={disabled} + /> + handleChange(e, true)} + className={`border border-gray-300 rounded-md p-2 w-full ${inputDisabledStyle}`} + placeholder="Metric Summary" + disabled={disabled} + /> +