diff --git a/clients/core/package.json b/clients/core/package.json index 1124f63b..ba62e163 100644 --- a/clients/core/package.json +++ b/clients/core/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@dagrejs/dagre": "^1.1.4", - "@hello-pangea/dnd": "^17.0.0", "@xyflow/react": "^12.3.6", "date-fns-tz": "^3.2.0", "external-remotes-plugin": "^1.0.0", diff --git a/clients/interview_component/routes/index.tsx b/clients/interview_component/routes/index.tsx index 7dfb0d5b..00ecb81c 100644 --- a/clients/interview_component/routes/index.tsx +++ b/clients/interview_component/routes/index.tsx @@ -4,6 +4,7 @@ import { Role } from '@tumaet/prompt-shared-state' import { InterviewDataShell } from '../src/interview/pages/InterviewDataShell' import { ProfileDetailPage } from '../src/interview/pages/ProfileDetail/ProfileDetailPage' import { MailingPage } from '../src/interview/pages/Mailing/MailingPage' +import { QuestionConfiguration } from '../src/interview/pages/QuestionConfiguration/QuestionConfiguration' const interviewRoutes: ExtendedRouteObject[] = [ { @@ -24,6 +25,15 @@ const interviewRoutes: ExtendedRouteObject[] = [ ), requiredPermissions: [Role.PROMPT_ADMIN, Role.COURSE_LECTURER], }, + { + path: '/question-configuration', + element: ( + + + + ), + requiredPermissions: [Role.PROMPT_ADMIN, Role.COURSE_LECTURER], + }, { path: '/mailing', element: ( diff --git a/clients/interview_component/sidebar/index.tsx b/clients/interview_component/sidebar/index.tsx index 067398f4..ca8df78c 100644 --- a/clients/interview_component/sidebar/index.tsx +++ b/clients/interview_component/sidebar/index.tsx @@ -8,6 +8,11 @@ const interviewSidebarItems: SidebarMenuItemProps = { goToPath: '', requiredPermissions: [Role.PROMPT_ADMIN, Role.COURSE_LECTURER], subitems: [ + { + title: 'Question Config', + goToPath: '/question-configuration', + requiredPermissions: [Role.PROMPT_ADMIN, Role.COURSE_LECTURER], + }, { title: 'Mailing', goToPath: '/mailing', diff --git a/clients/interview_component/src/interview/components/InterviewQuestionsDialog.tsx b/clients/interview_component/src/interview/components/InterviewQuestionsDialog.tsx deleted file mode 100644 index c11641cc..00000000 --- a/clients/interview_component/src/interview/components/InterviewQuestionsDialog.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Separator } from '@/components/ui/separator' -import { ScrollArea } from '@/components/ui/scroll-area' -import { ClipboardList, Trash2, ChevronUp, ChevronDown, Plus } from 'lucide-react' -import { InterviewQuestion } from '../interfaces/InterviewQuestion' -import { useCoursePhaseStore } from '../zustand/useCoursePhaseStore' -import { useUpdateCoursePhaseMetaData } from '@/hooks/useUpdateCoursePhaseMetaData' - -export const InterviewQuestionsDialog = () => { - const { coursePhase } = useCoursePhaseStore() - const [isOpen, setIsOpen] = useState(false) - const [newQuestion, setNewQuestion] = useState('') - const [interviewQuestions, setInterviewQuestions] = useState([] as InterviewQuestion[]) - const scrollRef = useRef(null) - - const scrollToBottom = () => { - scrollRef.current?.scrollIntoView(false) - } - - const { mutate } = useUpdateCoursePhaseMetaData() - - useEffect(() => { - if (isOpen) { - const questions = - coursePhase?.restrictedData?.interviewQuestions ?? ([] as InterviewQuestion[]) - setInterviewQuestions(questions) - } - }, [isOpen, coursePhase]) - - const addQuestion = () => { - if (newQuestion.trim()) { - setInterviewQuestions((prev) => [ - ...prev, - { - id: Date.now(), - question: newQuestion.trim(), - orderNum: interviewQuestions.length, - }, - ]) - setNewQuestion('') - requestAnimationFrame(() => { - scrollToBottom() - }) - } - } - - const deleteQuestion = (id: number) => { - setInterviewQuestions((prev) => prev.filter((q) => q.id !== id)) - } - - const moveQuestion = (index: number, direction: 'up' | 'down') => { - setInterviewQuestions((prev) => { - const newQuestions = [...prev] - if (direction === 'up' && index > 0) { - ;[newQuestions[index - 1], newQuestions[index]] = [ - newQuestions[index], - newQuestions[index - 1], - ] - } else if (direction === 'down' && index < newQuestions.length - 1) { - ;[newQuestions[index], newQuestions[index + 1]] = [ - newQuestions[index + 1], - newQuestions[index], - ] - } - return newQuestions.map((q, idx) => ({ ...q, orderNum: idx })) - }) - } - - const saveQuestions = () => { - if (coursePhase) { - mutate({ - id: coursePhase.id, - restrictedData: { - interviewQuestions: interviewQuestions, - }, - }) - } - setIsOpen(false) - } - - return ( - <> - - - - - Manage Interview Questions - - These questions will be the template for questions asked by the interviewer during the - interview. Deleting a question will make already written notes / responses to this - question unaccessible. - - - - -
-
    - {interviewQuestions.map((question, index) => ( -
  • -
    - - -
    - {question.question} - -
  • - ))} -
-
-
- -
-
- setNewQuestion(e.target.value)} - className='flex-grow' - maxLength={200} - /> - -
-

- {newQuestion.length}/200 characters -

-
- - - - -
-
- - ) -} diff --git a/clients/interview_component/src/interview/pages/Overview/OverviewPage.tsx b/clients/interview_component/src/interview/pages/Overview/OverviewPage.tsx index ee68d2ff..4f80f093 100644 --- a/clients/interview_component/src/interview/pages/Overview/OverviewPage.tsx +++ b/clients/interview_component/src/interview/pages/Overview/OverviewPage.tsx @@ -6,7 +6,6 @@ import { useLocation, useNavigate } from 'react-router-dom' import { useScreenSize } from '@/hooks/useScreenSize' import { useState } from 'react' import { SortDropdownMenu } from '../../components/SortDropdownMenu' -import { InterviewQuestionsDialog } from '../../components/InterviewQuestionsDialog' import { InterviewTimesDialog } from '../../components/InterviewTimesDialog' import { useSorting } from '../../hooks/useSorting' @@ -25,7 +24,6 @@ export const OverviewPage = (): JSX.Element => {
-
{ + const { coursePhase } = useCoursePhaseStore() + const [newQuestion, setNewQuestion] = useState('') + const [interviewQuestions, setInterviewQuestions] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const scrollRef = useRef(null) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [toBeDeletedQuestionID, setToBeDeletedQuestionID] = useState(undefined) + + const { mutate } = useUpdateCoursePhaseMetaData() + + useEffect(() => { + if (coursePhase) { + const questions = coursePhase.restrictedData?.interviewQuestions ?? [] + setInterviewQuestions(questions) + setIsLoading(false) + } + }, [coursePhase]) + + useEffect(() => { + if (coursePhase && !isLoading) { + mutate({ + id: coursePhase.id, + restrictedData: { + interviewQuestions: interviewQuestions, + }, + }) + } + }, [interviewQuestions, coursePhase, mutate, isLoading]) + + const handleEnter = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() // Prevent form submission if wrapped in a form + addQuestion() + } + } + + const addQuestion = () => { + if (newQuestion.trim()) { + setInterviewQuestions((prev) => [ + ...prev, + { + id: Date.now(), + question: newQuestion.trim(), + orderNum: prev.length, + }, + ]) + setNewQuestion('') + requestAnimationFrame(() => { + scrollToBottom() + }) + } + } + + const deleteQuestion = () => { + if (!toBeDeletedQuestionID) return + setInterviewQuestions((prev) => prev.filter((q) => q.id !== toBeDeletedQuestionID)) + setToBeDeletedQuestionID(undefined) + } + + const onDragEnd = (result: any) => { + if (!result.destination) return + + const newQuestions = Array.from(interviewQuestions) + const [reorderedItem] = newQuestions.splice(result.source.index, 1) + newQuestions.splice(result.destination.index, 0, reorderedItem) + + setInterviewQuestions(newQuestions.map((q, idx) => ({ ...q, orderNum: idx }))) + } + + const scrollToBottom = () => { + scrollRef.current?.scrollIntoView(false) + } + + return ( +
+ {/* Sticky header */} +
+
+ Interview Question Configuration +

+ These questions will be used as a template during interviews. Deleting a question will + make any associated notes or responses inaccessible. +

+
+
+
+ setNewQuestion(e.target.value)} + onKeyDown={handleEnter} + className='flex-grow' + maxLength={200} + /> + +
+

+ {newQuestion.length}/200 characters +

+
+
+ + {/* Scrollable content */} +
+ {isLoading ? ( +
+ +
+ ) : ( + +
+ + {(provided) => ( +
    + {interviewQuestions.map((question, index) => ( + + {(prov) => ( +
  • +
    + +
    + {question.question} + +
  • + )} +
    + ))} + {provided.placeholder} +
+ )} +
+
+
+ )} +
+ {deleteDialogOpen && ( + + deleteConfirmed ? deleteQuestion() : setToBeDeletedQuestionID(undefined) + } + deleteMessage='Are you sure you want to delete this question? This may result in the loss of interview answers.' + /> + )} +
+ ) +} diff --git a/clients/package.json b/clients/package.json index 3e6a97b6..f2a61e70 100644 --- a/clients/package.json +++ b/clients/package.json @@ -18,6 +18,7 @@ "lint": "eslint \"**/*.{js,jsx,ts,tsx}\"" }, "dependencies": { + "@hello-pangea/dnd": "^18.0.1", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-toolbar": "^1.1.1", "@tailwindcss/typography": "^0.5.15", diff --git a/clients/yarn.lock b/clients/yarn.lock index 16edcfab..ee671b19 100644 --- a/clients/yarn.lock +++ b/clients/yarn.lock @@ -30,7 +30,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.26.0 resolution: "@babel/runtime@npm:7.26.0" dependencies: @@ -39,6 +39,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.26.7": + version: 7.26.9 + resolution: "@babel/runtime@npm:7.26.9" + dependencies: + regenerator-runtime: "npm:^0.14.0" + checksum: 10c0/e8517131110a6ec3a7360881438b85060e49824e007f4a64b5dfa9192cf2bb5c01e84bfc109f02d822c7edb0db926928dd6b991e3ee460b483fb0fac43152d9b + languageName: node + linkType: hard + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -705,21 +714,19 @@ __metadata: languageName: node linkType: hard -"@hello-pangea/dnd@npm:^17.0.0": - version: 17.0.0 - resolution: "@hello-pangea/dnd@npm:17.0.0" +"@hello-pangea/dnd@npm:^18.0.1": + version: 18.0.1 + resolution: "@hello-pangea/dnd@npm:18.0.1" dependencies: - "@babel/runtime": "npm:^7.25.6" + "@babel/runtime": "npm:^7.26.7" css-box-model: "npm:^1.2.1" - memoize-one: "npm:^6.0.0" raf-schd: "npm:^4.0.3" - react-redux: "npm:^9.1.2" + react-redux: "npm:^9.2.0" redux: "npm:^5.0.1" - use-memo-one: "npm:^1.1.3" peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - checksum: 10c0/93417c055267f6f12a37a1cdb08d9db85ab021b102315e1e5a70a79d7de6c2ffaeff211e3ec40441c110f39e60688cfcea85ab86c21820041d974415c1ca715e + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + checksum: 10c0/30c47ac8048f85e5c6d39c0b5a492cf2cc9e5f532cee12c5ecc77688596c8846670be142bd716212db789f161cd769601a5da135fa99ac65824fbb6a07d4d137 languageName: node linkType: hard @@ -5633,7 +5640,6 @@ __metadata: resolution: "client@workspace:core" dependencies: "@dagrejs/dagre": "npm:^1.1.4" - "@hello-pangea/dnd": "npm:^17.0.0" "@xyflow/react": "npm:^12.3.6" clean-webpack-plugin: "npm:^4.0.0" compression-webpack-plugin: "npm:^11.1.0" @@ -10365,13 +10371,6 @@ __metadata: languageName: node linkType: hard -"memoize-one@npm:^6.0.0": - version: 6.0.0 - resolution: "memoize-one@npm:6.0.0" - checksum: 10c0/45c88e064fd715166619af72e8cf8a7a17224d6edf61f7a8633d740ed8c8c0558a4373876c9b8ffc5518c2b65a960266adf403cc215cb1e90f7e262b58991f54 - languageName: node - linkType: hard - "meow@npm:^8.1.2": version: 8.1.2 resolution: "meow@npm:8.1.2" @@ -12798,6 +12797,7 @@ __metadata: "@eslint/compat": "npm:^1.2.2" "@eslint/eslintrc": "npm:^3.1.0" "@eslint/js": "npm:^9.6.0" + "@hello-pangea/dnd": "npm:^18.0.1" "@radix-ui/react-icons": "npm:^1.3.2" "@radix-ui/react-toolbar": "npm:^1.1.1" "@tailwindcss/typography": "npm:^0.5.15" @@ -13295,7 +13295,7 @@ __metadata: languageName: node linkType: hard -"react-redux@npm:^9.1.2": +"react-redux@npm:^9.2.0": version: 9.2.0 resolution: "react-redux@npm:9.2.0" dependencies: @@ -15564,7 +15564,7 @@ __metadata: languageName: node linkType: hard -"use-memo-one@npm:^1.1.1, use-memo-one@npm:^1.1.3": +"use-memo-one@npm:^1.1.1": version: 1.1.3 resolution: "use-memo-one@npm:1.1.3" peerDependencies: