From ee3c28475ddd17d42bd72e564d132746be632603 Mon Sep 17 00:00:00 2001 From: Phill Conrad Date: Fri, 7 Aug 2026 21:31:08 -0700 Subject: [PATCH 1/4] pc - WIP issue 1, commit 2 --- frontend/src/App.tsx | 73 +- frontend/src/fixtures/conceptsFixtures.js | 24 - frontend/src/fixtures/courseMenuFixtures.ts | 75 - frontend/src/fixtures/courseStaffFixtures.js | 75 - frontend/src/fixtures/coursesFixtures.js | 34 - frontend/src/fixtures/currentUserFixtures.ts | 14 +- frontend/src/fixtures/edgesFixtures.js | 12 - frontend/src/fixtures/jobsByCourseFixtures.js | 67 - frontend/src/fixtures/roleEmailFixtures.js | 10 +- .../src/fixtures/rosterStudentFixtures.js | 130 -- frontend/src/fixtures/schoolFixtures.js | 22 - frontend/src/fixtures/subConceptsFixtures.js | 28 - frontend/src/fixtures/usersFixtures.js | 6 +- frontend/src/main/api/legacyClient.ts | 91 - .../components/Concept/ConceptDeleteModal.jsx | 40 - .../main/components/Concept/ConceptModal.jsx | 125 -- .../components/Concept/ConceptSelector.jsx | 43 - .../main/components/Concept/ConceptTable.jsx | 46 - .../src/main/components/Concept/EdgeTable.jsx | 28 - .../components/Concept/SubConceptModal.jsx | 154 -- .../components/Concept/SubConceptTable.jsx | 72 - .../CourseStaff/CourseStaffCSVUploadForm.jsx | 37 - .../CourseStaff/CourseStaffDeleteModal.jsx | 38 - .../CourseStaff/CourseStaffForm.jsx | 110 -- .../CourseStaff/CourseStaffTable.jsx | 162 -- .../Courses/CourseDownloadsForm.jsx | 30 - .../main/components/Courses/CourseMenu.tsx | 95 -- .../main/components/Courses/CourseModal.jsx | 105 -- .../main/components/Courses/CoursesTable.jsx | 50 - .../Courses/InstructorAdminCoursesTable.jsx | 400 ----- .../components/Courses/SchoolTypeahead.jsx | 49 - .../Courses/StudentCoursesTable.jsx | 29 - .../TabComponent/ConceptTabComponent.jsx | 138 -- .../TabComponent/DownloadsTabComponent.jsx | 23 - .../TabComponent/EdgeConceptTabComponent.jsx | 166 -- .../TabComponent/EnrollmentTabComponent.jsx | 271 --- .../Courses/TabComponent/JobTabComponent.jsx | 36 - .../Courses/TabComponent/PLTabComponent.jsx | 247 --- .../TabComponent/ScaffoldTabComponent.jsx | 146 -- .../TabComponent/SettingsTabComponent.jsx | 46 - .../TabComponent/StaffTabComponent.jsx | 240 --- .../TabComponent/SubConceptTabComponent.jsx | 148 -- .../Courses/UpdateInstructorForm.jsx | 50 - .../LegacyHomePage/LegacyAssessmentSelect.tsx | 119 -- .../LegacyHomePage/LegacyConceptGraph.tsx | 860 ---------- .../LegacyHomePage/LegacyQuestionSearch.tsx | 192 --- .../src/main/components/Nav/AppNavbar.tsx | 31 +- frontend/src/main/components/Nav/Footer.tsx | 75 +- .../main/components/Profile/PatSection.tsx | 140 -- .../RosterStudent/DroppedStudentsTable.jsx | 67 - .../RosterStudentCSVUploadForm.jsx | 37 - .../RosterStudentDeleteModal.jsx | 38 - .../RosterStudent/RosterStudentDropdown.jsx | 42 - .../RosterStudent/RosterStudentForm.jsx | 125 -- .../RosterStudent/RosterStudentTable.jsx | 156 -- .../components/Scaffold/AssessmentSelect.tsx | 115 -- .../main/components/Scaffold/LevelLegend.tsx | 23 - .../components/Scaffold/LinkToScaffold.tsx | 27 - .../components/Scaffold/LinkToSettings.tsx | 29 - .../components/Scaffold/QuestionSearch.tsx | 188 --- .../main/components/Scaffold/ResetButton.tsx | 23 - .../components/Scaffold/ScaffoldBrand.tsx | 21 - .../Scaffold/ScaffoldConceptGraph.tsx | 1060 ------------ .../components/Scaffold/ScaffoldTopBar.tsx | 124 -- .../main/components/Scaffold/StarStatus.tsx | 61 - .../Scaffold/UnlockAssessmentsModal.tsx | 90 - .../src/main/components/Scaffold/types.ts | 12 - .../components/Settings/CanvasApiForm.jsx | 86 - .../components/Settings/CourseOptionsForm.jsx | 63 - .../src/main/components/Users/UsersTable.tsx | 8 +- frontend/src/main/data/conceptContent.ts | 823 --------- frontend/src/main/data/conceptGraph.ts | 258 --- .../src/main/data/conceptGraphPositions.ts | 33 - .../main/layouts/BasicLayout/BasicLayout.tsx | 18 +- .../pages/Admin/AdminCoursesIndexPage.jsx | 80 - .../src/main/pages/Admin/AdminsIndexPage.tsx | 4 +- ...eatePage.tsx => ResearchersCreatePage.tsx} | 22 +- ...IndexPage.tsx => ResearchersIndexPage.tsx} | 22 +- frontend/src/main/pages/ConceptGraphPage.tsx | 1388 ---------------- .../Courses/InstructorCourseShowPage.jsx | 178 -- .../Courses/InstructorCoursesIndexPage.jsx | 78 - .../src/main/pages/Help/AboutCitelines.jsx | 18 + .../src/main/pages/Help/AboutScaffold.jsx | 98 -- .../src/main/pages/Home/HomePageLoggedIn.jsx | 110 +- .../src/main/pages/Home/LegacyHomePage.tsx | 730 -------- frontend/src/main/pages/UserProfilePage.tsx | 67 - frontend/src/main/types/conceptGraph.ts | 88 - frontend/src/main/utils/conceptGraphUtils.ts | 50 - frontend/src/main/utils/courseOptionsUtils.js | 7 - frontend/src/main/utils/layout.ts | 102 -- frontend/src/main/utils/rosterStudentUtils.js | 9 - frontend/src/main/utils/staffTools.tsx | 89 - frontend/src/main/utils/staffToolsContext.ts | 39 - frontend/src/main/utils/useStaffTools.ts | 9 - .../Concept/ConceptModal.stories.jsx | 46 - .../Concept/ConceptSelector.stories.jsx | 47 - .../Concept/ConceptTable.stories.jsx | 24 - .../Concept/SubConceptModal.stories.jsx | 50 - .../Concept/SubConceptTable.stories.jsx | 24 - .../CourseStaffCSVUploadForm.stories.jsx | 18 - .../CourseStaffDeleteModal.stories.jsx | 31 - .../CourseStaff/CourseStaffForm.stories.jsx | 33 - .../CourseStaff/CourseStaffTable.stories.jsx | 83 - .../Courses/CourseModal.stories.jsx | 74 - .../Courses/CoursesTable.stories.jsx | 29 - .../InstructorCoursesTable.stories.jsx | 123 -- .../Courses/SchoolDropdown.stories.jsx | 57 - .../Courses/StudentCoursesTable.stories.jsx | 74 - .../ConceptTabComponent.stories.jsx | 44 - .../EdgeConceptTabComponent.stories.jsx | 70 - .../TabComponents/JobTabComponent.stories.jsx | 49 - .../TabComponents/PLTabComponent.stories.jsx | 90 - .../ScaffoldTabComponent.stories.jsx | 57 - .../SubConceptTabComponent.stories.jsx | 39 - .../AssessmentSelect.stories.tsx | 31 - .../LegacyConceptGraph.stories.tsx | 27 - .../LegacyHomePage/QuestionSearch.stories.tsx | 47 - .../components/Profile/PatSection.stories.tsx | 63 - .../DroppedStudentsTable.stories.jsx | 70 - .../RosterStudentCSVUploadForm.stories.jsx | 19 - .../RosterStudentDeleteModal.stories.jsx | 31 - .../RosterStudentForm.stories.jsx | 33 - .../RosterStudentTable.stories.jsx | 83 - .../Scaffold/AssessmentSelect.stories.tsx | 31 - .../Scaffold/LevelLegend.stories.tsx | 13 - .../Scaffold/LinkToScaffold.stories.tsx | 16 - .../Scaffold/LinkToSettings.stories.tsx | 16 - .../Scaffold/QuestionSearch.stories.tsx | 47 - .../Scaffold/ResetButton.stories.tsx | 17 - .../Scaffold/ScaffoldBrand.stories.tsx | 13 - .../Scaffold/ScaffoldConceptGraph.stories.tsx | 73 - .../Scaffold/ScaffoldTopBar.stories.tsx | 101 -- .../Scaffold/StarStatus.stories.tsx | 32 - .../UnlockAssessmentsModal.stories.tsx | 42 - .../Settings/CourseOptionsForm.stories.jsx | 64 - ...stories.jsx => AboutCitelines.stories.jsx} | 0 frontend/src/tests/api/legacyClient.test.ts | 181 -- .../Concept/ConceptDeleteModal.test.jsx | 62 - .../components/Concept/ConceptModal.test.jsx | 153 -- .../Concept/ConceptSelector.test.jsx | 113 -- .../components/Concept/ConceptTable.test.jsx | 86 - .../components/Concept/EdgeTable.test.jsx | 74 - .../Concept/SubConceptModal.test.jsx | 168 -- .../Concept/SubConceptTable.test.jsx | 166 -- .../CourseStaffCSVUploadForm.test.jsx | 63 - .../CourseStaffDeleteModal.test.jsx | 81 - .../CourseStaff/CourseStaffForm.test.jsx | 111 -- .../CourseStaff/CourseStaffTable.test.jsx | 329 ---- .../Courses/CourseDownloadsForm.jsx | 30 - .../components/Courses/CourseMenu.test.jsx | 128 -- .../components/Courses/CourseModal.test.jsx | 352 ---- .../components/Courses/CoursesTable.test.jsx | 108 -- .../InstructorAdminCoursesTable.test.jsx | 1475 ----------------- .../Courses/SchoolDropdown.test.jsx | 132 -- .../Courses/StudentCoursesTable.test.jsx | 94 -- .../TabComponent/ConceptTabComponent.test.jsx | 250 --- .../DownloadsTabComponent.test.jsx | 85 - .../EdgeConceptTabComponent.test.jsx | 354 ---- .../EnrollmentTabComponent.test.jsx | 783 --------- .../TabComponent/JobTabComponent.test.jsx | 151 -- .../TabComponent/PLTabComponent.test.jsx | 465 ------ .../ScaffoldTabComponent.test.jsx | 305 ---- .../SettingsTabComponent.test.jsx | 160 -- .../TabComponent/StaffTabComponent.test.jsx | 732 -------- .../SubConceptTabComponent.test.jsx | 279 ---- .../LegacyHomePage/AssessmentSelect.test.tsx | 119 -- .../LegacyConceptGraph.test.tsx | 392 ----- .../LegacyHomePage/QuestionSearch.test.tsx | 315 ---- .../components/Profile/PatSection.test.tsx | 204 --- .../DroppedStudentsTable.test.jsx | 94 -- .../RosterStudentCSVUploadForm.test.jsx | 39 - .../RosterStudentDeleteModal.test.jsx | 81 - .../RosterStudentDropdown.test.jsx | 93 -- .../RosterStudent/RosterStudentForm.test.jsx | 115 -- .../RosterStudent/RosterStudentTable.test.jsx | 281 ---- .../Scaffold/AssessmentSelect.test.tsx | 119 -- .../components/Scaffold/LevelLegend.test.tsx | 33 - .../Scaffold/LinkToScaffold.test.tsx | 59 - .../Scaffold/LinkToSettings.test.tsx | 60 - .../Scaffold/QuestionSearch.test.tsx | 315 ---- .../components/Scaffold/ResetButton.test.tsx | 19 - .../Scaffold/ScaffoldConceptGraph.test.tsx | 688 -------- .../Scaffold/ScaffoldTopBar.test.tsx | 198 --- .../components/Scaffold/StarStatus.test.tsx | 15 - .../Scaffold/UnlockAssessmentsModal.test.tsx | 140 -- .../Settings/CanvasApiForm.test.jsx | 306 ---- .../Settings/CourseOptionsForm.test.jsx | 104 -- .../Admin/AdminCoursesIndexPage.test.jsx | 236 --- ...est.jsx => ResearchersCreatePage.test.jsx} | 32 +- ...test.jsx => ResearchersIndexPage.test.jsx} | 54 +- .../src/tests/pages/ConceptGraphPage.test.tsx | 943 ----------- .../ConceptGraphPageCourseSwitch.test.tsx | 264 --- .../Courses/InstructorCourseShowPage.test.jsx | 471 ------ .../InstructorCoursesIndexPage.test.jsx | 274 --- ...ffold.test.jsx => AboutCitelines.test.jsx} | 0 .../tests/pages/Home/LegacyHomePage.test.tsx | 596 ------- .../src/tests/pages/UserProfilePage.test.jsx | 118 -- .../src/tests/utils/conceptGraphUtils.test.ts | 109 -- frontend/src/tests/utils/layout.test.ts | 175 -- .../tests/utils/rosterStudentUtils.test.jsx | 31 - frontend/src/tests/utils/staffTools.test.tsx | 300 ---- 201 files changed, 124 insertions(+), 28586 deletions(-) delete mode 100644 frontend/src/fixtures/conceptsFixtures.js delete mode 100644 frontend/src/fixtures/courseMenuFixtures.ts delete mode 100644 frontend/src/fixtures/courseStaffFixtures.js delete mode 100644 frontend/src/fixtures/coursesFixtures.js delete mode 100644 frontend/src/fixtures/edgesFixtures.js delete mode 100644 frontend/src/fixtures/jobsByCourseFixtures.js delete mode 100644 frontend/src/fixtures/rosterStudentFixtures.js delete mode 100644 frontend/src/fixtures/schoolFixtures.js delete mode 100644 frontend/src/fixtures/subConceptsFixtures.js delete mode 100644 frontend/src/main/api/legacyClient.ts delete mode 100644 frontend/src/main/components/Concept/ConceptDeleteModal.jsx delete mode 100644 frontend/src/main/components/Concept/ConceptModal.jsx delete mode 100644 frontend/src/main/components/Concept/ConceptSelector.jsx delete mode 100644 frontend/src/main/components/Concept/ConceptTable.jsx delete mode 100644 frontend/src/main/components/Concept/EdgeTable.jsx delete mode 100644 frontend/src/main/components/Concept/SubConceptModal.jsx delete mode 100644 frontend/src/main/components/Concept/SubConceptTable.jsx delete mode 100644 frontend/src/main/components/CourseStaff/CourseStaffCSVUploadForm.jsx delete mode 100644 frontend/src/main/components/CourseStaff/CourseStaffDeleteModal.jsx delete mode 100644 frontend/src/main/components/CourseStaff/CourseStaffForm.jsx delete mode 100644 frontend/src/main/components/CourseStaff/CourseStaffTable.jsx delete mode 100644 frontend/src/main/components/Courses/CourseDownloadsForm.jsx delete mode 100644 frontend/src/main/components/Courses/CourseMenu.tsx delete mode 100644 frontend/src/main/components/Courses/CourseModal.jsx delete mode 100644 frontend/src/main/components/Courses/CoursesTable.jsx delete mode 100644 frontend/src/main/components/Courses/InstructorAdminCoursesTable.jsx delete mode 100644 frontend/src/main/components/Courses/SchoolTypeahead.jsx delete mode 100644 frontend/src/main/components/Courses/StudentCoursesTable.jsx delete mode 100644 frontend/src/main/components/Courses/TabComponent/ConceptTabComponent.jsx delete mode 100644 frontend/src/main/components/Courses/TabComponent/DownloadsTabComponent.jsx delete mode 100644 frontend/src/main/components/Courses/TabComponent/EdgeConceptTabComponent.jsx delete mode 100644 frontend/src/main/components/Courses/TabComponent/EnrollmentTabComponent.jsx delete mode 100644 frontend/src/main/components/Courses/TabComponent/JobTabComponent.jsx delete mode 100644 frontend/src/main/components/Courses/TabComponent/PLTabComponent.jsx delete mode 100644 frontend/src/main/components/Courses/TabComponent/ScaffoldTabComponent.jsx delete mode 100644 frontend/src/main/components/Courses/TabComponent/SettingsTabComponent.jsx delete mode 100644 frontend/src/main/components/Courses/TabComponent/StaffTabComponent.jsx delete mode 100644 frontend/src/main/components/Courses/TabComponent/SubConceptTabComponent.jsx delete mode 100644 frontend/src/main/components/Courses/UpdateInstructorForm.jsx delete mode 100644 frontend/src/main/components/LegacyHomePage/LegacyAssessmentSelect.tsx delete mode 100644 frontend/src/main/components/LegacyHomePage/LegacyConceptGraph.tsx delete mode 100644 frontend/src/main/components/LegacyHomePage/LegacyQuestionSearch.tsx delete mode 100644 frontend/src/main/components/Profile/PatSection.tsx delete mode 100644 frontend/src/main/components/RosterStudent/DroppedStudentsTable.jsx delete mode 100644 frontend/src/main/components/RosterStudent/RosterStudentCSVUploadForm.jsx delete mode 100644 frontend/src/main/components/RosterStudent/RosterStudentDeleteModal.jsx delete mode 100644 frontend/src/main/components/RosterStudent/RosterStudentDropdown.jsx delete mode 100644 frontend/src/main/components/RosterStudent/RosterStudentForm.jsx delete mode 100644 frontend/src/main/components/RosterStudent/RosterStudentTable.jsx delete mode 100644 frontend/src/main/components/Scaffold/AssessmentSelect.tsx delete mode 100644 frontend/src/main/components/Scaffold/LevelLegend.tsx delete mode 100644 frontend/src/main/components/Scaffold/LinkToScaffold.tsx delete mode 100644 frontend/src/main/components/Scaffold/LinkToSettings.tsx delete mode 100644 frontend/src/main/components/Scaffold/QuestionSearch.tsx delete mode 100644 frontend/src/main/components/Scaffold/ResetButton.tsx delete mode 100644 frontend/src/main/components/Scaffold/ScaffoldBrand.tsx delete mode 100644 frontend/src/main/components/Scaffold/ScaffoldConceptGraph.tsx delete mode 100644 frontend/src/main/components/Scaffold/ScaffoldTopBar.tsx delete mode 100644 frontend/src/main/components/Scaffold/StarStatus.tsx delete mode 100644 frontend/src/main/components/Scaffold/UnlockAssessmentsModal.tsx delete mode 100644 frontend/src/main/components/Scaffold/types.ts delete mode 100644 frontend/src/main/components/Settings/CanvasApiForm.jsx delete mode 100644 frontend/src/main/components/Settings/CourseOptionsForm.jsx delete mode 100644 frontend/src/main/data/conceptContent.ts delete mode 100644 frontend/src/main/data/conceptGraph.ts delete mode 100644 frontend/src/main/data/conceptGraphPositions.ts delete mode 100644 frontend/src/main/pages/Admin/AdminCoursesIndexPage.jsx rename frontend/src/main/pages/Admin/{InstructorsCreatePage.tsx => ResearchersCreatePage.tsx} (63%) rename frontend/src/main/pages/Admin/{InstructorsIndexPage.tsx => ResearchersIndexPage.tsx} (57%) delete mode 100644 frontend/src/main/pages/ConceptGraphPage.tsx delete mode 100644 frontend/src/main/pages/Courses/InstructorCourseShowPage.jsx delete mode 100644 frontend/src/main/pages/Courses/InstructorCoursesIndexPage.jsx create mode 100644 frontend/src/main/pages/Help/AboutCitelines.jsx delete mode 100644 frontend/src/main/pages/Help/AboutScaffold.jsx delete mode 100644 frontend/src/main/pages/Home/LegacyHomePage.tsx delete mode 100644 frontend/src/main/pages/UserProfilePage.tsx delete mode 100644 frontend/src/main/types/conceptGraph.ts delete mode 100644 frontend/src/main/utils/conceptGraphUtils.ts delete mode 100644 frontend/src/main/utils/courseOptionsUtils.js delete mode 100644 frontend/src/main/utils/layout.ts delete mode 100644 frontend/src/main/utils/rosterStudentUtils.js delete mode 100644 frontend/src/main/utils/staffTools.tsx delete mode 100644 frontend/src/main/utils/staffToolsContext.ts delete mode 100644 frontend/src/main/utils/useStaffTools.ts delete mode 100644 frontend/src/stories/components/Concept/ConceptModal.stories.jsx delete mode 100644 frontend/src/stories/components/Concept/ConceptSelector.stories.jsx delete mode 100644 frontend/src/stories/components/Concept/ConceptTable.stories.jsx delete mode 100644 frontend/src/stories/components/Concept/SubConceptModal.stories.jsx delete mode 100644 frontend/src/stories/components/Concept/SubConceptTable.stories.jsx delete mode 100644 frontend/src/stories/components/CourseStaff/CourseStaffCSVUploadForm.stories.jsx delete mode 100644 frontend/src/stories/components/CourseStaff/CourseStaffDeleteModal.stories.jsx delete mode 100644 frontend/src/stories/components/CourseStaff/CourseStaffForm.stories.jsx delete mode 100644 frontend/src/stories/components/CourseStaff/CourseStaffTable.stories.jsx delete mode 100644 frontend/src/stories/components/Courses/CourseModal.stories.jsx delete mode 100644 frontend/src/stories/components/Courses/CoursesTable.stories.jsx delete mode 100644 frontend/src/stories/components/Courses/InstructorCoursesTable.stories.jsx delete mode 100644 frontend/src/stories/components/Courses/SchoolDropdown.stories.jsx delete mode 100644 frontend/src/stories/components/Courses/StudentCoursesTable.stories.jsx delete mode 100644 frontend/src/stories/components/Courses/TabComponents/ConceptTabComponent.stories.jsx delete mode 100644 frontend/src/stories/components/Courses/TabComponents/EdgeConceptTabComponent.stories.jsx delete mode 100644 frontend/src/stories/components/Courses/TabComponents/JobTabComponent.stories.jsx delete mode 100644 frontend/src/stories/components/Courses/TabComponents/PLTabComponent.stories.jsx delete mode 100644 frontend/src/stories/components/Courses/TabComponents/ScaffoldTabComponent.stories.jsx delete mode 100644 frontend/src/stories/components/Courses/TabComponents/SubConceptTabComponent.stories.jsx delete mode 100644 frontend/src/stories/components/LegacyHomePage/AssessmentSelect.stories.tsx delete mode 100644 frontend/src/stories/components/LegacyHomePage/LegacyConceptGraph.stories.tsx delete mode 100644 frontend/src/stories/components/LegacyHomePage/QuestionSearch.stories.tsx delete mode 100644 frontend/src/stories/components/Profile/PatSection.stories.tsx delete mode 100644 frontend/src/stories/components/RosterStudent/DroppedStudentsTable.stories.jsx delete mode 100644 frontend/src/stories/components/RosterStudent/RosterStudentCSVUploadForm.stories.jsx delete mode 100644 frontend/src/stories/components/RosterStudent/RosterStudentDeleteModal.stories.jsx delete mode 100644 frontend/src/stories/components/RosterStudent/RosterStudentForm.stories.jsx delete mode 100644 frontend/src/stories/components/RosterStudent/RosterStudentTable.stories.jsx delete mode 100644 frontend/src/stories/components/Scaffold/AssessmentSelect.stories.tsx delete mode 100644 frontend/src/stories/components/Scaffold/LevelLegend.stories.tsx delete mode 100644 frontend/src/stories/components/Scaffold/LinkToScaffold.stories.tsx delete mode 100644 frontend/src/stories/components/Scaffold/LinkToSettings.stories.tsx delete mode 100644 frontend/src/stories/components/Scaffold/QuestionSearch.stories.tsx delete mode 100644 frontend/src/stories/components/Scaffold/ResetButton.stories.tsx delete mode 100644 frontend/src/stories/components/Scaffold/ScaffoldBrand.stories.tsx delete mode 100644 frontend/src/stories/components/Scaffold/ScaffoldConceptGraph.stories.tsx delete mode 100644 frontend/src/stories/components/Scaffold/ScaffoldTopBar.stories.tsx delete mode 100644 frontend/src/stories/components/Scaffold/StarStatus.stories.tsx delete mode 100644 frontend/src/stories/components/Scaffold/UnlockAssessmentsModal.stories.tsx delete mode 100644 frontend/src/stories/components/Settings/CourseOptionsForm.stories.jsx rename frontend/src/stories/pages/Help/{AboutScaffold.stories.jsx => AboutCitelines.stories.jsx} (100%) delete mode 100644 frontend/src/tests/api/legacyClient.test.ts delete mode 100644 frontend/src/tests/components/Concept/ConceptDeleteModal.test.jsx delete mode 100644 frontend/src/tests/components/Concept/ConceptModal.test.jsx delete mode 100644 frontend/src/tests/components/Concept/ConceptSelector.test.jsx delete mode 100644 frontend/src/tests/components/Concept/ConceptTable.test.jsx delete mode 100644 frontend/src/tests/components/Concept/EdgeTable.test.jsx delete mode 100644 frontend/src/tests/components/Concept/SubConceptModal.test.jsx delete mode 100644 frontend/src/tests/components/Concept/SubConceptTable.test.jsx delete mode 100644 frontend/src/tests/components/CourseStaff/CourseStaffCSVUploadForm.test.jsx delete mode 100644 frontend/src/tests/components/CourseStaff/CourseStaffDeleteModal.test.jsx delete mode 100644 frontend/src/tests/components/CourseStaff/CourseStaffForm.test.jsx delete mode 100644 frontend/src/tests/components/CourseStaff/CourseStaffTable.test.jsx delete mode 100644 frontend/src/tests/components/Courses/CourseDownloadsForm.jsx delete mode 100644 frontend/src/tests/components/Courses/CourseMenu.test.jsx delete mode 100644 frontend/src/tests/components/Courses/CourseModal.test.jsx delete mode 100644 frontend/src/tests/components/Courses/CoursesTable.test.jsx delete mode 100644 frontend/src/tests/components/Courses/InstructorAdminCoursesTable.test.jsx delete mode 100644 frontend/src/tests/components/Courses/SchoolDropdown.test.jsx delete mode 100644 frontend/src/tests/components/Courses/StudentCoursesTable.test.jsx delete mode 100644 frontend/src/tests/components/Courses/TabComponent/ConceptTabComponent.test.jsx delete mode 100644 frontend/src/tests/components/Courses/TabComponent/DownloadsTabComponent.test.jsx delete mode 100644 frontend/src/tests/components/Courses/TabComponent/EdgeConceptTabComponent.test.jsx delete mode 100644 frontend/src/tests/components/Courses/TabComponent/EnrollmentTabComponent.test.jsx delete mode 100644 frontend/src/tests/components/Courses/TabComponent/JobTabComponent.test.jsx delete mode 100644 frontend/src/tests/components/Courses/TabComponent/PLTabComponent.test.jsx delete mode 100644 frontend/src/tests/components/Courses/TabComponent/ScaffoldTabComponent.test.jsx delete mode 100644 frontend/src/tests/components/Courses/TabComponent/SettingsTabComponent.test.jsx delete mode 100644 frontend/src/tests/components/Courses/TabComponent/StaffTabComponent.test.jsx delete mode 100644 frontend/src/tests/components/Courses/TabComponent/SubConceptTabComponent.test.jsx delete mode 100644 frontend/src/tests/components/LegacyHomePage/AssessmentSelect.test.tsx delete mode 100644 frontend/src/tests/components/LegacyHomePage/LegacyConceptGraph.test.tsx delete mode 100644 frontend/src/tests/components/LegacyHomePage/QuestionSearch.test.tsx delete mode 100644 frontend/src/tests/components/Profile/PatSection.test.tsx delete mode 100644 frontend/src/tests/components/RosterStudent/DroppedStudentsTable.test.jsx delete mode 100644 frontend/src/tests/components/RosterStudent/RosterStudentCSVUploadForm.test.jsx delete mode 100644 frontend/src/tests/components/RosterStudent/RosterStudentDeleteModal.test.jsx delete mode 100644 frontend/src/tests/components/RosterStudent/RosterStudentDropdown.test.jsx delete mode 100644 frontend/src/tests/components/RosterStudent/RosterStudentForm.test.jsx delete mode 100644 frontend/src/tests/components/RosterStudent/RosterStudentTable.test.jsx delete mode 100644 frontend/src/tests/components/Scaffold/AssessmentSelect.test.tsx delete mode 100644 frontend/src/tests/components/Scaffold/LevelLegend.test.tsx delete mode 100644 frontend/src/tests/components/Scaffold/LinkToScaffold.test.tsx delete mode 100644 frontend/src/tests/components/Scaffold/LinkToSettings.test.tsx delete mode 100644 frontend/src/tests/components/Scaffold/QuestionSearch.test.tsx delete mode 100644 frontend/src/tests/components/Scaffold/ResetButton.test.tsx delete mode 100644 frontend/src/tests/components/Scaffold/ScaffoldConceptGraph.test.tsx delete mode 100644 frontend/src/tests/components/Scaffold/ScaffoldTopBar.test.tsx delete mode 100644 frontend/src/tests/components/Scaffold/StarStatus.test.tsx delete mode 100644 frontend/src/tests/components/Scaffold/UnlockAssessmentsModal.test.tsx delete mode 100644 frontend/src/tests/components/Settings/CanvasApiForm.test.jsx delete mode 100644 frontend/src/tests/components/Settings/CourseOptionsForm.test.jsx delete mode 100644 frontend/src/tests/pages/Admin/AdminCoursesIndexPage.test.jsx rename frontend/src/tests/pages/Admin/{InstructorsCreatePage.test.jsx => ResearchersCreatePage.test.jsx} (83%) rename frontend/src/tests/pages/Admin/{InstructorsIndexPage.test.jsx => ResearchersIndexPage.test.jsx} (75%) delete mode 100644 frontend/src/tests/pages/ConceptGraphPage.test.tsx delete mode 100644 frontend/src/tests/pages/ConceptGraphPageCourseSwitch.test.tsx delete mode 100644 frontend/src/tests/pages/Courses/InstructorCourseShowPage.test.jsx delete mode 100644 frontend/src/tests/pages/Courses/InstructorCoursesIndexPage.test.jsx rename frontend/src/tests/pages/Help/{AboutScaffold.test.jsx => AboutCitelines.test.jsx} (100%) delete mode 100644 frontend/src/tests/pages/Home/LegacyHomePage.test.tsx delete mode 100644 frontend/src/tests/pages/UserProfilePage.test.jsx delete mode 100644 frontend/src/tests/utils/conceptGraphUtils.test.ts delete mode 100644 frontend/src/tests/utils/layout.test.ts delete mode 100644 frontend/src/tests/utils/rosterStudentUtils.test.jsx delete mode 100644 frontend/src/tests/utils/staffTools.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be6619b..703e4d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,26 +3,20 @@ import { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import AdminsIndexPage from "main/pages/Admin/AdminsIndexPage"; import AdminsCreatePage from "main/pages/Admin/AdminsCreatePage"; -import InstructorsIndexPage from "main/pages/Admin/InstructorsIndexPage"; -import InstructorsCreatePage from "main/pages/Admin/InstructorsCreatePage"; +import ResearchersIndexPage from "main/pages/Admin/ResearchersIndexPage"; +import ResearchersCreatePage from "main/pages/Admin/ResearchersCreatePage"; import ProtectedPage from "main/pages/Auth/ProtectedPage"; -import LegacyHomePage from "main/pages/Home/LegacyHomePage"; -import ConceptGraphPage from "main/pages/ConceptGraphPage"; import NotFoundPage from "main/pages/Auth/NotFoundPage"; -import AboutScaffold from "main/pages/Help/AboutScaffold"; +import AboutCitelines from "main/pages/Help/AboutCitelines"; import SignInPage from "main/pages/Auth/SignInPage"; import SignInSuccessPage from "main/pages/Auth/SignInSuccessPage"; import { useCurrentUser } from "main/utils/currentUser"; import AdminDeveloperPage from "main/pages/Admin/AdminDeveloperPage"; -import InstructorCoursesIndexPage from "main/pages/Courses/InstructorCoursesIndexPage"; -import AdminCoursesIndexPage from "main/pages/Admin/AdminCoursesIndexPage"; import HomePageLoggedIn from "main/pages/Home/HomePageLoggedIn"; import HomePageLoggedOut from "main/pages/Home/HomePageLoggedOut"; import AdminJobsPage from "main/pages/Admin/AdminJobsPage"; -import InstructorCourseShowPage from "main/pages/Courses/InstructorCourseShowPage"; -import UserProfilePage from "main/pages/UserProfilePage"; export default function App() { const currentUser = useCurrentUser(); @@ -39,18 +33,7 @@ export default function App() { - } /> - } /> - } - enforceRole={"ROLE_USER"} - currentUser={currentUser} - /> - } - /> + } /> } /> } /> } + component={} enforceRole={"ROLE_ADMIN"} currentUser={currentUser} /> @@ -84,10 +67,10 @@ export default function App() { } /> } + component={} enforceRole={"ROLE_ADMIN"} currentUser={currentUser} /> @@ -103,36 +86,6 @@ export default function App() { /> } /> - } - enforceRole={"ROLE_ADMIN"} - currentUser={currentUser} - /> - } - /> - } - enforceRole={"ROLE_INSTRUCTOR"} - currentUser={currentUser} - /> - } - /> - } - enforceRole={"ROLE_INSTRUCTOR"} - currentUser={currentUser} - /> - } - /> } /> - } - enforceRole={"ROLE_USER"} - currentUser={currentUser} - /> - } - /> } /> diff --git a/frontend/src/fixtures/conceptsFixtures.js b/frontend/src/fixtures/conceptsFixtures.js deleted file mode 100644 index a21ced3..0000000 --- a/frontend/src/fixtures/conceptsFixtures.js +++ /dev/null @@ -1,24 +0,0 @@ -const conceptsFixtures = { - severalConcepts: [ - { - id: 1, - label: "Variables", - description: "Named storage locations for values.", - example: "int x = 3;", - level: 1, - x: 125, - y: 250, - }, - { - id: 2, - label: "Loops", - description: "Repeated execution of a block of code.", - example: "for (let i = 0; i < 10; i++) {}", - level: 2, - x: 300, - y: 420, - }, - ], -}; - -export default conceptsFixtures; diff --git a/frontend/src/fixtures/courseMenuFixtures.ts b/frontend/src/fixtures/courseMenuFixtures.ts deleted file mode 100644 index 04aac80..0000000 --- a/frontend/src/fixtures/courseMenuFixtures.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { schoolFixtures } from "./schoolFixtures"; - -export interface CourseAccess { - id: number; - courseName: string; - term: string; - school: { displayName: string; key: string }; - instructorEmail: string; - studentAccess: boolean; - staffAccess: boolean; - instructorAccess: boolean; - adminAccess: boolean; -} - -const courseMenuFixtures = { - // No courses at all - empty: [] as CourseAccess[], - - // Only student access courses - studentOnly: [ - { - id: 1, - courseName: "CMPSC 8", - term: "S26", - school: schoolFixtures.ucsb, - instructorEmail: "diba@ucsb.edu", - studentAccess: true, - staffAccess: false, - instructorAccess: false, - adminAccess: false, - }, - ] as CourseAccess[], - - // A mix of instructor, staff, and student courses, including a course - // with student and staff access (should only appear under Staff), and - // a course with instructor, staff and student access (should only - // appear under Instructor). - mixed: [ - { - id: 1, - courseName: "CMPSC 8", - term: "S26", - school: schoolFixtures.ucsb, - instructorEmail: "diba@ucsb.edu", - studentAccess: true, - staffAccess: true, - instructorAccess: true, - adminAccess: false, - }, - { - id: 2, - courseName: "CMPSC 5A", - term: "F26", - school: schoolFixtures.other, - instructorEmail: "ykk@ucsb.edu", - studentAccess: true, - staffAccess: true, - instructorAccess: false, - adminAccess: false, - }, - { - id: 3, - courseName: "CMPSC 5B", - term: "F26", - school: schoolFixtures.other, - instructorEmail: "phtcon@ucsb.edu", - studentAccess: true, - staffAccess: false, - instructorAccess: false, - adminAccess: false, - }, - ] as CourseAccess[], -}; - -export default courseMenuFixtures; diff --git a/frontend/src/fixtures/courseStaffFixtures.js b/frontend/src/fixtures/courseStaffFixtures.js deleted file mode 100644 index 275b334..0000000 --- a/frontend/src/fixtures/courseStaffFixtures.js +++ /dev/null @@ -1,75 +0,0 @@ -const courseStaffFixtures = { - oneStaff: [ - { - id: 1, - firstName: "Dr. John", - lastName: "Professor", - email: "johnprof@ucsb.edu", - }, - ], - - threeStaff: [ - { - id: 1, - firstName: "Dr. John", - lastName: "Professor", - email: "johnprof@ucsb.edu", - }, - - { - id: 2, - firstName: "Dr. Jane", - lastName: "Instructor", - email: "janeinstr@ucsb.edu", - }, - - { - id: 3, - firstName: "Mark", - lastName: "TA", - email: "markta@ucsb.edu", - }, - ], - sixStaff: [ - { - id: 1, - firstName: "Dr. John", - lastName: "Professor", - email: "johnprof@ucsb.edu", - }, - - { - id: 2, - firstName: "Dr. Jane", - lastName: "Instructor", - email: "janeinstr@ucsb.edu", - }, - - { - id: 3, - firstName: "Mark", - lastName: "TA", - email: "markta@ucsb.edu", - }, - { - id: 4, - firstName: "Sarah", - lastName: "Lead", - email: "sarahlead@ucsb.edu", - }, - { - id: 5, - firstName: "Mike", - lastName: "Assistant", - email: "mikeassist@ucsb.edu", - }, - { - id: 6, - firstName: "Fred", - lastName: "Assistant", - email: "fredassist@ucsb.edu", - }, - ], -}; - -export { courseStaffFixtures }; diff --git a/frontend/src/fixtures/coursesFixtures.js b/frontend/src/fixtures/coursesFixtures.js deleted file mode 100644 index 65283f3..0000000 --- a/frontend/src/fixtures/coursesFixtures.js +++ /dev/null @@ -1,34 +0,0 @@ -import { schoolFixtures } from "./schoolFixtures"; -const coursesFixtures = { - severalCourses: [ - { - id: 1, - courseName: "CMPSC 8", - term: "S26", - school: schoolFixtures.ucsb, - instructorEmail: "diba@ucsb.edu", - numStudents: 25, - numStaff: 3, - }, - { - id: 2, - courseName: "CMPSC 5A", - term: "F26", - school: schoolFixtures.other, - instructorEmail: "ykk@ucsb.edu", - numStudents: 18, - numStaff: 2, - }, - { - id: 3, - courseName: "CMPSC 5B", - term: "F26", - school: schoolFixtures.other, - instructorEmail: "phtcon@ucsb.edu", - numStudents: 0, - numStaff: 0, - }, - ], -}; - -export default coursesFixtures; diff --git a/frontend/src/fixtures/currentUserFixtures.ts b/frontend/src/fixtures/currentUserFixtures.ts index 6448148..5a7fab1 100644 --- a/frontend/src/fixtures/currentUserFixtures.ts +++ b/frontend/src/fixtures/currentUserFixtures.ts @@ -10,12 +10,12 @@ export const apiCurrentUserFixtures = { roles: [ { authority: "ROLE_USER" }, { authority: "ROLE_ADMIN" }, - { authority: "ROLE_INSTRUCTOR" }, + { authority: "ROLE_RESEARCHER" }, ], }, - instructorUser: { + researcherUser: { user: { email: "diba@ucsb.edu", givenName: "Diba" }, - roles: [{ authority: "ROLE_USER" }, { authority: "ROLE_INSTRUCTOR" }], + roles: [{ authority: "ROLE_USER" }, { authority: "ROLE_RESEARCHER" }], }, } satisfies Record; @@ -32,14 +32,14 @@ export const currentUserFixtures = { loggedIn: true as const, root: { ...apiCurrentUserFixtures.adminUser, - rolesList: ["ROLE_USER", "ROLE_ADMIN", "ROLE_INSTRUCTOR"], + rolesList: ["ROLE_USER", "ROLE_ADMIN", "ROLE_RESEARCHER"], }, }, - instructorUser: { + researcherUser: { loggedIn: true as const, root: { - ...apiCurrentUserFixtures.instructorUser, - rolesList: ["ROLE_USER", "ROLE_INSTRUCTOR"], + ...apiCurrentUserFixtures.researcherUser, + rolesList: ["ROLE_USER", "ROLE_RESEARCHER"], }, }, } satisfies Record; diff --git a/frontend/src/fixtures/edgesFixtures.js b/frontend/src/fixtures/edgesFixtures.js deleted file mode 100644 index 2c1410e..0000000 --- a/frontend/src/fixtures/edgesFixtures.js +++ /dev/null @@ -1,12 +0,0 @@ -const edgesFixtures = { - severalEdges: [ - { - id: 10, - sourceId: 1, - targetId: 2, - color: null, - }, - ], -}; - -export default edgesFixtures; diff --git a/frontend/src/fixtures/jobsByCourseFixtures.js b/frontend/src/fixtures/jobsByCourseFixtures.js deleted file mode 100644 index 748b950..0000000 --- a/frontend/src/fixtures/jobsByCourseFixtures.js +++ /dev/null @@ -1,67 +0,0 @@ -const jobsByCourseFixtures = { - threeJobs: [ - { - id: 1, - jobName: "PushTeamsToGitHubJob", - createdByEmail: "phtcon@ucsb.edu", - scopeType: "course", - scopeId: 5, - createdAt: "2023-01-01T10:00:00", - updatedAt: "2023-01-01T10:05:00", - status: "complete", - log: "Job completed successfully", - }, - { - id: 2, - jobName: "AddIndividualTeam", - createdByEmail: "phtcon@ucsb.edu", - scopeType: "course", - scopeId: 5, - createdAt: "2023-01-02T10:00:00", - updatedAt: "2023-01-02T10:05:00", - status: "error", - log: "Job failed with error: Connection timeout", - }, - { - id: 3, - jobName: "AddTeamMemberToGithubJob", - createdByEmail: "phtcon@ucsb.edu", - scopeType: "course", - scopeId: 5, - createdAt: "2023-01-03T10:00:00", - updatedAt: "2023-01-03T10:05:00", - status: "running", - log: "Job is currently running...", - }, - ], - oneJob: [ - { - id: 1, - jobName: "PushTeamsToGitHubJob", - createdByEmail: "phtcon@ucsb.edu", - scopeType: "course", - scopeId: 5, - createdAt: "2023-01-01T10:00:00", - updatedAt: "2023-01-01T10:05:00", - status: "complete", - log: "Job completed successfully", - }, - ], - longLogJob: [ - { - id: 1, - jobName: "PushTeamsToGitHubJob", - createdByEmail: "phtcon@ucsb.edu", - scopeType: "course", - scopeId: 5, - createdAt: "2023-01-01T10:00:00", - updatedAt: "2023-01-01T10:05:00", - status: "complete", - log: "This is a very long log message that should be displayed in a scrollable container. ".repeat( - 20, - ), - }, - ], -}; - -export { jobsByCourseFixtures }; diff --git a/frontend/src/fixtures/roleEmailFixtures.js b/frontend/src/fixtures/roleEmailFixtures.js index 7ce23ce..304cc1e 100644 --- a/frontend/src/fixtures/roleEmailFixtures.js +++ b/frontend/src/fixtures/roleEmailFixtures.js @@ -1,21 +1,21 @@ const roleEmailFixtures = { oneItem: { - email: "instructor1@example.com", + email: "researcher1@example.com", }, threeItems: [ { - email: "instructor1@example.com", + email: "researcher1@example.com", }, { email: "admin1@example.com", }, { - email: "instructor2@example.com", + email: "researcher2@example.com", }, ], threeItemsWithIsInAdminEmailField: [ { - email: "instructor1@example.com", + email: "researcher1@example.com", isInAdminEmails: true, }, { @@ -23,7 +23,7 @@ const roleEmailFixtures = { isInAdminEmails: false, }, { - email: "instructor2@example.com", + email: "researcher2@example.com", isInAdminEmails: false, }, ], diff --git a/frontend/src/fixtures/rosterStudentFixtures.js b/frontend/src/fixtures/rosterStudentFixtures.js deleted file mode 100644 index fbaec6d..0000000 --- a/frontend/src/fixtures/rosterStudentFixtures.js +++ /dev/null @@ -1,130 +0,0 @@ -const rosterStudentFixtures = { - oneStudent: [ - { - id: 1, - studentId: "1234567", - firstName: "Bob", - lastName: "Smith", - email: "bobsmith@ucsb.edu", - }, - ], - threeStudents: [ - { - id: 3, - studentId: "A123456", - firstName: "Alice", - lastName: "Brown", - email: "alicebrown@ucsb.edu", - }, - - { - id: 4, - studentId: "X123456", - firstName: "Tom", - lastName: "Hanks", - email: "tomhanks@ucsb.edu", - }, - - { - id: 6, - studentId: "Z123456", - firstName: "Emma", - lastName: "Watson", - email: "emmawatson@ucsb.edu", - }, - ], - sixStudents: [ - { - id: 1, - studentId: "A123456", - firstName: "Alice", - lastName: "Brown", - email: "alicebrown@ucsb.edu", - }, - - { - id: 2, - studentId: "X123456", - firstName: "Tom", - lastName: "Hanks", - email: "tomhanks@ucsb.edu", - }, - - { - id: 3, - studentId: "Z123456", - firstName: "Emma", - lastName: "Watson", - email: "emmawatson@ucsb.edu", - }, - { - id: 4, - studentId: "B123456", - firstName: "Jon", - lastName: "Snow", - email: "jonsnow@ucsb.edu", - }, - { - id: 5, - studentId: "C123456", - firstName: "Bob", - lastName: "Smith", - email: "bobsmith@ucsb.edu", - }, - { - id: 6, - studentId: "D123456", - firstName: "Arya", - lastName: "Sue", - email: "aryasue@ucsb.edu", - }, - ], - fourStudentsOneDropped: [ - { - id: 3, - studentId: "A123456", - firstName: "Alice", - lastName: "Brown", - email: "alicebrown@ucsb.edu", - }, - - { - id: 4, - studentId: "X123456", - firstName: "Tom", - lastName: "Hanks", - email: "tomhanks@ucsb.edu", - }, - - { - id: 6, - studentId: "Z123456", - firstName: "Emma", - lastName: "Watson", - email: "emmawatson@ucsb.edu", - }, - { - id: 8, - studentId: "D123456", - firstName: "Arya", - lastName: "Sue", - email: "aryasue@ucsb.edu", - rosterStatus: "DROPPED", - }, - ], -}; - -const loadResultFixtures = { - successful: { - created: 1, - updated: 2, - rejected: [], - }, - failed: { - created: 3, - updated: 4, - rejected: rosterStudentFixtures.oneStudent, - }, -}; - -export { rosterStudentFixtures, loadResultFixtures }; diff --git a/frontend/src/fixtures/schoolFixtures.js b/frontend/src/fixtures/schoolFixtures.js deleted file mode 100644 index c08723f..0000000 --- a/frontend/src/fixtures/schoolFixtures.js +++ /dev/null @@ -1,22 +0,0 @@ -const schoolFixtures = { - ucsb: { - alternateNames: [ - "UC Santa Barbara", - "University of California, Santa Barbara", - "SB", - ], - canvasImplementation: "https://ucsb.instructure.com/", - displayName: "UCSB", - key: "UCSB", - }, - other: { - alternateNames: ["Other"], - canvasImplementation: "", - displayName: "Other", - key: "OTHER", - }, -}; - -const schoolList = [schoolFixtures.ucsb, schoolFixtures.other]; - -export { schoolFixtures, schoolList }; diff --git a/frontend/src/fixtures/subConceptsFixtures.js b/frontend/src/fixtures/subConceptsFixtures.js deleted file mode 100644 index d5c4777..0000000 --- a/frontend/src/fixtures/subConceptsFixtures.js +++ /dev/null @@ -1,28 +0,0 @@ -const subConceptsFixtures = { - severalSubConcepts: [ - { - id: 11, - label: "Declaring variables", - description: "Create a variable with a name and type.", - example: 'String name = "Ada";', - parentId: 1, - parentLabel: "Variables", - parentLevel: 1, - parentX: 125, - sortOrder: 1, - }, - { - id: 12, - label: "Updating variables", - description: "Assign a new value to an existing variable.", - example: "count = count + 1;", - parentId: 1, - parentLabel: "Variables", - parentLevel: 1, - parentX: 125, - sortOrder: 2, - }, - ], -}; - -export default subConceptsFixtures; diff --git a/frontend/src/fixtures/usersFixtures.js b/frontend/src/fixtures/usersFixtures.js index 1ff5f95..75cce41 100644 --- a/frontend/src/fixtures/usersFixtures.js +++ b/frontend/src/fixtures/usersFixtures.js @@ -10,7 +10,7 @@ const usersFixtures = { givenName: "Phill", familyName: "Conrad", admin: true, - instructor: false, + researcher: false, }, { id: 2, @@ -22,7 +22,7 @@ const usersFixtures = { givenName: "Phillip", familyName: "Conrad", admin: false, - instructor: false, + researcher: false, }, { id: 3, @@ -34,7 +34,7 @@ const usersFixtures = { givenName: "Craig", familyName: "Zzyxx", admin: false, - instructor: true, + researcher: true, }, ], }; diff --git a/frontend/src/main/api/legacyClient.ts b/frontend/src/main/api/legacyClient.ts deleted file mode 100644 index 48b1ce3..0000000 --- a/frontend/src/main/api/legacyClient.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Backend access for LegacyHomePage.tsx and its private components ONLY. -// Every endpoint here lives under /api/legacy/, a frozen copy of the backend -// contract, so the non-legacy endpoints can evolve without touching the legacy -// page. Do not consolidate this with the useBackend-based data access used by -// ConceptGraphPage, and do not import this module outside the LegacyHomePage tree. - -const API_BASE = "/api/legacy"; - -export interface Assessment { - id: string; - pl_assessment_id: string; - name: string; -} - -export interface Question { - id: string; - assessment_id: string; - pl_question_uuid: string; - title: string; -} - -export interface QuestionConcept { - id: string; - question_id: string; - concept_id: string; - subconcept_label: string | null; -} - -export async function fetchAssessments(): Promise { - const res = await fetch(`${API_BASE}/assessments`); - return res.json(); -} - -export async function fetchQuestions( - assessmentId: string, -): Promise { - const res = await fetch(`${API_BASE}/assessments/${assessmentId}/questions`); - return res.json(); -} - -export async function fetchQuestionConcepts( - questionId: string, -): Promise { - const res = await fetch(`${API_BASE}/questions/${questionId}/concepts`); - return res.json(); -} - -export interface LegacyUserStateResponse { - starred_ids: string[]; - detail_cards: unknown[]; - mastered_subconcepts: string[]; -} - -export async function fetchLegacyUserState( - userid: number, -): Promise { - const res = await fetch(`${API_BASE}/user-state/${userid}`); - if (res.status === 404) return null; - if (!res.ok) - throw new Error(`Failed to fetch user state for userid ${userid}`); - return res.json(); -} - -export async function saveLegacyUserState(body: { - userid: number; - starred_ids: string[]; - detail_cards: unknown[]; - mastered_subconcepts: string[]; -}): Promise { - const res = await fetch(`${API_BASE}/user-state`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) - throw new Error(`Failed to save user state for userid ${body.userid}`); -} - -export async function logLegacyUserActivity(body: { - userid: number; - event_type: string; - payload: object; -}): Promise { - const res = await fetch(`${API_BASE}/user-activity`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) - throw new Error(`Failed to log user activity for userid ${body.userid}`); -} diff --git a/frontend/src/main/components/Concept/ConceptDeleteModal.jsx b/frontend/src/main/components/Concept/ConceptDeleteModal.jsx deleted file mode 100644 index 0f8183f..0000000 --- a/frontend/src/main/components/Concept/ConceptDeleteModal.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import Modal from "react-bootstrap/Modal"; -import { useForm } from "react-hook-form"; -import { Form } from "react-bootstrap"; - -export default function ConceptDeleteModal({ - onSubmitAction, - showModal, - toggleShowModal, - modalTitle = "Delete Concept", - buttonText = "Delete Concept", - message = "Are you sure you want to delete this concept? Deleting a top-level concept also deletes all of its subconcepts.", - testId = "ConceptDeleteModal", -}) { - const hideModal = () => { - toggleShowModal(false); - }; - - const { handleSubmit } = useForm(); - - return ( - - {modalTitle} -
- - {message} - - - - -
-
- ); -} diff --git a/frontend/src/main/components/Concept/ConceptModal.jsx b/frontend/src/main/components/Concept/ConceptModal.jsx deleted file mode 100644 index 2f34af2..0000000 --- a/frontend/src/main/components/Concept/ConceptModal.jsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useEffect, useMemo } from "react"; -import Modal from "react-bootstrap/Modal"; -import { Form } from "react-bootstrap"; -import { Controller, useForm } from "react-hook-form"; -import SimpleMdeReact from "react-simplemde-editor"; -import "easymde/dist/easymde.min.css"; - -export default function ConceptModal({ - onSubmitAction, - showModal, - toggleShowModal, - initialContents, - buttonText = "Create", - modalTitle = "Create Concept", -}) { - const { - control, - formState: { errors }, - handleSubmit, - reset, - } = useForm({ - defaultValues: initialContents ?? {}, - }); - - useEffect(() => { - reset(initialContents ?? {}); - }, [initialContents, reset]); - - const editorOptions = useMemo(() => ({ spellChecker: false }), []); - const labelEditorOptions = useMemo( - () => ({ spellChecker: false, minHeight: "50px", toolbar: false }), - [], - ); - - const closeModal = () => { - toggleShowModal(false); - }; - - return ( - - - {modalTitle} - - -
- - - Label -
- ( - - )} - /> -
- {errors.label && ( -
- {errors.label?.message} -
- )} -
- - Description -
- ( - - )} - /> -
-
- - Example -
- ( - - )} - /> -
-
-
- - - -
-
- ); -} diff --git a/frontend/src/main/components/Concept/ConceptSelector.jsx b/frontend/src/main/components/Concept/ConceptSelector.jsx deleted file mode 100644 index 3fa7993..0000000 --- a/frontend/src/main/components/Concept/ConceptSelector.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Form } from "react-bootstrap"; -import { useBackend } from "main/utils/useBackend"; - -export default function ConceptSelector({ - courseId, - onSelect, - testId = "ConceptSelector", -}) { - const { data: concepts = [] } = useBackend( - ["/api/concepts/top-level", courseId], - { - method: "GET", - url: "/api/concepts/top-level", - params: { courseId }, - }, - [], - ); - - const handleChange = (e) => { - const selectedId = e.target.value; - if (selectedId === "") { - onSelect(null); - } else { - const concept = concepts.find((c) => String(c.id) === selectedId); - onSelect(concept || null); - } - }; - - return ( - - - {concepts.map((concept) => ( - - ))} - - ); -} diff --git a/frontend/src/main/components/Concept/ConceptTable.jsx b/frontend/src/main/components/Concept/ConceptTable.jsx deleted file mode 100644 index acb08d3..0000000 --- a/frontend/src/main/components/Concept/ConceptTable.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import OurTable, { ButtonColumn } from "main/components/Common/OurTable"; - -export default function ConceptTable({ - concepts = [], - editCallback = () => {}, - deleteCallback = () => {}, - showButtons = true, - testId = "ConceptTable", -}) { - const columns = [ - { - header: "id", - accessorKey: "id", - id: "id", - }, - { - header: "label", - accessorKey: "label", - id: "label", - }, - { - header: "level", - accessorKey: "level", - id: "level", - }, - { - header: "x", - accessorKey: "x", - id: "x", - }, - { - header: "y", - accessorKey: "y", - id: "y", - }, - ]; - - if (showButtons) { - columns.push( - ButtonColumn("Edit", "primary", editCallback, testId), - ButtonColumn("Delete", "danger", deleteCallback, testId), - ); - } - - return ; -} diff --git a/frontend/src/main/components/Concept/EdgeTable.jsx b/frontend/src/main/components/Concept/EdgeTable.jsx deleted file mode 100644 index 62b65a3..0000000 --- a/frontend/src/main/components/Concept/EdgeTable.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import OurTable, { ButtonColumn } from "main/components/Common/OurTable"; - -export default function EdgeTable({ - edges = [], - deleteCallback = () => {}, - testId = "EdgeTable", -}) { - const columns = [ - { - header: "id", - accessorKey: "id", - id: "id", - }, - { - header: "From (source)", - accessorKey: "sourceLabel", - id: "sourceLabel", - }, - { - header: "To (target)", - accessorKey: "targetLabel", - id: "targetLabel", - }, - ButtonColumn("Delete", "danger", deleteCallback, testId), - ]; - - return ; -} diff --git a/frontend/src/main/components/Concept/SubConceptModal.jsx b/frontend/src/main/components/Concept/SubConceptModal.jsx deleted file mode 100644 index 80228ac..0000000 --- a/frontend/src/main/components/Concept/SubConceptModal.jsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useEffect, useMemo } from "react"; -import Modal from "react-bootstrap/Modal"; -import { Form } from "react-bootstrap"; -import { Controller, useForm } from "react-hook-form"; -import SimpleMdeReact from "react-simplemde-editor"; -import "easymde/dist/easymde.min.css"; - -const normalizeInitialContents = (initialContents) => ({ - ...initialContents, - parentId: initialContents?.parentId ?? initialContents?.parent?.id ?? "", - parentLabel: - initialContents?.parentLabel ?? initialContents?.parent?.label ?? "", -}); - -export default function SubConceptModal({ - onSubmitAction, - showModal, - toggleShowModal, - initialContents, - buttonText = "Create", - modalTitle = "Create SubConcept", -}) { - const { - control, - register, - formState: { errors }, - handleSubmit, - reset, - } = useForm({ - defaultValues: normalizeInitialContents(initialContents), - }); - - useEffect(() => { - reset(normalizeInitialContents(initialContents)); - }, [initialContents, reset]); - - const editorOptions = useMemo(() => ({ spellChecker: false }), []); - const labelEditorOptions = useMemo( - () => ({ spellChecker: false, minHeight: "50px", toolbar: false }), - [], - ); - - const closeModal = () => { - toggleShowModal(false); - }; - - return ( - - - {modalTitle} - - -
- - - Parent Id - - - - Parent Label - - - - Label -
- ( - - )} - /> -
- {errors.label && ( -
- {errors.label?.message} -
- )} -
- - Description -
- ( - - )} - /> -
-
- - Example -
- ( - - )} - /> -
-
-
- - - -
-
- ); -} diff --git a/frontend/src/main/components/Concept/SubConceptTable.jsx b/frontend/src/main/components/Concept/SubConceptTable.jsx deleted file mode 100644 index f3588fb..0000000 --- a/frontend/src/main/components/Concept/SubConceptTable.jsx +++ /dev/null @@ -1,72 +0,0 @@ -import OurTable, { ButtonColumn } from "main/components/Common/OurTable"; - -export default function SubConceptTable({ - subConcepts = [], - editCallback = () => {}, - deleteCallback = () => {}, - testId = "SubConceptTable", -}) { - const columns = [ - { - header: "id", - accessorKey: "id", - id: "id", - }, - { - header: "label", - accessorKey: "label", - id: "label", - }, - { - header: "parentId", - accessorKey: "parentId", - id: "parentId", - }, - { - header: "parentLabel", - accessorKey: "parentLabel", - id: "parentLabel", - }, - { - header: "parentLevel", - accessorKey: "parentLevel", - id: "parentLevel", - }, - { - header: "parentX", - accessorKey: "parentX", - id: "parentX", - }, - { - header: "sortOrder", - accessorKey: "sortOrder", - id: "sortOrder", - }, - ButtonColumn("Edit", "primary", editCallback, testId), - ButtonColumn("Delete", "danger", deleteCallback, testId), - ]; - - const sortedSubConcepts = [...subConcepts].sort((a, b) => { - if ((a.parentLevel ?? 0) !== (b.parentLevel ?? 0)) { - return (a.parentLevel ?? 0) - (b.parentLevel ?? 0); - } - - if ((a.parentX ?? 0) !== (b.parentX ?? 0)) { - return (a.parentX ?? 0) - (b.parentX ?? 0); - } - - if ((a.parentLabel ?? "") !== (b.parentLabel ?? "")) { - return (a.parentLabel ?? "").localeCompare(b.parentLabel ?? ""); - } - - if ((a.sortOrder ?? 0) !== (b.sortOrder ?? 0)) { - return (a.sortOrder ?? 0) - (b.sortOrder ?? 0); - } - - return (a.id ?? 0) - (b.id ?? 0); - }); - - return ( - - ); -} diff --git a/frontend/src/main/components/CourseStaff/CourseStaffCSVUploadForm.jsx b/frontend/src/main/components/CourseStaff/CourseStaffCSVUploadForm.jsx deleted file mode 100644 index 7e85cbe..0000000 --- a/frontend/src/main/components/CourseStaff/CourseStaffCSVUploadForm.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useForm } from "react-hook-form"; -import { Button, Form } from "react-bootstrap"; -import React from "react"; - -export default function CourseStaffCSVUploadForm({ submitAction }) { - const { - register, - formState: { errors }, - handleSubmit, - } = useForm(); - - return ( -
- - Upload Staff CSV - - - {errors.upload && "Staff CSV is required. "} - - - -
- ); -} diff --git a/frontend/src/main/components/CourseStaff/CourseStaffDeleteModal.jsx b/frontend/src/main/components/CourseStaff/CourseStaffDeleteModal.jsx deleted file mode 100644 index 27cb4d4..0000000 --- a/frontend/src/main/components/CourseStaff/CourseStaffDeleteModal.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import Modal from "react-bootstrap/Modal"; -import { useForm } from "react-hook-form"; -import { Form } from "react-bootstrap"; - -export default function CourseStaffDeleteModal({ - onSubmitAction, - showModal, - toggleShowModal, -}) { - const hideModal = () => { - toggleShowModal(false); - }; - - const { register, handleSubmit } = useForm(); - - return ( - - Delete Course Staff -
- - - Are you sure you want to delete this course staff member? - - - - - -
-
- ); -} diff --git a/frontend/src/main/components/CourseStaff/CourseStaffForm.jsx b/frontend/src/main/components/CourseStaff/CourseStaffForm.jsx deleted file mode 100644 index 72e040b..0000000 --- a/frontend/src/main/components/CourseStaff/CourseStaffForm.jsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Button, Form } from "react-bootstrap"; -import { useForm } from "react-hook-form"; -import { useNavigate } from "react-router"; -import regexUtils from "main/utils/regexUtils"; - -function CourseStaffForm({ - initialContents, - submitAction, - buttonLabel = "Create", - cancelDisabled = false, -}) { - // Stryker disable all - const { - register, - formState: { errors }, - handleSubmit, - } = useForm({ defaultValues: initialContents || {} }); - // Stryker restore all - - const navigate = useNavigate(); - - const testIdPrefix = "CourseStaffForm"; - - return ( -
- - First Name - - - {errors.firstName?.message} - - - - - Last Name - - - {errors.lastName?.message} - - - - {!initialContents && ( - - Email - - - {errors.email?.message} - - - )} - - {initialContents && ( - - Email - - - )} - - - {!cancelDisabled && ( - - )} -
- ); -} - -export default CourseStaffForm; diff --git a/frontend/src/main/components/CourseStaff/CourseStaffTable.jsx b/frontend/src/main/components/CourseStaff/CourseStaffTable.jsx deleted file mode 100644 index 1a05478..0000000 --- a/frontend/src/main/components/CourseStaff/CourseStaffTable.jsx +++ /dev/null @@ -1,162 +0,0 @@ -import React from "react"; -import OurTable, { ButtonColumn } from "main/components/Common/OurTable"; -import { Tooltip, OverlayTrigger } from "react-bootstrap"; - -import { useBackendMutation } from "main/utils/useBackend"; -import { hasRole } from "main/utils/currentUser"; -import Modal from "react-bootstrap/Modal"; -import CourseStaffForm from "main/components/CourseStaff/CourseStaffForm"; -import { toast } from "react-toastify"; -import CourseStaffDeleteModal from "main/components/CourseStaff/CourseStaffDeleteModal"; - -export default function CourseStaffTable({ - staff, - currentUser, - courseId, - testIdPrefix = "CourseStaffTable", - isInstructor = true, -}) { - const [showEditModal, setShowEditModal] = React.useState(false); - const [editStaff, setEditStaff] = React.useState(null); - const [showDeleteModal, setShowDeleteModal] = React.useState(false); - const [deleteStaff, setDeleteStaff] = React.useState(null); - - function cellToAxiosParamsDelete(formData) { - return { - // Stryker disable next-line StringLiteral - url: "/api/coursestaff/delete", - method: "DELETE", - params: { - id: formData.id, - courseId: courseId, - }, - }; - } - - const cellToAxiosParamsEdit = (formData) => ({ - url: `/api/coursestaff`, - method: "PUT", - // Stryker disable next-line ObjectLiteral - params: { - firstName: formData.firstName, - lastName: formData.lastName, - id: formData.id, - courseId: courseId, - }, - }); - - const hideModal = () => { - setShowEditModal(false); - }; - - const hideDeleteModal = () => { - setShowDeleteModal(false); - }; - - const onEditSuccess = () => { - toast("Staff member updated successfully."); - hideModal(); - }; - - const onDeleteSuccess = () => { - toast("Staff member deleted successfully."); - hideDeleteModal(); - }; - - const deleteMutation = useBackendMutation( - cellToAxiosParamsDelete, - { onSuccess: onDeleteSuccess }, - [`/api/coursestaff/course?courseId=${courseId}`], - ); - - // Stryker disable next-line all - const deleteCallback = async (cell) => { - setShowDeleteModal(true); - setDeleteStaff(cell.row.original.id); - }; - - const submitDeleteForm = (data) => { - deleteMutation.mutate({ - id: deleteStaff, - ...data, - }); - }; - - const editMutation = useBackendMutation( - cellToAxiosParamsEdit, - { onSuccess: onEditSuccess }, - [`/api/coursestaff/course?courseId=${courseId}`], - ); - - const editCallback = (cell) => { - setEditStaff(cell.row.original); - setShowEditModal(true); - }; - - const submitEditForm = (data) => { - editMutation.mutate(data); - }; - - const columns = [ - { - header: "id", - accessorKey: "id", - id: "id", - }, - { - header: "First Name", - accessorKey: "firstName", - }, - { - header: "Last Name", - accessorKey: "lastName", - }, - { - header: "Email", - accessorKey: "email", - }, - ]; - - if ( - isInstructor && - (hasRole(currentUser, "ROLE_INSTRUCTOR") || - hasRole(currentUser, "ROLE_ADMIN")) - ) { - columns.push(ButtonColumn("Edit", "primary", editCallback, testIdPrefix)); - columns.push( - ButtonColumn("Delete", "danger", deleteCallback, testIdPrefix), - ); - } - - return ( - <> - - - Edit Staff Member - - - - - - - -
- - ); -} diff --git a/frontend/src/main/components/Courses/CourseDownloadsForm.jsx b/frontend/src/main/components/Courses/CourseDownloadsForm.jsx deleted file mode 100644 index 876cc8b..0000000 --- a/frontend/src/main/components/Courses/CourseDownloadsForm.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import { Button, Form } from "react-bootstrap"; - -export default function CourseDownloadsForm({ downloadAction, testIdPrefix }) { - const handleSubmit = (event) => { - event.preventDefault(); - downloadAction(); - }; - - return ( -
- -

- Course Downloads -

- - Export utility options available for this course instance. - -
- {} - -
- ); -} diff --git a/frontend/src/main/components/Courses/CourseMenu.tsx b/frontend/src/main/components/Courses/CourseMenu.tsx deleted file mode 100644 index 2eed9c9..0000000 --- a/frontend/src/main/components/Courses/CourseMenu.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Link } from "react-router"; -import { NavDropdown } from "react-bootstrap"; -import { useBackend } from "main/utils/useBackend"; -import { type CurrentUser } from "main/utils/currentUser"; -import { GraphIcon } from "main/components/Common/Icons"; - -export interface CourseAccess { - id: number; - courseName: string; - term: string; - school: { displayName: string; key: string }; - instructorEmail: string; - studentAccess: boolean; - staffAccess: boolean; - instructorAccess: boolean; - adminAccess: boolean; -} - -function CourseMenuItem({ course }: { course: CourseAccess }) { - return ( - - - {`${course.courseName} ${course.term}, ${course.school.displayName}, ${course.instructorEmail}, ${course.id}`} - - ); -} - -function CourseMenuSection({ - heading, - courses, -}: { - heading: string; - courses: CourseAccess[]; -}) { - if (courses.length === 0) { - return null; - } - return ( - <> - {heading} - {courses.map((course) => ( - - ))} - - ); -} - -export default function CourseMenu({ - currentUser, -}: { - currentUser: CurrentUser; -}): React.JSX.Element | null { - const { data: courses } = useBackend( - ["/api/courses/list"], - { method: "GET", url: "/api/courses/list" }, - // Stryker disable next-line all : don't test default value of empty list - [], - true, - { enabled: currentUser.loggedIn }, - ); - - const courseList = courses ?? []; - - if (!currentUser.loggedIn || courseList.length === 0) { - return null; - } - - const instructorCourses = courseList.filter((c) => c.instructorAccess); - const staffCourses = courseList.filter( - (c) => c.staffAccess && !c.instructorAccess, - ); - const studentCourses = courseList.filter( - (c) => c.studentAccess && !c.instructorAccess && !c.staffAccess, - ); - - if ( - instructorCourses.length === 0 && - staffCourses.length === 0 && - studentCourses.length === 0 - ) { - return null; - } - - return ( - - - - - - ); -} diff --git a/frontend/src/main/components/Courses/CourseModal.jsx b/frontend/src/main/components/Courses/CourseModal.jsx deleted file mode 100644 index 5d56dfa..0000000 --- a/frontend/src/main/components/Courses/CourseModal.jsx +++ /dev/null @@ -1,105 +0,0 @@ -import Modal from "react-bootstrap/Modal"; -import { Form } from "react-bootstrap"; -import { useForm } from "react-hook-form"; -import { useEffect } from "react"; -import { SchoolTypeahead } from "main/components/Courses/SchoolTypeahead"; - -function CourseModal({ - onSubmitAction, - showModal, - toggleShowModal, - initialContents, - buttonText = "Create", - modalTitle = "Create Course", -}) { - const { - register, - formState: { errors }, - handleSubmit, - reset, - control, - } = useForm({}); - - // Reset form when initialContents changes (e.g., when editing) - useEffect(() => { - reset(initialContents); - }, [initialContents, reset]); - - const closeModal = () => { - toggleShowModal(false); - }; - - return ( - - - {modalTitle} - - -
- - - Course Name - - - {errors.courseName?.message} - - Term - - - {errors.term?.message} - - - - School - - - {errors.school?.message} - - - - - - -
-
- ); -} - -export default CourseModal; diff --git a/frontend/src/main/components/Courses/CoursesTable.jsx b/frontend/src/main/components/Courses/CoursesTable.jsx deleted file mode 100644 index dab8220..0000000 --- a/frontend/src/main/components/Courses/CoursesTable.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import OurTable from "main/components/Common/OurTable"; -import { Tooltip, OverlayTrigger, Button, Spinner } from "react-bootstrap"; -import { Link } from "react-router"; -import LinkToScaffold from "../Scaffold/LinkToScaffold"; - -const baseColumns = [ - { - header: "id", - accessorKey: "id", // accessor is the "key" in the data - }, - { - header: "Course Name", - accessorKey: "courseName", - }, - { - header: "Term", - accessorKey: "term", - }, - { - header: "School", - id: "school", - accessorKey: "school.displayName", - }, -]; - -export default function CoursesTable({ - courses, - testId, - isLoading, - courseNameLinkPrefix, -}) { - const columns = courseNameLinkPrefix - ? baseColumns.map((column) => - column.accessorKey === "courseName" - ? { - ...column, - id: "courseName", - cell: ({ cell }) => ( - - ), - } - : column, - ) - : baseColumns; - - return ; -} diff --git a/frontend/src/main/components/Courses/InstructorAdminCoursesTable.jsx b/frontend/src/main/components/Courses/InstructorAdminCoursesTable.jsx deleted file mode 100644 index db9266e..0000000 --- a/frontend/src/main/components/Courses/InstructorAdminCoursesTable.jsx +++ /dev/null @@ -1,400 +0,0 @@ -import OurTable from "main/components/Common/OurTable"; -import { hasRole } from "main/utils/currentUser"; -import { Tooltip, OverlayTrigger, Button, Fade } from "react-bootstrap"; -import { useState } from "react"; -import { useBackendMutation } from "main/utils/useBackend"; -import { toast } from "react-toastify"; -import UpdateInstructorForm from "main/components/Courses/UpdateInstructorForm"; -import CourseModal from "main/components/Courses/CourseModal"; -import Modal from "react-bootstrap/Modal"; -import { useLocation } from "react-router"; -import LinkToScaffold from "main/components/Scaffold/LinkToScaffold"; -import LinkToSettings from "main/components/Scaffold/LinkToSettings"; - -export default function InstructorAdminCoursesTable({ - courses, - storybook = false, - currentUser, - testId = "InstructorAdminCoursesTable", - enableInstructorUpdate = false, - deleteCourseButton = false, - canEditCourse, - mutationQueryKeys = [ - "/api/courses/list/admins", - "/api/courses/list/instructors", - ], -}) { - const location = useLocation(); - - const canEdit = (row) => { - if (canEditCourse !== undefined) { - return typeof canEditCourse === "function" - ? canEditCourse(row) - : canEditCourse; - } - if (hasRole(currentUser, "ROLE_ADMIN")) { - return true; - } - if ( - hasRole(currentUser, "ROLE_INSTRUCTOR") && - row.original.instructorEmail === currentUser.root.user.email - ) { - return true; - } - - return false; - }; - - const [showModal, setShowModal] = useState(false); - const [selectedCourse, setSelectedCourse] = useState(null); - const [showCourseEditModal, setShowCourseEditModal] = useState(false); - const [selectedCourseForEdit, setSelectedCourseForEdit] = useState(null); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [courseToDelete, setCourseToDelete] = useState(null); - - const handleShowCourseEditModal = (course) => { - setSelectedCourseForEdit(course); - setShowCourseEditModal(true); - }; - - const handleCloseCourseEditModal = () => { - setShowCourseEditModal(false); - setSelectedCourseForEdit(null); - }; - - const columns = [ - { - header: "id", - accessorKey: "id", // accessor is the "key" in the data - }, - { - header: "Course Name", - id: "courseName", - cell: ({ cell }) => { - const course = cell.row.original; - return ( - - ); - }, - }, - { - header: "Settings", - id: "settings", - cell: ({ cell }) => { - const course = cell.row.original; - return ( - - ); - }, - }, - { - header: "Term", - accessorKey: "term", - }, - { - header: "School", - id: "school", - accessorKey: "school.displayName", - }, - { - header: "Edit", - id: "edit", - cell: ({ cell }) => { - const canEditCourse = canEdit(cell.row); - if (canEditCourse) { - return ( - - ); - } else { - return ( - - ); - } - }, - }, - { - header: "Students", - accessorKey: "numStudents", - }, - { - header: "Staff", - accessorKey: "numStaff", - }, - ]; - const cellToAxiosParamsEdit = (formData) => { - return { - url: `/api/courses/updateInstructor`, - method: "PUT", - params: { - courseId: formData.courseId, - instructorEmail: formData.instructorEmail, - }, - }; - }; - - const cellToAxiosParamsCourseEdit = (formData) => { - return { - url: `/api/courses`, - method: "PUT", - params: { - courseId: formData.courseId, - courseName: formData.courseName, - term: formData.term, - school: formData.school, - }, - }; - }; - - const cellToAxiosParamsDelete = (course) => ({ - url: "/api/courses", - method: "DELETE", - params: { - courseId: course.id, - }, - }); - - const onInstructorUpdateSuccess = () => { - handleCloseModal(); - }; - - const onInstructorUpdateError = (error) => { - if (error.response.data.message) - toast( - `Was not able to update instructor:\n${error.response.data.message}`, - ); - else toast(`Was not able to update instructor:\n${error.message}`); - }; - - const onCourseUpdateSuccess = () => { - handleCloseCourseEditModal(); - toast("Course updated successfully"); - }; - - const onCourseUpdateError = (error) => { - if (error.response.data.message) - toast(`Was not able to update course:\n${error.response.data.message}`); - else toast(`Was not able to update course:\n${error.message}`); - }; - - const onDeleteSuccess = () => { - toast("Course deleted successfully"); - setShowDeleteModal(false); - setCourseToDelete(null); - }; - - const onDeleteError = (error) => { - // Stryker disable next-line OptionalChaining : defensive coding for error shape - if (error.response?.data?.message) - toast(`Could not delete course:\n${error.response.data.message}`); - else toast(`Could not delete course:\n${error.message}`); - }; - - const editMutation = useBackendMutation( - cellToAxiosParamsEdit, - { - onSuccess: onInstructorUpdateSuccess, - onError: onInstructorUpdateError, - }, - mutationQueryKeys, - ); - - const courseEditMutation = useBackendMutation( - cellToAxiosParamsCourseEdit, - { - onSuccess: onCourseUpdateSuccess, - onError: onCourseUpdateError, - }, - mutationQueryKeys, - ); - - const deleteMutation = useBackendMutation( - cellToAxiosParamsDelete, - { - onSuccess: onDeleteSuccess, - onError: onDeleteError, - }, - mutationQueryKeys, - ); - - const handleShowModal = (course) => { - setSelectedCourse(course); - setShowModal(true); - }; - - const handleCloseModal = () => { - setShowModal(false); - setSelectedCourse(null); - }; - - const handleUpdateInstructor = async (formData) => { - formData.courseId = selectedCourse.id; - editMutation.mutate(formData); - }; - - const handleUpdateCourse = async (formData) => { - formData.courseId = selectedCourseForEdit.id; - formData.school = formData.school.key; - courseEditMutation.mutate(formData); - }; - - const columnsWithInstall = [ - ...columns, - { - header: "Instructor", - accessorKey: "instructorEmail", - cell: ({ cell }) => { - const isAdmin = hasRole(currentUser, "ROLE_ADMIN"); - if (isAdmin && enableInstructorUpdate) { - return ( - - ); - } else { - return cell.row.original.instructorEmail; - } - }, - }, - ...(deleteCourseButton - ? [ - { - header: "Delete", - id: "delete", - cell: ({ cell }) => { - const canDelete = - cell.row.original.numStudents === 0 && - cell.row.original.numStaff === 0; - - // Stryker disable ObjectLiteral,StringLiteral : cosmetic styling for disabled button - const disabledButtonStyle = { - background: "#A0A0A0", - padding: "1px", - borderRadius: "4px", - }; - // Stryker restore ObjectLiteral,StringLiteral - - if (!canDelete) { - return ( - - Cannot delete a course with active students or staff - - } - > - -
- -
-
-
- ); - } - - return ( - - ); - }, - }, - ] - : []), - ]; - - return ( - <> - - - Update Instructor - - - - - - - - setShowDeleteModal(false)} - centered - > - - Confirm Delete - - - - {courseToDelete && ( -

- Please confirm that you really want to delete course{" "} - {courseToDelete.courseName}. This action cannot - be undone. -

- )} -
- - - - - - -
- - - - ); -} diff --git a/frontend/src/main/components/Courses/SchoolTypeahead.jsx b/frontend/src/main/components/Courses/SchoolTypeahead.jsx deleted file mode 100644 index f63f666..0000000 --- a/frontend/src/main/components/Courses/SchoolTypeahead.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useBackend } from "main/utils/useBackend"; -import { Typeahead } from "react-bootstrap-typeahead"; -import { Controller } from "react-hook-form"; - -export function SchoolTypeahead({ control, rules, testid }) { - const { data: schools = [] } = useBackend( - [`/api/systemInfo/schools`], - { - method: "GET", - url: `/api/systemInfo/schools`, - }, - undefined, - true, - { - staleTime: "static", - }, - ); - - const filterByFields = (option, props) => { - const search = props.text.toLowerCase(); - return ( - option.displayName.toLowerCase().includes(search) || - option.alternateNames.some((name) => name.toLowerCase().includes(search)) - ); - }; - return ( - ( - field.onChange(selected[0] ?? null)} - id="school-typeahead" - isInvalid={fieldState.invalid} - inputProps={{ - "aria-label": "Choose a school", - "data-testid": testid, - }} - options={schools} - labelKey="displayName" - filterBy={filterByFields} - placeholder="Start typing to select a school..." - /> - )} - /> - ); -} diff --git a/frontend/src/main/components/Courses/StudentCoursesTable.jsx b/frontend/src/main/components/Courses/StudentCoursesTable.jsx deleted file mode 100644 index 161dfeb..0000000 --- a/frontend/src/main/components/Courses/StudentCoursesTable.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useBackend, useBackendMutation } from "main/utils/useBackend"; -import CoursesTable from "main/components/Courses/CoursesTable"; -import React from "react"; - -export function StudentCoursesTable({ testid }) { - const { data: courses } = useBackend( - ["/api/courses/list/students"], - // Stryker disable next-line StringLiteral : The default value for an empty ("") method is GET. Therefore, there is no way to kill a mutation that transforms "GET" to "" - { method: "GET", url: "/api/courses/list/students" }, - // Stryker disable next-line all : don't test default value of empty list - [], - ); - - return ( - <> - {courses.length > 0 ? ( - <> - - - ) : ( -

You are not enrolled in any student courses yet.

- )} - - ); -} diff --git a/frontend/src/main/components/Courses/TabComponent/ConceptTabComponent.jsx b/frontend/src/main/components/Courses/TabComponent/ConceptTabComponent.jsx deleted file mode 100644 index cdb09e3..0000000 --- a/frontend/src/main/components/Courses/TabComponent/ConceptTabComponent.jsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useState } from "react"; -import { Button } from "react-bootstrap"; -import { toast } from "react-toastify"; -import ConceptDeleteModal from "main/components/Concept/ConceptDeleteModal"; -import ConceptModal from "main/components/Concept/ConceptModal"; -import ConceptTable from "main/components/Concept/ConceptTable"; -import { useBackend, useBackendMutation } from "main/utils/useBackend"; - -// Suppress error toasts when the concept list fetch fails (e.g. network error); -// background fetches should not interrupt the user with toast messages. -const suppressFetchToasts = true; -const DEFAULT_NEW_CONCEPT_POSITION = { x: 0, y: 0 }; - -export default function ConceptTabComponent({ courseId, testIdPrefix }) { - const [showCreateModal, setShowCreateModal] = useState(false); - const [showEditModal, setShowEditModal] = useState(false); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [editingConcept, setEditingConcept] = useState(null); - const [deletingConceptId, setDeletingConceptId] = useState(null); - const conceptsPath = `/api/concepts/course?courseId=${courseId}`; - const conceptRelatedQueries = [ - conceptsPath, - "/api/concepts/top-level", - "/api/concepts/subconcepts", - ]; - const { data: concepts } = useBackend( - [conceptsPath], - { method: "GET", url: conceptsPath }, - [], - suppressFetchToasts, - ); - - const objectToAxiosParams = (concept) => ({ - url: "/api/concept", - method: "POST", - data: { - courseId, - label: concept.label, - description: concept.description, - example: concept.example, - ...DEFAULT_NEW_CONCEPT_POSITION, - }, - }); - - const createConceptMutation = useBackendMutation( - objectToAxiosParams, - { - onSuccess: (concept) => { - toast(`Concept ${concept.label} created`); - setShowCreateModal(false); - }, - }, - conceptRelatedQueries, - ); - - const updateConceptMutation = useBackendMutation( - (concept) => ({ - url: `/api/concept/put?conceptId=${editingConcept.id}`, - method: "PUT", - data: { - label: concept.label, - description: concept.description, - example: concept.example, - }, - }), - { - onSuccess: (concept) => { - toast(`Concept ${concept.label} updated`); - setShowEditModal(false); - setEditingConcept(null); - }, - }, - conceptRelatedQueries, - ); - - const deleteConceptMutation = useBackendMutation( - () => ({ - url: `/api/concept/delete?conceptId=${deletingConceptId}`, - method: "DELETE", - }), - { - onSuccess: () => { - toast("Concept deleted"); - setShowDeleteModal(false); - setDeletingConceptId(null); - }, - }, - conceptRelatedQueries, - ); - - const editCallback = (cell) => { - setEditingConcept(cell.row.original); - setShowEditModal(true); - }; - - const deleteCallback = (cell) => { - setDeletingConceptId(cell.row.original.id); - setShowDeleteModal(true); - }; - - return ( -
-

Concepts

- - createConceptMutation.mutate(concept)} - /> - updateConceptMutation.mutate(concept)} - buttonText="Update" - modalTitle="Edit Concept" - /> - deleteConceptMutation.mutate({})} - /> - -
- ); -} diff --git a/frontend/src/main/components/Courses/TabComponent/DownloadsTabComponent.jsx b/frontend/src/main/components/Courses/TabComponent/DownloadsTabComponent.jsx deleted file mode 100644 index f6a1074..0000000 --- a/frontend/src/main/components/Courses/TabComponent/DownloadsTabComponent.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import CourseDownloadsForm from "main/components/Courses/CourseDownloadsForm"; -import { toast } from "react-toastify"; - -export default function DownloadsTabComponent({ courseId, testIdPrefix }) { - const onSuccessDownloadTriggered = () => { - toast("Download successfully initiated."); - }; - - const handleSubmit = () => { - console.log(`Frontend form submit action captured for course: ${courseId}`); - onSuccessDownloadTriggered(); - }; - - return ( -
- -
- ); -} diff --git a/frontend/src/main/components/Courses/TabComponent/EdgeConceptTabComponent.jsx b/frontend/src/main/components/Courses/TabComponent/EdgeConceptTabComponent.jsx deleted file mode 100644 index 935efb4..0000000 --- a/frontend/src/main/components/Courses/TabComponent/EdgeConceptTabComponent.jsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { useState } from "react"; -import { Alert, Button, Form } from "react-bootstrap"; -import { toast } from "react-toastify"; -import EdgeTable from "main/components/Concept/EdgeTable"; -import { useBackend, useBackendMutation } from "main/utils/useBackend"; - -const suppressFetchToasts = true; - -export default function EdgeConceptTabComponent({ courseId, testIdPrefix }) { - const [sourceConceptId, setSourceConceptId] = useState(""); - const [targetConceptId, setTargetConceptId] = useState(""); - const [createEdgeError, setCreateEdgeError] = useState(null); - - const conceptsPath = `/api/concepts/course?courseId=${courseId}`; - const { data: concepts } = useBackend( - [conceptsPath], - { method: "GET", url: conceptsPath }, - [], - suppressFetchToasts, - ); - - const edgesPath = `/api/concepts/edges?courseId=${courseId}`; - const { data: edges } = useBackend( - [edgesPath], - { method: "GET", url: edgesPath }, - [], - suppressFetchToasts, - ); - - const labelById = new Map( - concepts.map((concept) => [concept.id, concept.label]), - ); - - const edgesWithLabels = edges.map((edge) => ({ - ...edge, - sourceLabel: labelById.get(edge.sourceId) ?? `id ${edge.sourceId}`, - targetLabel: labelById.get(edge.targetId) ?? `id ${edge.targetId}`, - })); - - const createEdgeObjectToAxiosParams = ({ - sourceConceptId, - targetConceptId, - }) => ({ - url: "/api/concepts/edges/post", - method: "POST", - params: { sourceConceptId, targetConceptId }, - }); - - const createEdgeMutation = useBackendMutation( - createEdgeObjectToAxiosParams, - { - onSuccess: () => { - toast("Edge created"); - setSourceConceptId(""); - setTargetConceptId(""); - }, - onError: (error) => { - const message = - error.response?.data?.message ?? - "Error creating edge; please try again"; - setCreateEdgeError(message); - toast(message); - }, - }, - [edgesPath], - ); - - const deleteEdgeObjectToAxiosParams = (cell) => ({ - url: "/api/concepts/edges/delete", - method: "DELETE", - params: { id: cell.row.original.id }, - }); - - const deleteEdgeMutation = useBackendMutation( - deleteEdgeObjectToAxiosParams, - { - onSuccess: () => { - toast("Edge deleted"); - }, - }, - [edgesPath], - ); - - const handleCreateEdge = (event) => { - event.preventDefault(); - setCreateEdgeError(null); - createEdgeMutation.mutate({ sourceConceptId, targetConceptId }); - }; - - const createDisabled = - sourceConceptId === "" || - targetConceptId === "" || - sourceConceptId === targetConceptId; - - return ( -
-

Edges

-

- Edges represent prerequisite relationships between top-level concepts. - Select a "from" concept (the prerequisite) and a - "to" concept (the concept that depends on it), then click - Create Edge. -

-
- - From (source) - setSourceConceptId(event.target.value)} - > - - {concepts.map((concept) => ( - - ))} - - - - To (target) - setTargetConceptId(event.target.value)} - > - - {concepts.map((concept) => ( - - ))} - - - {createEdgeError && ( - - {createEdgeError} - - )} - -
- { - setCreateEdgeError(null); - deleteEdgeMutation.mutate(cell); - }} - testId={`${testIdPrefix}-EdgeTable`} - /> -
- ); -} diff --git a/frontend/src/main/components/Courses/TabComponent/EnrollmentTabComponent.jsx b/frontend/src/main/components/Courses/TabComponent/EnrollmentTabComponent.jsx deleted file mode 100644 index 5f9049d..0000000 --- a/frontend/src/main/components/Courses/TabComponent/EnrollmentTabComponent.jsx +++ /dev/null @@ -1,271 +0,0 @@ -import { toast } from "react-toastify"; -import { useBackend, useBackendMutation } from "main/utils/useBackend"; -import React, { useState } from "react"; -import { - Button, - Col, - Form, - ModalBody, - ModalHeader, - Row, - OverlayTrigger, - Tooltip, -} from "react-bootstrap"; -import { InfoIcon } from "main/components/Common/Icons"; -import RosterStudentCSVUploadForm from "main/components/RosterStudent/RosterStudentCSVUploadForm"; -import RosterStudentForm from "main/components/RosterStudent/RosterStudentForm"; -import RosterStudentTable from "main/components/RosterStudent/RosterStudentTable"; -import Modal from "react-bootstrap/Modal"; -import DroppedStudentsTable from "main/components/RosterStudent/DroppedStudentsTable"; - -export default function EnrollmentTabComponent({ - courseId, - testIdPrefix, - currentUser, - canEditStudents, -}) { - const [postModal, setPostModal] = useState(false); - const [csvModal, setCsvModal] = useState(false); - const [csvErrorModal, setCsvErrorModal] = useState(false); - const [csvErrorModalData, setCsvErrorModalData] = useState(null); - - const { data: rosterStudents } = useBackend( - [`/api/rosterstudents/course/${courseId}`], - // Stryker disable next-line StringLiteral : GET and empty string are equivalent - { method: "GET", url: `/api/rosterstudents/course/${courseId}` }, - [], - true, - ); - const [searchTerm, setSearchTerm] = useState(""); - - const objectToAxiosParamsCSV = (formData) => { - const file = new FormData(); - file.append("file", formData.upload[0]); - return { - url: `/api/rosterstudents/upload/csv`, - data: file, - params: { - courseId: courseId, - }, - method: "POST", - }; - }; - - const objectToAxiosParamsPost = (student) => ({ - url: `/api/rosterstudents/post`, - method: "POST", - params: { - courseId: courseId, - firstName: student.firstName, - lastName: student.lastName, - studentId: student.studentId, - email: student.email, - }, - }); - - const onSuccessRoster = (modalFn) => { - toast("Roster successfully updated."); - // Clear the search filter to show the updated roster - setSearchTerm(""); - modalFn(false); - }; - - const rosterPostMutation = useBackendMutation( - objectToAxiosParamsPost, - { - onSuccess: () => onSuccessRoster(setPostModal), - onError: (error) => { - toast.error( - `Error adding student: ${JSON.stringify(error.response.data, null, 2)}`, - ); - }, - }, - [`/api/rosterstudents/course/${courseId}`], - ); - - const rosterCsvMutation = useBackendMutation( - objectToAxiosParamsCSV, - { - onSuccess: () => onSuccessRoster(setCsvModal), - onError: (error) => { - if (error.response.status !== 409) { - toast.error( - `Error uploading CSV: ${JSON.stringify(error.response.data, null, 2)}`, - ); - } else { - setCsvErrorModal(true); - setCsvErrorModalData(error.response.data.rejected); - setCsvModal(false); - } - }, - }, - [`/api/rosterstudents/course/${courseId}`], - ); - - const handleCsvSubmit = (formData) => { - rosterCsvMutation.mutate(formData); - }; - - const handlePostSubmit = (student) => { - rosterPostMutation.mutate(student); - }; - - const downloadCsv = () => { - window.open(`/api/csv/rosterstudents?courseId=${courseId}`, "_blank"); - }; - - const openCsvHelp = () => { - window.open("/help/csv", "_blank"); - }; - - return ( -
- setCsvErrorModal(false)} - centered={true} - data-testid={`${testIdPrefix}-csv-error-modal`} - size="lg" - > - Upload CSV Roster - -

- The following students couldn't be uploaded to the roster as - their emails and student IDs match two separate students: -

- -
-
- setCsvModal(false)} - centered={true} - data-testid={`${testIdPrefix}-csv-modal`} - > - Upload CSV Roster - - - - - setPostModal(false)} - centered={true} - data-testid={`${testIdPrefix}-post-modal`} - > - Add Individual Student - - - - - - -
- - CSV Upload Format Help - } - > - - -
- - - - - - - -
- -
- - - Search Students: - - - setSearchTerm(e.target.value)} - data-testid={`${testIdPrefix}-search`} - /> - - -
-
- - { - const searchTermLower = searchTerm.toLowerCase(); - const fullName = `${student.firstName} ${student.lastName}`; - if (student.studentId.toLowerCase().includes(searchTermLower)) { - return true; - } else if ( - student.email.toLowerCase().includes(searchTermLower) - ) { - return true; - } else if (fullName.toLowerCase().includes(searchTermLower)) { - return true; - } - return false; - }) - .filter((student) => student.rosterStatus !== "DROPPED")} - currentUser={currentUser} - courseId={courseId} - testIdPrefix={`${testIdPrefix}-RosterStudentTable`} - canEditStudents={canEditStudents} - /> - - -

Dropped Students

- student.rosterStatus === "DROPPED", - )} - courseId={courseId} - /> -
-
- ); -} diff --git a/frontend/src/main/components/Courses/TabComponent/JobTabComponent.jsx b/frontend/src/main/components/Courses/TabComponent/JobTabComponent.jsx deleted file mode 100644 index 3d06968..0000000 --- a/frontend/src/main/components/Courses/TabComponent/JobTabComponent.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; -import { useBackend } from "main/utils/useBackend"; -import JobsTable from "main/components/Jobs/JobsTable"; -import { Button } from "react-bootstrap"; - -export default function JobTabComponent({ courseId, testIdPrefix }) { - const { data: jobs, refetch } = useBackend( - ["/api/jobs/course", courseId], - { - method: "GET", - url: "/api/jobs/course", - params: { courseId }, - }, - [], - ); - - const refreshJobs = () => { - refetch(); - }; - - return ( -
-

Job Status

- - - - -
- ); -} diff --git a/frontend/src/main/components/Courses/TabComponent/PLTabComponent.jsx b/frontend/src/main/components/Courses/TabComponent/PLTabComponent.jsx deleted file mode 100644 index c244219..0000000 --- a/frontend/src/main/components/Courses/TabComponent/PLTabComponent.jsx +++ /dev/null @@ -1,247 +0,0 @@ -import { useState } from "react"; -import { Alert, Button, Form } from "react-bootstrap"; -import { useForm } from "react-hook-form"; -import { toast } from "react-toastify"; -import { useBackend, useBackendMutation } from "main/utils/useBackend"; - -// 403 messages the backend sends for problems the user can fix themselves; -// these are shown on the page rather than in a toast. -const EXPECTED_REPO_ERRORS = [ - "must set up Github PAT first", - "No access to repo via Github PAT token", - "Read/write access to repo via Github PAT is required", -]; - -const EXPECTED_INSTANCE_ERRORS = [ - "must set up Github PAT first", - "must set up PrairieLearn PAT first", - "must associate course with PlRepo first", - "course instance id not found", -]; - -// Shows expected 403 messages on the page (via setError); everything else in a toast. -function makeOnError(expectedMessages, setError, genericMessage) { - return (error) => { - const status = error?.response?.status; - const message = error?.response?.data?.message; - if (status === 403 && expectedMessages.includes(message)) { - setError(message); - } else { - setError(null); - toast.error(message ?? genericMessage); - } - }; -} - -export default function PLTabComponent({ courseId, testIdPrefix }) { - const [repoError, setRepoError] = useState(null); - - // Current course, including the PL association details (plRepoName, - // plInstanceShortName, plInstanceNumericId). Both mutations below invalidate - // this key, so the green checks refresh right after a successful update. - const { data: course } = useBackend( - [`/api/courses/${courseId}`], - // Stryker disable next-line StringLiteral : GET and empty string are equivalent - { method: "GET", url: `/api/courses/${courseId}` }, - null, - true, - ); - const [instanceError, setInstanceError] = useState(null); - - const { - register: registerRepo, - handleSubmit: handleSubmitRepo, - formState: { errors: repoFormErrors }, - } = useForm(); - - const { - register: registerInstance, - handleSubmit: handleSubmitInstance, - formState: { errors: instanceFormErrors }, - } = useForm(); - - const repoMutation = useBackendMutation( - (data) => ({ - url: "/api/courses/updateGithubRepo", - method: "PUT", - params: { courseId, repoName: data.repoName }, - }), - { - onSuccess: () => { - setRepoError(null); - toast("GitHub repo associated with course"); - }, - onError: makeOnError( - EXPECTED_REPO_ERRORS, - setRepoError, - "Error associating GitHub repo", - ), - }, - [`/api/courses/${courseId}`], - ); - - const instanceMutation = useBackendMutation( - (data) => ({ - url: "/api/courses/updatePLInstance", - method: "PUT", - params: { courseId, instanceId: data.instanceId }, - }), - { - onSuccess: () => { - setInstanceError(null); - toast("PrairieLearn course instance associated with course"); - }, - onError: makeOnError( - EXPECTED_INSTANCE_ERRORS, - setInstanceError, - "Error associating PrairieLearn course instance", - ), - }, - [`/api/courses/${courseId}`], - ); - - const syncJobMutation = useBackendMutation( - () => ({ - url: "/api/jobs/launch/syncCourseWithPlRepo", - method: "POST", - params: { courseId }, - }), - { - onSuccess: (data) => { - toast(`Sync job launched, job number ${data.id}`); - }, - onError: (error) => { - const message = error?.response?.data?.message; - toast.error(message ?? "Unable to launch sync job"); - }, - }, - ["/api/jobs/all"], - ); - - const launchSyncJob = () => syncJobMutation.mutate(); - - return ( -
-

PrairieLearn

- -

To configure your course for PrairieLearn:

-
    -
  1. Create a Github PAT and enter it on the profile page.
  2. -
  3. Create a PrairieLearn PAT and enter it on the profile page.
  4. -
  5. - Enter the name of the Github Repo associated with this course (the - part after https://github.com/, e.g.{" "} - ucsb-cs156/pl-cs156): - {repoError && ( - - {repoError} - - )} -
    repoMutation.mutate(data))} - > - - - Repo name - - {course?.plRepoId && ( - - ✓ {course.plRepoName ?? `repo id ${course.plRepoId}`} - - )} - - - {repoFormErrors.repoName && "A repo name is required."} - - - -
    -
  6. -
  7. - Enter the numeric course instance id of the course instance for this - course: - {instanceError && ( - - {instanceError} - - )} -
    - instanceMutation.mutate(data), - )} - > - - - Course instance id - - {course?.plInstanceId && ( - - ✓{" "} - {course.plInstanceShortName - ? `${course.plInstanceShortName} (PrairieLearn id ${course.plInstanceNumericId})` - : `instance id ${course.plInstanceId}`} - - )} - {/* text + numeric inputMode: an id field, so no spinner widget */} - - - {instanceFormErrors.instanceId && - "A course instance id is required."} - - - -
    -
  8. -
- - -
- ); -} diff --git a/frontend/src/main/components/Courses/TabComponent/ScaffoldTabComponent.jsx b/frontend/src/main/components/Courses/TabComponent/ScaffoldTabComponent.jsx deleted file mode 100644 index de7c2f5..0000000 --- a/frontend/src/main/components/Courses/TabComponent/ScaffoldTabComponent.jsx +++ /dev/null @@ -1,146 +0,0 @@ -import React from "react"; -import { Button, Form } from "react-bootstrap"; -import { useForm } from "react-hook-form"; -import { toast } from "react-toastify"; -import axios from "axios"; -import { useBackendMutation } from "main/utils/useBackend"; - -/** - * Sanitizes a value for use in a downloaded filename: trims surrounding - * whitespace, replaces any run of characters that are not letters or digits - * with a single dash, and strips any leading/trailing dashes left behind. - * Returns "" for null/undefined input. - * - * Examples: "CMPSC 8" -> "CMPSC-8"; " Fall 2026! " -> "Fall-2026"; null -> "". - * - * @param {*} value the value to sanitize (coerced to a string) - * @returns {string} the sanitized, filename-safe string - */ -const sanitizeForFilename = (value) => - String(value ?? "") - .trim() - .replace(/[^a-zA-Z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - -export default function ScaffoldTabComponent({ - courseId, - courseName, - term, - school, - testIdPrefix, -}) { - const { - register, - formState: { errors }, - handleSubmit, - reset, - } = useForm(); - - const downloadYaml = async () => { - try { - const response = await axios({ - url: "/api/concepts/yaml/download", - method: "GET", - params: { courseId }, - }); - const blob = new Blob([response.data], { type: "application/x-yaml" }); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - const schoolKey = school?.key ?? school; - const filename = [ - "Scaffold", - sanitizeForFilename(courseName), - sanitizeForFilename(term), - sanitizeForFilename(schoolKey), - courseId, - ] - .filter((part) => part !== "" && part !== undefined && part !== null) - .join("-"); - link.setAttribute("download", `${filename}.yml`); - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); - } catch (error) { - toast.error(`Error downloading concepts YAML: ${error.message}`); - } - }; - - const objectToAxiosParamsUpload = (formData) => { - const file = new FormData(); - file.append("file", formData.upload[0]); - return { - url: "/api/concepts/yaml/upload", - method: "POST", - data: file, - params: { - courseId, - }, - }; - }; - - const uploadMutation = useBackendMutation(objectToAxiosParamsUpload, { - onSuccess: (report) => { - toast( - `Concepts replaced: ${report.conceptsCreated} concepts, ` + - `${report.subconceptsCreated} subconcepts, ${report.edgesCreated} edges, ` + - `${report.practiceProblemsCreated} practice problems. ` + - `Saved scaffold state was cleared for ${report.userStatesCleared} user(s).`, - ); - reset(); - }, - onError: (error) => { - toast.error( - `Error uploading concepts YAML: ${JSON.stringify(error.response.data, null, 2)}`, - ); - }, - }); - - return ( -
-

Scaffold

-

- Download this course's concepts, subconcepts, prerequisite edges, - and practice problems as an editable YAML file, or replace them by - uploading one. See docs/yaml-format.md for the file format. -

- -
- - - Upload Concepts YAML - - - - {errors.upload && "Concepts YAML file is required."} - - - Warning: uploading replaces ALL concepts, subconcepts, edges, and - practice problems for this course, and clears every student's - saved scaffold state. - - - -
-
- ); -} diff --git a/frontend/src/main/components/Courses/TabComponent/SettingsTabComponent.jsx b/frontend/src/main/components/Courses/TabComponent/SettingsTabComponent.jsx deleted file mode 100644 index e4c0115..0000000 --- a/frontend/src/main/components/Courses/TabComponent/SettingsTabComponent.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from "react"; -import CanvasApiForm from "main/components/Settings/CanvasApiForm"; -import CourseOptionsForm from "main/components/Settings/CourseOptionsForm"; -import { useBackendMutation } from "main/utils/useBackend"; -import { toast } from "react-toastify"; - -export default function SettingsTabComponent({ - courseId, - testIdPrefix, - canEditCourseOptions, -}) { - const onSuccessCanvasCredentialsAdded = () => { - toast("Canvas credentials successfully added."); - }; - - const objectToAxiosParamsCanvasToken = (formData) => ({ - url: `/api/courses/updateCourseCanvasToken`, - method: "PUT", - params: { - courseId: courseId, - canvasCourseId: formData.canvasCourseId, - canvasApiToken: formData.canvasApiToken, - }, - }); - - const canvasMutation = useBackendMutation( - objectToAxiosParamsCanvasToken, - { - onSuccess: onSuccessCanvasCredentialsAdded, - }, - [`/api/courses/getCanvasInfo?courseId=${courseId}`], - ); - - const handleSubmit = (formData) => { - canvasMutation.mutate(formData); - }; - - return ( -
-
- -
- -
- ); -} diff --git a/frontend/src/main/components/Courses/TabComponent/StaffTabComponent.jsx b/frontend/src/main/components/Courses/TabComponent/StaffTabComponent.jsx deleted file mode 100644 index 3649593..0000000 --- a/frontend/src/main/components/Courses/TabComponent/StaffTabComponent.jsx +++ /dev/null @@ -1,240 +0,0 @@ -import { toast } from "react-toastify"; -import { useBackend, useBackendMutation } from "main/utils/useBackend"; -import React, { useState } from "react"; -import { - Button, - Col, - Form, - ModalBody, - ModalHeader, - Row, - OverlayTrigger, - Tooltip, -} from "react-bootstrap"; -import { InfoIcon } from "main/components/Common/Icons"; -import CourseStaffForm from "main/components/CourseStaff/CourseStaffForm"; -import CourseStaffCSVUploadForm from "main/components/CourseStaff/CourseStaffCSVUploadForm"; -import CourseStaffTable from "main/components/CourseStaff/CourseStaffTable"; -import Modal from "react-bootstrap/Modal"; - -export default function StaffTabComponent({ - courseId, - testIdPrefix, - currentUser, - isInstructor = true, -}) { - const [postModal, showPostModal] = useState(false); - const [csvModal, setCsvModal] = useState(false); - const { data: courseStaff } = useBackend( - [`/api/coursestaff/course?courseId=${courseId}`], - // Stryker disable next-line StringLiteral : GET and empty string are equivalent - { method: "GET", url: `/api/coursestaff/course?courseId=${courseId}` }, - [], - true, - ); - const [searchTerm, setSearchTerm] = useState(""); - - const objectToAxiosParamsPost = (staff) => ({ - url: `/api/coursestaff/post`, - method: "POST", - params: { - courseId: courseId, - firstName: staff.firstName, - lastName: staff.lastName, - email: staff.email, - }, - }); - - const objectToAxiosParamsCSV = (formData) => { - const file = new FormData(); - file.append("file", formData.upload[0]); - return { - url: `/api/coursestaff/upload/csv`, - data: file, - params: { - courseId: courseId, - }, - method: "POST", - }; - }; - - const onSuccessStaff = (modalFn) => { - toast("Staff roster successfully updated."); - // Clear the search filter to show the updated roster - setSearchTerm(""); - modalFn(false); - }; - - const staffPostMutation = useBackendMutation( - objectToAxiosParamsPost, - { onSuccess: () => onSuccessStaff(showPostModal) }, - [`/api/coursestaff/course?courseId=${courseId}`], - ); - - const staffCsvMutation = useBackendMutation( - objectToAxiosParamsCSV, - { - onSuccess: () => onSuccessStaff(setCsvModal), - onError: (error) => { - toast.error( - `Error uploading CSV: ${JSON.stringify(error.response.data, null, 2)}`, - ); - setCsvModal(false); - }, - }, - [`/api/coursestaff/course?courseId=${courseId}`], - ); - - const handlePostSubmit = (staff) => { - staffPostMutation.mutate(staff); - }; - - const handleCsvSubmit = (formData) => { - staffCsvMutation.mutate(formData); - }; - - const openCsvHelp = () => { - window.open("/help/csv#staff-information", "_blank"); - }; - - // Render tooltip for disabled buttons - const renderComingSoonTooltip = (props) => ( - - Coming Soon - - ); - - return ( -
- showPostModal(false)} - centered={true} - data-testid={`${testIdPrefix}-post-modal`} - > - Add Staff Member - - - - - setCsvModal(false)} - centered={true} - data-testid={`${testIdPrefix}-csv-modal`} - > - Upload Staff CSV - - - - - {isInstructor && ( - - -
- - - Staff CSV Upload Format Help - - } - > - - -
- - - - - - - - - - - -
- )} - -
- - - Search Staff: - - - setSearchTerm(e.target.value)} - data-testid={`${testIdPrefix}-search`} - /> - - -
-
- - { - const searchTermLower = searchTerm.toLowerCase(); - const fullName = `${staffMember.firstName} ${staffMember.lastName}`; - if (staffMember.email.toLowerCase().includes(searchTermLower)) { - return true; - } else if ( - staffMember.githubLogin?.toLowerCase().includes(searchTermLower) - ) { - return true; - } else if (fullName.toLowerCase().includes(searchTermLower)) { - return true; - } - return false; - })} - currentUser={currentUser} - courseId={courseId} - testIdPrefix={`${testIdPrefix}-CourseStaffTable`} - isInstructor={isInstructor} - /> - -
- ); -} diff --git a/frontend/src/main/components/Courses/TabComponent/SubConceptTabComponent.jsx b/frontend/src/main/components/Courses/TabComponent/SubConceptTabComponent.jsx deleted file mode 100644 index 8769db6..0000000 --- a/frontend/src/main/components/Courses/TabComponent/SubConceptTabComponent.jsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, { useState } from "react"; -import { Button } from "react-bootstrap"; -import { toast } from "react-toastify"; -import { useBackend } from "main/utils/useBackend"; -import ConceptDeleteModal from "main/components/Concept/ConceptDeleteModal"; -import SubConceptTable from "main/components/Concept/SubConceptTable"; -import SubConceptModal from "main/components/Concept/SubConceptModal"; -import ConceptSelector from "main/components/Concept/ConceptSelector"; -import { useBackendMutation } from "main/utils/useBackend"; - -export default function SubConceptTabComponent({ courseId, testIdPrefix }) { - const [selectedConcept, setSelectedConcept] = useState(null); - const [showCreateModal, setShowCreateModal] = useState(false); - const [showEditModal, setShowEditModal] = useState(false); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [editingSubConcept, setEditingSubConcept] = useState(null); - const [deletingSubConceptId, setDeletingSubConceptId] = useState(null); - const subConceptQueryKey = ["/api/concepts/subconcepts", courseId]; - const invalidationQueries = ["/api/concepts/subconcepts"]; - - const { data: subConcepts = [] } = useBackend( - subConceptQueryKey, - { - method: "GET", - url: "/api/concepts/subconcepts", - params: { courseId }, - }, - [], - ); - - const createSubconceptAxiosParams = (data) => ({ - url: "/api/concept/subconcept", - method: "POST", - data: { - courseId: Number(courseId), - parentConceptId: Number(data.parentId), - label: data.label, - description: data.description, - example: data.example, - }, - }); - - const createSubconceptMutation = useBackendMutation( - createSubconceptAxiosParams, - { - onSuccess: (subConcept) => { - toast(`SubConcept ${subConcept.label} created`); - setShowCreateModal(false); - }, - }, - invalidationQueries, - ); - - const updateSubconceptMutation = useBackendMutation( - (data) => ({ - url: `/api/concept/subconcept/put?conceptId=${editingSubConcept.id}`, - method: "PUT", - data: { - label: data.label, - description: data.description, - example: data.example, - }, - }), - { - onSuccess: (subConcept) => { - toast(`SubConcept ${subConcept.label} updated`); - setShowEditModal(false); - setEditingSubConcept(null); - }, - }, - invalidationQueries, - ); - - const deleteSubconceptMutation = useBackendMutation( - () => ({ - url: `/api/concept/delete?conceptId=${deletingSubConceptId}`, - method: "DELETE", - }), - { - onSuccess: () => { - toast("SubConcept deleted"); - setShowDeleteModal(false); - setDeletingSubConceptId(null); - }, - }, - invalidationQueries, - ); - - const editCallback = (cell) => { - setEditingSubConcept(cell.row.original); - setShowEditModal(true); - }; - - const deleteCallback = (cell) => { - setDeletingSubConceptId(cell.row.original.id); - setShowDeleteModal(true); - }; - - return ( -
-

SubConcepts

- - - - - - deleteSubconceptMutation.mutate({})} - modalTitle="Delete SubConcept" - buttonText="Delete SubConcept" - message="Are you sure you want to delete this subconcept?" - testId="SubConceptDeleteModal" - /> -
- ); -} diff --git a/frontend/src/main/components/Courses/UpdateInstructorForm.jsx b/frontend/src/main/components/Courses/UpdateInstructorForm.jsx deleted file mode 100644 index f8fd462..0000000 --- a/frontend/src/main/components/Courses/UpdateInstructorForm.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Button, Form } from "react-bootstrap"; -import { useForm } from "react-hook-form"; - -export default function UpdateInstructorForm({ - initialContents, - handleUpdateInstructor, -}) { - const { - register, - formState: { errors }, - handleSubmit, - } = useForm({ defaultValues: initialContents || {} }); - - return ( -
- - Course Name - - New Instructor Email - - - {errors.instructorEmail?.message} - - - Email must belong to an existing instructor or admin. - - - -
- ); -} diff --git a/frontend/src/main/components/LegacyHomePage/LegacyAssessmentSelect.tsx b/frontend/src/main/components/LegacyHomePage/LegacyAssessmentSelect.tsx deleted file mode 100644 index 26bcc15..0000000 --- a/frontend/src/main/components/LegacyHomePage/LegacyAssessmentSelect.tsx +++ /dev/null @@ -1,119 +0,0 @@ -// Frozen copy for LegacyHomePage only. LegacyHomePage is the "touchstone -// ground truth" preserving the original prototype designer's UX research; -// it must not be affected by refactors to the shared Scaffold components. -// Do not consolidate this with main/components/Scaffold/AssessmentSelect. -import { useState, useEffect, useRef } from "react"; -import type { Assessment } from "../../api/legacyClient"; - -interface AssessmentSelectProps { - assessments: Assessment[]; - selectedAssessmentId: string; - onSelect: (id: string) => void; -} - -export default function LegacyAssessmentSelect({ - assessments, - selectedAssessmentId, - onSelect, -}: AssessmentSelectProps) { - const [isOpen, setIsOpen] = useState(false); - const containerRef = useRef(null); - - const selectedName = - assessments.find((a) => a.id === selectedAssessmentId)?.name ?? ""; - - useEffect(() => { - const handler = (e: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(e.target as Node) - ) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handler, true); - return () => document.removeEventListener("mousedown", handler, true); - }, []); - - return ( -
-
setIsOpen((o) => !o)} - style={{ - width: "100%", - height: 28, - padding: "0px 10px", - fontFamily: "Helvetica, Arial, sans-serif", - fontSize: 13, - background: "#ffffff", - color: selectedAssessmentId ? "#1E293B" : "#94A3B8", - border: "1px solid #000000", - borderRadius: 6, - cursor: "pointer", - boxSizing: "border-box", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - userSelect: "none", - }} - > - {selectedName || "Select assessment…"} - - - -
- - {isOpen && assessments.length > 0 && ( -
- {assessments.map((a, i) => ( -
{ - onSelect(a.id); - setIsOpen(false); - }} - className={ - "dropdown-option" + - (a.id === selectedAssessmentId ? " is-selected" : "") - } - style={{ - borderBottom: - i < assessments.length - 1 ? "1px solid #F1F5F9" : "none", - }} - > - {a.name} -
- ))} -
- )} -
- ); -} diff --git a/frontend/src/main/components/LegacyHomePage/LegacyConceptGraph.tsx b/frontend/src/main/components/LegacyHomePage/LegacyConceptGraph.tsx deleted file mode 100644 index dc2b755..0000000 --- a/frontend/src/main/components/LegacyHomePage/LegacyConceptGraph.tsx +++ /dev/null @@ -1,860 +0,0 @@ -import { - useEffect, - useCallback, - useMemo, - useRef, - createContext, - useContext, -} from "react"; -import { - ReactFlow, - type ReactFlowInstance, - Controls, - Background, - BackgroundVariant, - useNodesState, - useEdgesState, - Handle, - Position, - MarkerType, - type NodeChange, - type NodeProps, - type NodeTypes, - type Node, - type Edge, -} from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; -import { buildLegacyGraphElements } from "main/utils/layout"; -import { majorConcepts, prereqEdgeData } from "main/data/conceptGraph"; -import { positions } from "main/data/conceptGraphPositions"; -import { conceptContent, type ConceptContent } from "main/data/conceptContent"; -import { useStaffTools } from "main/utils/useStaffTools"; - -const cardKeyMap: Record = { - Description: "description", - Example: "example", - "PrairieLearn Practice": "practiceUrl", -}; - -interface SavedDetailCard { - cardType: string; - itemLabel: string; - conceptId: string; - conceptColor: string; - posX: number; - posY: number; -} - -interface ConceptGraphProps { - highlightedIds: Set; - highlightedSubconcepts: Map>; - onConceptClick: (id: string) => void; - starredIds: Set; - onStarClick: (id: string) => void; - onReset: () => void; - onDetailAdded?: (card: { - cardType: string; - itemLabel: string; - conceptId: string; - conceptColor: string; - posX: number; - posY: number; - }) => void; - onDetailDeleted?: (cardType: string, itemLabel: string) => void; - restoredDetailCards?: SavedDetailCard[]; - onDetailMoved?: ( - cardType: string, - itemLabel: string, - posX: number, - posY: number, - ) => void; - masteredSubconcepts: Set; - onSubconceptMastered: (sub: string) => void; - onPaneClick?: () => void; -} - -function toPastel(hex: string, strength: number = 0.35): string { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - const pr = Math.round(r * strength + 255 * (1 - strength)); - const pg = Math.round(g * strength + 255 * (1 - strength)); - const pb = Math.round(b * strength + 255 * (1 - strength)); - return `rgb(${pr}, ${pg}, ${pb})`; -} - -const DeleteDetailContext = createContext<(id: string) => void>(() => {}); - -const LEVELS = [ - { label: "Level 5", color: "#2bcd9c" }, - { label: "Level 4", color: "#fe9a71" }, - { label: "Level 3", color: "#93ebff" }, - { label: "Level 2", color: "#feaef2" }, - { label: "Level 1", color: "#c99ffe" }, -]; - -function LevelLegend() { - return ( -
- {LEVELS.map((level, i) => ( -
-
- {level.label} -
- ))} -
- ); -} - -function ResetButton({ onReset }: { onReset: () => void }) { - return ( -
- - - - - - Click to reset graph - -
- ); -} - -function MajorNode({ data, id }: NodeProps) { - const color = data.color as string; - const label = data.label as string; - const subconcepts = data.subconcepts as string[]; - const highlighted = data.highlighted as boolean; - const hasSelection = data.hasSelection as boolean; - const highlightedSubconcepts = data.highlightedSubconcepts as Set; - const starred = data.starred as boolean; - const onStarClick = data.onStarClick as () => void; - const masteredSubconcepts = data.masteredSubconcepts as - Set | undefined; - const onSubconceptMastered = data.onSubconceptMastered as - ((sub: string) => void) | undefined; - - const { debugMode } = useStaffTools(); - const conceptContentForNode = conceptContent[id]; - const debugTitle = useMemo( - () => - debugMode - ? JSON.stringify( - { - id, - label, - color, - subconcepts, - conceptContent: conceptContentForNode, - }, - null, - 2, - ) - : undefined, - [debugMode, id, label, color, subconcepts, conceptContentForNode], - ); - - const showColor = !hasSelection || highlighted; - - return ( -
- - -
- {label} -
{ - e.stopPropagation(); - onStarClick(); - }} - style={{ - position: "absolute", - top: 12, - right: 12, - width: 28, - height: 28, - borderRadius: 6, - border: "2px solid #1E293B", - background: starred ? "#FACC15" : "#ffffff", - display: "flex", - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - flexShrink: 0, - }} - > - - - -
-
- -
- {subconcepts.map((sub, i) => { - const isMastered = masteredSubconcepts?.has(sub) ?? false; - return ( -
-
{ - e.stopPropagation(); - onSubconceptMastered?.(sub); - }} - style={{ - position: "absolute", - left: 6, - top: "50%", - transform: "translateY(-50%)", - width: 14, - height: 14, - borderRadius: 3, - borderTop: "1px solid #1E293B", - borderLeft: "1px solid #1E293B", - borderRight: "2.5px solid #1E293B", - borderBottom: "2.5px solid #1E293B", - background: isMastered ? color : "#ffffff", - display: "flex", - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - flexShrink: 0, - }} - > - {isMastered && ( - - - - )} -
- {sub} -
- ); - })} -
- - -
- ); -} - -function DetailNode({ data, id }: NodeProps) { - const onDelete = useContext(DeleteDetailContext); - - const cardType = data.cardType as string; - const itemLabel = data.itemLabel as string; - const conceptColor = data.conceptColor as string; - const greyed = data.greyed as boolean | undefined; - const bgColor = greyed ? "#f1f5f9" : toPastel(conceptColor); - const borderColor = greyed ? "#cbd5e1" : "#000000"; - const pillBg = greyed ? "#cbd5e1" : conceptColor; - const textColor = greyed ? "#94a3b8" : "#1E293B"; - - return ( -
- - - {/* X button */} -
{ - e.stopPropagation(); - onDelete(id); - }} - > - - - - -
- - {/* Pill label */} - - {cardType} - - - {/* Item label */} -
- {itemLabel} -
- - {/* Content */} -
- {cardType === "Example" ? ( -
-            {(data.cardContent as string) ||
-              `Example for "${itemLabel}" will appear here.`}
-          
- ) : ( -
- {(data.cardContent as string) || - `${cardType} for "${itemLabel}" will appear here.`} -
- )} -
- - -
- ); -} - -const nodeTypes: NodeTypes = { major: MajorNode, detail: DetailNode }; -const { nodes: initialNodes, edges: initialEdges } = - buildLegacyGraphElements(positions); - -export default function LegacyConceptGraph({ - highlightedIds, - highlightedSubconcepts, - onConceptClick, - starredIds, - onStarClick, - onReset, - onDetailAdded, - onDetailDeleted, - restoredDetailCards, - onDetailMoved, - masteredSubconcepts, - onSubconceptMastered, - onPaneClick, -}: ConceptGraphProps) { - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - const hasSelection = highlightedIds.size > 0; - const restoredRef = useRef(false); - const nodesRef = useRef(nodes); - useEffect(() => { - nodesRef.current = nodes; - }, [nodes]); - - const highlightedIdsRef = useRef(highlightedIds); - useEffect(() => { - highlightedIdsRef.current = highlightedIds; - }, [highlightedIds]); - - useEffect(() => { - if (restoredRef.current || !restoredDetailCards?.length) return; - restoredRef.current = true; - - const newNodes = restoredDetailCards.map((card, i) => { - const concept = majorConcepts.find((c) => c.name === card.conceptId); - const conceptLabel = concept?.label.replace(/\n/g, " ") ?? ""; - const isConceptItself = card.itemLabel === conceptLabel; - const contentKey = isConceptItself - ? card.conceptId - : `${card.conceptId}:${card.itemLabel}`; - const cardContent = - conceptContent[contentKey]?.[cardKeyMap[card.cardType]] ?? ""; - - return { - id: `detail-restored-${i}-${card.cardType}-${card.itemLabel}`, - type: "detail" as const, - position: { x: card.posX, y: card.posY }, - data: { - cardType: card.cardType, - itemLabel: card.itemLabel, - conceptId: card.conceptId, - conceptColor: card.conceptColor, - cardContent, - }, - }; - }); - const newEdges = restoredDetailCards.map((card, i) => ({ - id: `edge-restored-${i}`, - source: card.conceptId, - target: newNodes[i].id, - targetHandle: "bottom", - type: "default", - style: { - stroke: card.conceptColor, - strokeWidth: 4, - strokeDasharray: "4 2", - }, - markerEnd: { type: MarkerType.ArrowClosed, color: card.conceptColor }, - data: { conceptColor: card.conceptColor }, - })); - - setNodes((prev) => [...prev, ...newNodes]); - setEdges((prev) => [...prev, ...newEdges]); - }, [restoredDetailCards, setNodes, setEdges]); - - const rfInstance = useRef | null>(null); - - const handleReset = useCallback(() => { - setNodes(initialNodes); - setEdges(initialEdges); - onReset(); - setTimeout(() => rfInstance.current?.fitView({ padding: 0.08 }), 50); - }, [onReset, setNodes, setEdges]); - - const handleDeleteDetail = useCallback( - (id: string) => { - setNodes((nds) => { - const node = nds.find((n) => n.id === id); - if (node?.data) { - onDetailDeleted?.( - node.data.cardType as string, - node.data.itemLabel as string, - ); - } - return nds.filter((n) => n.id !== id); - }); - setEdges((eds) => eds.filter((e) => e.source !== id && e.target !== id)); - }, - [setNodes, setEdges, onDetailDeleted], - ); - - const onDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - }, []); - - const onDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - const raw = e.dataTransfer.getData("application/scaffold-card"); - if (!raw) return; - - const { cardType, itemLabel, conceptId, conceptColor, cardContent } = - JSON.parse(raw); - const position = rfInstance.current?.screenToFlowPosition({ - x: e.clientX, - y: e.clientY, - }); - if (!position) return; - - const nodeId = `detail-${Date.now()}`; - - const isHighlighted = highlightedIdsRef.current.has(conceptId); - const hasSelectionNow = highlightedIdsRef.current.size > 0; - const edgeColor = - hasSelectionNow && !isHighlighted ? "#cbd5e1" : conceptColor; - - setNodes((nds) => [ - ...nds, - { - id: nodeId, - type: "detail", - position, - data: { cardType, itemLabel, conceptColor, cardContent, conceptId }, - }, - ]); - - setEdges((eds) => [ - ...eds, - { - id: `detail-edge-${nodeId}`, - source: conceptId, - target: nodeId, - targetHandle: "bottom", - type: "default", - style: { stroke: edgeColor, strokeWidth: 4, strokeDasharray: "4 2" }, - markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor }, - data: { conceptColor }, - }, - ]); - - onDetailAdded?.({ - cardType, - itemLabel, - conceptId, - conceptColor, - posX: position.x, - posY: position.y, - }); - }, - [setNodes, setEdges, onDetailAdded], - ); - - // Update node appearance when highlighted set changes - useEffect(() => { - setNodes((nds) => - nds.map((node) => { - if (node.type === "detail") { - const greyed = - hasSelection && !highlightedIds.has(node.data.conceptId as string); - return { - ...node, - style: { ...node.style, opacity: greyed ? 0.5 : 1 }, - data: { ...node.data, greyed }, - }; - } - return { - ...node, - data: { - ...node.data, - highlighted: highlightedIds.has(node.id), - hasSelection, - highlightedSubconcepts: - highlightedSubconcepts.get(node.id) ?? new Set(), - starred: starredIds.has(node.id), - onStarClick: () => onStarClick(node.id), - masteredSubconcepts: masteredSubconcepts, - onSubconceptMastered: (sub: string) => onSubconceptMastered(sub), - }, - }; - }), - ); - }, [ - highlightedIds, - hasSelection, - highlightedSubconcepts, - starredIds, - masteredSubconcepts, - onSubconceptMastered, - onStarClick, - setNodes, - ]); - - // Update edge appearance when highlighted set changes - useEffect(() => { - if (!hasSelection) { - setEdges((eds) => [ - ...initialEdges, - ...eds - .filter( - (e) => - e.id.startsWith("detail-edge-") || - e.id.startsWith("edge-restored-"), - ) - .map((e) => { - const color = (e.data?.conceptColor as string) ?? "#64748B"; - return { - ...e, - style: { stroke: color, strokeWidth: 4, strokeDasharray: "4 2" }, - markerEnd: { type: MarkerType.ArrowClosed, color }, - }; - }), - ]); - return; - } - setEdges((eds) => [ - ...prereqEdgeData.map((e) => { - const isHighlighted = - highlightedIds.has(e.source) && highlightedIds.has(e.target); - const color = - majorConcepts.find((c) => c.name === e.source)?.color ?? "#64748B"; - return { - id: `prereq-${e.source}-${e.target}`, - source: e.source, - target: e.target, - sourceHandle: "top", - targetHandle: "bottom", - type: "default", - animated: isHighlighted, - style: { - stroke: isHighlighted ? color : "#CBD5E1", - strokeWidth: isHighlighted ? 5 : 4, - strokeDasharray: isHighlighted ? undefined : "5 3", - opacity: isHighlighted ? 1 : 0.2, - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: isHighlighted ? color : "#CBD5E1", - }, - }; - }), - ...eds - .filter( - (e) => - e.id.startsWith("detail-edge-") || - e.id.startsWith("edge-restored-"), - ) - .map((e) => { - const isHighlighted = highlightedIds.has(e.source); - const color = (e.data?.conceptColor as string) ?? "#64748B"; - return { - ...e, - style: { - stroke: isHighlighted ? color : "#cbd5e1", - strokeWidth: 4, - strokeDasharray: "4 2", - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: isHighlighted ? color : "#cbd5e1", - }, - }; - }), - ]); - }, [highlightedIds, hasSelection, setEdges]); - - const onNodeClick = useCallback( - (_e: React.MouseEvent, node: Node) => { - if (node.type === "detail") return; - onConceptClick(node.id); - }, - [onConceptClick], - ); - - const handleNodesChange = useCallback( - (changes: NodeChange[]) => { - onNodesChange(changes); - changes.forEach((change) => { - if ( - change.type === "position" && - change.dragging === false && - change.position - ) { - const node = nodesRef.current.find((n) => n.id === change.id); - if (node?.type === "detail") { - onDetailMoved?.( - node.data.cardType as string, - node.data.itemLabel as string, - change.position.x, - change.position.y, - ); - } - } - }); - }, - [onNodesChange, onDetailMoved], - ); - - return ( - -
- - Scaffold Concept Graph - -
- - -
- ) => { - rfInstance.current = instance; - }} - onDrop={onDrop} - onDragOver={onDragOver} - fitView - fitViewOptions={{ padding: 0.08 }} - minZoom={0.1} - onPaneClick={onPaneClick} - > - - - -
-
- ); -} diff --git a/frontend/src/main/components/LegacyHomePage/LegacyQuestionSearch.tsx b/frontend/src/main/components/LegacyHomePage/LegacyQuestionSearch.tsx deleted file mode 100644 index 67352ea..0000000 --- a/frontend/src/main/components/LegacyHomePage/LegacyQuestionSearch.tsx +++ /dev/null @@ -1,192 +0,0 @@ -// Frozen copy for LegacyHomePage only. LegacyHomePage is the "touchstone -// ground truth" preserving the original prototype designer's UX research; -// it must not be affected by refactors to the shared Scaffold components. -// Do not consolidate this with main/components/Scaffold/QuestionSearch. -import { useState, useEffect, useRef } from "react"; -import type { Question } from "../../api/legacyClient"; - -interface QuestionSearchProps { - questions: Question[]; - selectedQuestionId: string; - onSelect: (id: string) => void; - disabled: boolean; -} - -export default function LegacyQuestionSearch({ - questions, - selectedQuestionId, - onSelect, - disabled, -}: QuestionSearchProps) { - const [inputValue, setInputValue] = useState(""); - const [isOpen, setIsOpen] = useState(false); - const containerRef = useRef(null); - - // Reset when the questions list is cleared (assessment deselected or changed) - useEffect(() => { - if (questions.length === 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setInputValue(""); - setIsOpen(false); - } - }, [questions]); - - // Reset if question is cleared externally - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - if (!selectedQuestionId) setInputValue(""); - }, [selectedQuestionId]); - - // Close dropdown on outside click - useEffect(() => { - const handler = (e: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(e.target as Node) - ) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handler, true); - return () => document.removeEventListener("mousedown", handler, true); - }, []); - - const filtered = questions.filter((q) => - q.title.toLowerCase().includes(inputValue.toLowerCase()), - ); - - const handleSelect = (q: Question) => { - setInputValue(q.title); - setIsOpen(false); - onSelect(q.id); - }; - - const handleChange = (e: React.ChangeEvent) => { - setInputValue(e.target.value); - setIsOpen(true); - if (!e.target.value) onSelect(""); - }; - - const inputStyle: React.CSSProperties = { - width: "100%", - height: 28, - padding: "0px 10px", - fontFamily: "Helvetica, Arial, sans-serif", - fontSize: 13, - background: disabled ? "#ffffff" : "#ffffff", - color: "#1E293B", - border: "1px solid #000000", - borderRadius: 6, - outline: "none", - cursor: disabled ? "not-allowed" : "text", - boxSizing: "border-box", - }; - - return ( -
- {/* Input + clear button wrapper */} -
- { - if (!disabled) setIsOpen(true); - }} - onKeyDown={(e) => { - if (e.key === "Enter" && filtered.length > 0) - handleSelect(filtered[0]); - if (e.key === "Escape") setIsOpen(false); - }} - placeholder={ - disabled - ? "Select assessment to pick question" - : "Search questions…" - } - disabled={disabled} - style={{ ...inputStyle, paddingRight: inputValue ? 28 : 10 }} - /> - {inputValue && ( - - )} -
- - {isOpen && !disabled && filtered.length > 0 && ( -
- {filtered.map((q, i) => ( -
handleSelect(q)} - className={ - "dropdown-option" + - (q.id === selectedQuestionId ? " is-selected" : "") - } - style={{ - borderBottom: - i < filtered.length - 1 ? "1px solid #F1F5F9" : "none", - }} - > - {q.title} -
- ))} -
- )} - - {isOpen && !disabled && filtered.length === 0 && inputValue && ( -
- No questions match "{inputValue}" -
- )} -
- ); -} diff --git a/frontend/src/main/components/Nav/AppNavbar.tsx b/frontend/src/main/components/Nav/AppNavbar.tsx index 5031e95..87caa64 100644 --- a/frontend/src/main/components/Nav/AppNavbar.tsx +++ b/frontend/src/main/components/Nav/AppNavbar.tsx @@ -4,8 +4,6 @@ import type { SystemInfo } from "main/utils/systemInfo"; import { Container, Nav, Navbar, NavDropdown } from "react-bootstrap"; import GoogleLogin from "main/components/Nav/GoogleLogin"; import AppNavbarLocalhost from "main/components/Nav/AppNavbarLocalhost"; -import ScaffoldBrand from "main/components/Scaffold/ScaffoldBrand"; -import CourseMenu from "main/components/Courses/CourseMenu"; export default function AppNavbar({ currentUser, @@ -37,18 +35,13 @@ export default function AppNavbar({ > - + Citelines - <> - {/* be sure that each NavDropdown has a unique id and data-testid */} - - diff --git a/frontend/src/main/components/Nav/Footer.tsx b/frontend/src/main/components/Nav/Footer.tsx index 4aa8c7e..1536f0c 100644 --- a/frontend/src/main/components/Nav/Footer.tsx +++ b/frontend/src/main/components/Nav/Footer.tsx @@ -1,23 +1,6 @@ import { Link } from "react-router"; -import { Button, Form } from "react-bootstrap"; -import { useStaffTools } from "main/utils/useStaffTools"; - -const toggleContainerStyle = { - display: "flex", - alignItems: "center", - gap: "16px", -} as const; export default function Footer() { - const { - debugMode, - enableEditing, - canUseStaffTools, - setStaffTool, - newConceptHandler, - realignConceptsHandler, - } = useStaffTools(); - return (
- About Scaffold + About Citelines - {canUseStaffTools && ( - - {enableEditing && newConceptHandler && ( - - )} - {enableEditing && realignConceptsHandler && ( - - )} - - - setStaffTool("enableEditing", e.target.checked) - } - /> - - Enable Editing - - - - setStaffTool("debugMode", e.target.checked)} - /> - - Debug Mode - - - - )}
); diff --git a/frontend/src/main/components/Profile/PatSection.tsx b/frontend/src/main/components/Profile/PatSection.tsx deleted file mode 100644 index c9f4e6a..0000000 --- a/frontend/src/main/components/Profile/PatSection.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import axios from "axios"; -import { Button, Form } from "react-bootstrap"; -import { useForm } from "react-hook-form"; -import { toast } from "react-toastify"; -import { useBackendMutation } from "main/utils/useBackend"; - -// Shape of the /api/pat/* responses (entity/PatCredential.java). The ciphertext -// and keyVersion fields are @JsonIgnore'd on the backend, so the token itself -// never appears here — only metadata about it. -export interface PatCredential { - id: number; - userId: number; - platform: string; - lastFour: string; - expiresAt: string | null; -} - -interface PatSectionProps { - // Heading and form label, e.g. "GitHub PAT" or "PrairieLearn PAT". - title: string; - // Backend endpoint for both GET (metadata) and POST (set), e.g. "/api/pat/github". - endpoint: string; - testIdPrefix: string; -} - -// The GET returns 404 (not an empty body) when no token is on file yet. -async function fetchPatCredential( - endpoint: string, -): Promise { - try { - const response = await axios.get(endpoint); - return response.data; - } catch (e: unknown) { - const err = e as { status?: number }; - if (err.status === 404) { - return null; - } - throw e; - } -} - -type PatFormFields = { - token: string; - expiresAt?: string; -}; - -/** - * One "enter your personal access token" section of the profile page: shows - * metadata about the stored token (never the token itself) and a form to set - * or replace it. Used once per platform (GitHub, PrairieLearn). - */ -export default function PatSection({ - title, - endpoint, - testIdPrefix, -}: PatSectionProps) { - const { data: patCredential } = useQuery({ - queryKey: [endpoint], - queryFn: () => fetchPatCredential(endpoint), - retry: false, - }); - - const { - register, - handleSubmit, - reset, - formState: { errors }, - } = useForm(); - - const mutation = useBackendMutation( - (data: PatFormFields) => ({ - url: endpoint, - method: "POST", - params: { - token: data.token, - expiresAt: data.expiresAt || undefined, - }, - }), - { - onSuccess: () => { - toast(`${title} saved`); - reset(); - }, - onError: (error: unknown) => { - const message = - (error as { response?: { data?: { message?: string } } }).response - ?.data?.message ?? `Error saving ${title}`; - toast.error(message); - }, - }, - [endpoint], - ); - - const onSubmit = (data: PatFormFields) => mutation.mutate(data); - - return ( -
-

{title}

- {patCredential ? ( -

- PAT: ******{patCredential.lastFour}, expires:{" "} - {patCredential.expiresAt ?? "not specified"} -

- ) : ( -

No PAT set

- )} - -
- - {title} - - - {errors.token && `A ${title} is required.`} - - - - - Expiration Date (optional) - - - - -
-
- ); -} diff --git a/frontend/src/main/components/RosterStudent/DroppedStudentsTable.jsx b/frontend/src/main/components/RosterStudent/DroppedStudentsTable.jsx deleted file mode 100644 index c0eac71..0000000 --- a/frontend/src/main/components/RosterStudent/DroppedStudentsTable.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useBackendMutation } from "main/utils/useBackend"; -import { toast } from "react-toastify"; -import OurTable, { ButtonColumn } from "main/components/Common/OurTable"; - -export default function DroppedStudentsTable({ students, courseId }) { - const columns = [ - { - header: "id", - accessorKey: "id", - id: "id", - }, - - { - header: "Student Id", - accessorKey: "studentId", - }, - - { - header: "First Name", - accessorKey: "firstName", - }, - { - header: "Last Name", - accessorKey: "lastName", - }, - { - header: "Email", - accessorKey: "email", - }, - ]; - - const cellToAxiosParamsRestore = (cell) => ({ - url: `/api/rosterstudents/restore`, - method: "PUT", - params: { - id: cell.row.original.id, - }, - }); - - const restoreSuccess = () => { - toast("Student successfully restored to course."); - }; - - const restoreMutation = useBackendMutation( - cellToAxiosParamsRestore, - { - onSuccess: restoreSuccess, - }, - [`/api/rosterstudents/course/${courseId}`], - ); - - const restoreCallback = (cell) => { - restoreMutation.mutate(cell); - }; - - columns.push( - ButtonColumn("Restore", "primary", restoreCallback, "RestoreButton"), - ); - - return ( - - ); -} diff --git a/frontend/src/main/components/RosterStudent/RosterStudentCSVUploadForm.jsx b/frontend/src/main/components/RosterStudent/RosterStudentCSVUploadForm.jsx deleted file mode 100644 index 322243b..0000000 --- a/frontend/src/main/components/RosterStudent/RosterStudentCSVUploadForm.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useForm } from "react-hook-form"; -import { Button, Form } from "react-bootstrap"; -import React from "react"; - -export default function RosterStudentCSVUploadForm({ submitAction }) { - const { - register, - formState: { errors }, - handleSubmit, - } = useForm(); - - return ( -
- - Upload Student Roster - - - {errors.upload && "Roster is required. "} - - - -
- ); -} diff --git a/frontend/src/main/components/RosterStudent/RosterStudentDeleteModal.jsx b/frontend/src/main/components/RosterStudent/RosterStudentDeleteModal.jsx deleted file mode 100644 index 11fefd1..0000000 --- a/frontend/src/main/components/RosterStudent/RosterStudentDeleteModal.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import Modal from "react-bootstrap/Modal"; -import { useForm } from "react-hook-form"; -import { Form } from "react-bootstrap"; - -export default function RosterStudentDeleteModal({ - onSubmitAction, - showModal, - toggleShowModal, -}) { - const hideModal = () => { - toggleShowModal(false); - }; - - const { register, handleSubmit } = useForm(); - - return ( - - Delete Roster Student -
- - - Are you sure you want to delete this roster student? - - - - - -
-
- ); -} diff --git a/frontend/src/main/components/RosterStudent/RosterStudentDropdown.jsx b/frontend/src/main/components/RosterStudent/RosterStudentDropdown.jsx deleted file mode 100644 index a6d5ae5..0000000 --- a/frontend/src/main/components/RosterStudent/RosterStudentDropdown.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Typeahead } from "react-bootstrap-typeahead"; -import { Form } from "react-bootstrap"; -import "react-bootstrap-typeahead/css/Typeahead.css"; - -export default function RosterStudentDropdown({ - rosterStudents, - value, - onChange, - isInvalid, -}) { - const options = rosterStudents.map((student) => ({ - id: student.id, - fullName: `${student.firstName} ${student.lastName}`, - })); - - const selectedOption = options.filter((opt) => opt.id === value); - - const handleSelectionChange = (selected) => { - const id = selected.length > 0 ? selected[0].id : ""; - onChange(id); - }; - - return ( - - onChange(text)} - inputProps={{ - "aria-label": "Select Student", - "data-testid": "RosterStudentDropdown", - className: isInvalid ? "form-control is-invalid" : "form-control", - }} - /> - - ); -} diff --git a/frontend/src/main/components/RosterStudent/RosterStudentForm.jsx b/frontend/src/main/components/RosterStudent/RosterStudentForm.jsx deleted file mode 100644 index 4da3fcb..0000000 --- a/frontend/src/main/components/RosterStudent/RosterStudentForm.jsx +++ /dev/null @@ -1,125 +0,0 @@ -import { Button, Form } from "react-bootstrap"; -import { useForm } from "react-hook-form"; -import { useNavigate } from "react-router"; -import regexUtils from "main/utils/regexUtils"; - -function RosterStudentForm({ - initialContents, - submitAction, - buttonLabel = "Create", - cancelDisabled = false, -}) { - // Stryker disable all - const { - register, - formState: { errors }, - handleSubmit, - } = useForm({ defaultValues: initialContents || {} }); - // Stryker restore all - - const navigate = useNavigate(); - - const testIdPrefix = "RosterStudentForm"; - - return ( -
- - Student Id - - - {errors.studentId?.message} - - - - - First Name - - - {errors.firstName?.message} - - - - - Last Name - - - {errors.lastName?.message} - - - - {!initialContents && ( - - Email - - - {errors.email?.message} - - - )} - - {initialContents && ( - - Email - - - )} - - - {!cancelDisabled && ( - - )} -
- ); -} - -export default RosterStudentForm; diff --git a/frontend/src/main/components/RosterStudent/RosterStudentTable.jsx b/frontend/src/main/components/RosterStudent/RosterStudentTable.jsx deleted file mode 100644 index 59cafab..0000000 --- a/frontend/src/main/components/RosterStudent/RosterStudentTable.jsx +++ /dev/null @@ -1,156 +0,0 @@ -import React from "react"; -import OurTable, { ButtonColumn } from "main/components/Common/OurTable"; -import { Tooltip, OverlayTrigger } from "react-bootstrap"; - -import { useBackendMutation } from "main/utils/useBackend"; -import { cellToAxiosParamsDelete } from "main/utils/rosterStudentUtils"; -import { hasRole } from "main/utils/currentUser"; -import Modal from "react-bootstrap/Modal"; -import RosterStudentForm from "main/components/RosterStudent/RosterStudentForm"; -import { toast } from "react-toastify"; -import RosterStudentDeleteModal from "main/components/RosterStudent/RosterStudentDeleteModal"; - -export default function RosterStudentTable({ - students, - currentUser, - courseId, - testIdPrefix = "RosterStudentTable", - canEditStudents, -}) { - const [showEditModal, setShowEditModal] = React.useState(false); - const [editStudent, setEditStudent] = React.useState(null); - const [showDeleteModal, setShowDeleteModal] = React.useState(false); - const [deleteStudent, setDeleteStudent] = React.useState(null); - - const cellToAxiosParamsEdit = (formData) => ({ - url: `/api/rosterstudents/update`, - method: "PUT", - params: { - studentId: formData.studentId, - firstName: formData.firstName, - lastName: formData.lastName, - id: formData.id, - }, - }); - - const hideEditModal = () => { - setShowEditModal(false); - }; - - const hideDeleteModal = () => { - setShowDeleteModal(false); - }; - - const onEditSuccess = () => { - toast("Student updated successfully."); - hideEditModal(); - }; - - const onDeleteSuccess = () => { - toast("Student deleted successfully."); - hideDeleteModal(); - }; - - const deleteMutation = useBackendMutation( - cellToAxiosParamsDelete, - { onSuccess: onDeleteSuccess }, - [`/api/rosterstudents/course/${courseId}`], - ); - - const deleteCallback = async (cell) => { - setShowDeleteModal(true); - setDeleteStudent(cell.row.original.id); - }; - - const submitDeleteForm = (data) => { - deleteMutation.mutate({ - id: deleteStudent, - ...data, - }); - }; - - const editMutation = useBackendMutation( - cellToAxiosParamsEdit, - { onSuccess: onEditSuccess }, - [`/api/rosterstudents/course/${courseId}`], - ); - - const editCallback = (cell) => { - setEditStudent(cell.row.original); - setShowEditModal(true); - }; - - const submitEditForm = (data) => { - editMutation.mutate(data); - }; - - const columns = [ - { - header: "id", - accessorKey: "id", - id: "id", - }, - - { - header: "Student Id", - accessorKey: "studentId", - }, - - { - header: "First Name", - accessorKey: "firstName", - }, - { - header: "Last Name", - accessorKey: "lastName", - }, - { - header: "Email", - accessorKey: "email", - }, - ]; - - const canEditRoster = - canEditStudents ?? - (hasRole(currentUser, "ROLE_INSTRUCTOR") || - hasRole(currentUser, "ROLE_ADMIN")); - - if (canEditRoster) { - columns.push(ButtonColumn("Edit", "primary", editCallback, testIdPrefix)); - columns.push( - ButtonColumn("Delete", "danger", deleteCallback, testIdPrefix), - ); - } - - return ( - <> - - - Edit Student - - - - - - - -
- - ); -} diff --git a/frontend/src/main/components/Scaffold/AssessmentSelect.tsx b/frontend/src/main/components/Scaffold/AssessmentSelect.tsx deleted file mode 100644 index b6e0c48..0000000 --- a/frontend/src/main/components/Scaffold/AssessmentSelect.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import type { Assessment } from "main/types/conceptGraph"; - -interface AssessmentSelectProps { - assessments: Assessment[]; - selectedAssessmentId: string; - onSelect: (id: string) => void; -} - -export default function AssessmentSelect({ - assessments, - selectedAssessmentId, - onSelect, -}: AssessmentSelectProps) { - const [isOpen, setIsOpen] = useState(false); - const containerRef = useRef(null); - - const selectedName = - assessments.find((a) => a.id === selectedAssessmentId)?.name ?? ""; - - useEffect(() => { - const handler = (e: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(e.target as Node) - ) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handler, true); - return () => document.removeEventListener("mousedown", handler, true); - }, []); - - return ( -
-
setIsOpen((o) => !o)} - style={{ - width: "100%", - height: 28, - padding: "0px 10px", - fontFamily: "Helvetica, Arial, sans-serif", - fontSize: 13, - background: "#ffffff", - color: selectedAssessmentId ? "#1E293B" : "#94A3B8", - border: "1px solid #000000", - borderRadius: 6, - cursor: "pointer", - boxSizing: "border-box", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - userSelect: "none", - }} - > - {selectedName || "Select assessment…"} - - - -
- - {isOpen && assessments.length > 0 && ( -
- {assessments.map((a, i) => ( -
{ - onSelect(a.id); - setIsOpen(false); - }} - className={ - "dropdown-option" + - (a.id === selectedAssessmentId ? " is-selected" : "") - } - style={{ - borderBottom: - i < assessments.length - 1 ? "1px solid #F1F5F9" : "none", - }} - > - {a.name} -
- ))} -
- )} -
- ); -} diff --git a/frontend/src/main/components/Scaffold/LevelLegend.tsx b/frontend/src/main/components/Scaffold/LevelLegend.tsx deleted file mode 100644 index 65af512..0000000 --- a/frontend/src/main/components/Scaffold/LevelLegend.tsx +++ /dev/null @@ -1,23 +0,0 @@ -const LEVELS = [ - { label: "Level 5", color: "#2bcd9c" }, - { label: "Level 4", color: "#fe9a71" }, - { label: "Level 3", color: "#93ebff" }, - { label: "Level 2", color: "#feaef2" }, - { label: "Level 1", color: "#c99ffe" }, -]; - -export default function LevelLegend() { - return ( -
- {LEVELS.map((level, i) => ( -
-
- {level.label} -
- ))} -
- ); -} diff --git a/frontend/src/main/components/Scaffold/LinkToScaffold.tsx b/frontend/src/main/components/Scaffold/LinkToScaffold.tsx deleted file mode 100644 index 84f2711..0000000 --- a/frontend/src/main/components/Scaffold/LinkToScaffold.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Tooltip, OverlayTrigger } from "react-bootstrap"; -import { Link } from "react-router"; -import { GraphIcon } from "main/components/Common/Icons"; -import type { CourseLinkProps } from "main/components/Scaffold/types"; - -export default function LinkToScaffold({ - course, - rowIndex, - testId = "LinkToScaffold", -}: CourseLinkProps) { - const suffix = rowIndex === undefined ? "" : `-${rowIndex}`; - return ( - - View scaffold for {course.courseName} - - } - > - - - {course.courseName} - - - ); -} diff --git a/frontend/src/main/components/Scaffold/LinkToSettings.tsx b/frontend/src/main/components/Scaffold/LinkToSettings.tsx deleted file mode 100644 index 1f67b20..0000000 --- a/frontend/src/main/components/Scaffold/LinkToSettings.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Tooltip, OverlayTrigger } from "react-bootstrap"; -import { Link } from "react-router"; -import { UserListIcon } from "main/components/Common/Icons"; -import type { CourseLinkProps } from "main/components/Scaffold/types"; - -export default function LinkToSettings({ - course, - rowIndex, - testId = "LinkToSettings", -}: CourseLinkProps) { - const suffix = rowIndex === undefined ? "" : `-${rowIndex}`; - return ( - - Settings and Course Roster for {course.courseName} - - } - > - - - - - ); -} diff --git a/frontend/src/main/components/Scaffold/QuestionSearch.tsx b/frontend/src/main/components/Scaffold/QuestionSearch.tsx deleted file mode 100644 index 7beaefb..0000000 --- a/frontend/src/main/components/Scaffold/QuestionSearch.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import type { Question } from "main/types/conceptGraph"; - -interface QuestionSearchProps { - questions: Question[]; - selectedQuestionId: string; - onSelect: (id: string) => void; - disabled: boolean; -} - -export default function QuestionSearch({ - questions, - selectedQuestionId, - onSelect, - disabled, -}: QuestionSearchProps) { - const [inputValue, setInputValue] = useState(""); - const [isOpen, setIsOpen] = useState(false); - const containerRef = useRef(null); - - // Reset when the questions list is cleared (assessment deselected or changed) - useEffect(() => { - if (questions.length === 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setInputValue(""); - setIsOpen(false); - } - }, [questions]); - - // Reset if question is cleared externally - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - if (!selectedQuestionId) setInputValue(""); - }, [selectedQuestionId]); - - // Close dropdown on outside click - useEffect(() => { - const handler = (e: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(e.target as Node) - ) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handler, true); - return () => document.removeEventListener("mousedown", handler, true); - }, []); - - const filtered = questions.filter((q) => - q.title.toLowerCase().includes(inputValue.toLowerCase()), - ); - - const handleSelect = (q: Question) => { - setInputValue(q.title); - setIsOpen(false); - onSelect(q.id); - }; - - const handleChange = (e: React.ChangeEvent) => { - setInputValue(e.target.value); - setIsOpen(true); - if (!e.target.value) onSelect(""); - }; - - const inputStyle: React.CSSProperties = { - width: "100%", - height: 28, - padding: "0px 10px", - fontFamily: "Helvetica, Arial, sans-serif", - fontSize: 13, - background: disabled ? "#ffffff" : "#ffffff", - color: "#1E293B", - border: "1px solid #000000", - borderRadius: 6, - outline: "none", - cursor: disabled ? "not-allowed" : "text", - boxSizing: "border-box", - }; - - return ( -
- {/* Input + clear button wrapper */} -
- { - if (!disabled) setIsOpen(true); - }} - onKeyDown={(e) => { - if (e.key === "Enter" && filtered.length > 0) - handleSelect(filtered[0]); - if (e.key === "Escape") setIsOpen(false); - }} - placeholder={ - disabled - ? "Select assessment to pick question" - : "Search questions…" - } - disabled={disabled} - style={{ ...inputStyle, paddingRight: inputValue ? 28 : 10 }} - /> - {inputValue && ( - - )} -
- - {isOpen && !disabled && filtered.length > 0 && ( -
- {filtered.map((q, i) => ( -
handleSelect(q)} - className={ - "dropdown-option" + - (q.id === selectedQuestionId ? " is-selected" : "") - } - style={{ - borderBottom: - i < filtered.length - 1 ? "1px solid #F1F5F9" : "none", - }} - > - {q.title} -
- ))} -
- )} - - {isOpen && !disabled && filtered.length === 0 && inputValue && ( -
- No questions match "{inputValue}" -
- )} -
- ); -} diff --git a/frontend/src/main/components/Scaffold/ResetButton.tsx b/frontend/src/main/components/Scaffold/ResetButton.tsx deleted file mode 100644 index a616f4e..0000000 --- a/frontend/src/main/components/Scaffold/ResetButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -export default function ResetButton({ onReset }: { onReset: () => void }) { - return ( -
- - - - - - Click to reset graph - -
- ); -} diff --git a/frontend/src/main/components/Scaffold/ScaffoldBrand.tsx b/frontend/src/main/components/Scaffold/ScaffoldBrand.tsx deleted file mode 100644 index 8d27419..0000000 --- a/frontend/src/main/components/Scaffold/ScaffoldBrand.tsx +++ /dev/null @@ -1,21 +0,0 @@ -export default function ScaffoldBrand() { - return ( -
- Scaffold -
- ); -} diff --git a/frontend/src/main/components/Scaffold/ScaffoldConceptGraph.tsx b/frontend/src/main/components/Scaffold/ScaffoldConceptGraph.tsx deleted file mode 100644 index 9dcc73a..0000000 --- a/frontend/src/main/components/Scaffold/ScaffoldConceptGraph.tsx +++ /dev/null @@ -1,1060 +0,0 @@ -import { - useEffect, - useCallback, - useMemo, - useRef, - useState, - createContext, - useContext, -} from "react"; -import { - ReactFlow, - type ReactFlowInstance, - Controls, - Background, - BackgroundVariant, - useNodesState, - useEdgesState, - Handle, - Position, - MarkerType, - type NodeChange, - type NodeProps, - type NodeTypes, - type Node, - type Edge, -} from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; -import { - buildScaffoldGraphElements, - type MajorConceptLike, - type SubconceptLike, - type EdgeLike, -} from "main/utils/layout"; -import type { ConceptContentDTO } from "main/types/conceptGraph"; -import { useStaffTools } from "main/utils/useStaffTools"; -import LevelLegend from "main/components/Scaffold/LevelLegend"; -import ResetButton from "main/components/Scaffold/ResetButton"; - -// This is a parallel, database-driven version of ConceptGraph.tsx: instead of -// importing majorConcepts/prereqEdgeData/positions/conceptContent from the -// hardcoded frontend/src/main/data files, it takes them as props so it can -// render a concept graph fetched from the backend for any course. The -// original ConceptGraph.tsx (used by LegacyHomePage.tsx) is left completely -// untouched; once this version is proven out, one of the two can be retired. - -const cardKeyMap: Record = { - Description: "descriptionHtml", - Example: "exampleHtml", - "PrairieLearn Practice": "practiceUrl", -}; - -interface SavedDetailCard { - cardType: string; - itemLabel: string; - conceptId: string; - conceptColor: string; - posX: number; - posY: number; -} - -interface ScaffoldConceptGraphProps { - majorConcepts: MajorConceptLike[]; - positions: Record; - conceptContent: Record; - prereqEdgeData: EdgeLike[]; - highlightedIds: Set; - highlightedSubconcepts: Map>; - onConceptClick: (id: string) => void; - starredIds: Set; - onStarClick: (id: string) => void; - onReset: () => void; - onDetailAdded?: (card: { - cardType: string; - itemLabel: string; - conceptId: string; - conceptColor: string; - posX: number; - posY: number; - }) => void; - onDetailDeleted?: (cardType: string, itemLabel: string) => void; - restoredDetailCards?: SavedDetailCard[]; - onDetailMoved?: ( - cardType: string, - itemLabel: string, - posX: number, - posY: number, - ) => void; - // Called when the user finishes dragging a top-level concept node. The position is a - // private, per-user override (see ConceptGraphPage) until an instructor's course-wide - // reset recomputes the shared, canonical position. - onMajorMoved?: (name: string, posX: number, posY: number) => void; - // Called when an author (with editing enabled) drag-and-drops a card's - // subconcepts into a new order. orderedSubconceptIds is the complete new - // ordering of the parent's subconcept ids; the page persists it. - onSubconceptsReordered?: ( - parentConceptId: number, - orderedSubconceptIds: number[], - ) => void; - onConceptDoubleClick?: (conceptId: string) => void; - onSubconceptDoubleClick?: ( - parentConceptId: string, - subconceptId: string, - ) => void; - onAddSubconcept?: (parentConceptId: string) => void; - masteredSubconcepts: Set; - onSubconceptMastered: (sub: string) => void; - onPaneClick?: () => void; -} - -function toPastel(hex: string, strength: number = 0.35): string { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - const pr = Math.round(r * strength + 255 * (1 - strength)); - const pg = Math.round(g * strength + 255 * (1 - strength)); - const pb = Math.round(b * strength + 255 * (1 - strength)); - return `rgb(${pr}, ${pg}, ${pb})`; -} - -const DeleteDetailContext = createContext<(id: string) => void>(() => {}); -const ConceptContentContext = createContext>( - {}, -); -// Lets a MajorNode report that the author drag-and-dropped its subconcepts into -// a new order (nodeId is the parent's React Flow node id, i.e. its numeric -// concept id as a string). Provided by ScaffoldConceptGraph, which updates the node's -// data and notifies the page so it can persist the order. -const SubconceptReorderContext = createContext< - (nodeId: string, reordered: SubconceptLike[]) => void ->(() => {}); - -function MajorNode({ data, id }: NodeProps) { - const color = data.color as string; - const labelHtml = data.labelHtml as string; - const subconcepts = data.subconcepts as SubconceptLike[]; - const highlighted = data.highlighted as boolean; - const hasSelection = data.hasSelection as boolean; - const highlightedSubconcepts = data.highlightedSubconcepts as Set; - const starred = data.starred as boolean; - const onStarClick = data.onStarClick as () => void; - const masteredSubconcepts = data.masteredSubconcepts as - Set | undefined; - const onSubconceptMastered = data.onSubconceptMastered as - ((sub: string) => void) | undefined; - const onConceptDoubleClick = data.onConceptDoubleClick as - ((conceptId: string) => void) | undefined; - const onSubconceptDoubleClick = data.onSubconceptDoubleClick as - ((parentConceptId: string, subconceptId: string) => void) | undefined; - const onAddSubconcept = data.onAddSubconcept as - ((parentConceptId: string) => void) | undefined; - - const { debugMode, enableEditing } = useStaffTools(); - const onSubconceptsReordered = useContext(SubconceptReorderContext); - // Index of the subconcept row being dragged / hovered over during an - // editing-enabled drag-and-drop reorder; null when no drag is in progress. - const [dragIndex, setDragIndex] = useState(null); - const [dropIndex, setDropIndex] = useState(null); - const allConceptContent = useContext(ConceptContentContext); - const conceptContentForNode = allConceptContent[id]; - const debugTitle = useMemo( - () => - debugMode - ? JSON.stringify( - { - id, - label: labelHtml, - color, - subconcepts, - conceptContent: conceptContentForNode, - }, - null, - 2, - ) - : undefined, - [debugMode, id, labelHtml, color, subconcepts, conceptContentForNode], - ); - - const showColor = !hasSelection || highlighted; - - return ( -
- - -
{ - if (!enableEditing) return; - onConceptDoubleClick?.(id); - }} - style={{ - background: showColor ? color : "#94A3B8", - letterSpacing: "0.03em", - fontFamily: "Helvetica, Arial, sans-serif", - color: "#000000", - paddingTop: "27px", - paddingBottom: "6px", - paddingLeft: "14px", - paddingRight: "14px", - lineHeight: 1.1, - textAlign: "left", - fontSize: 28, - fontWeight: 700, - whiteSpace: "pre-line", - transition: "background 0.25s", - position: "relative", - }} - > - -
{ - e.stopPropagation(); - onStarClick(); - }} - onDoubleClick={(e) => e.stopPropagation()} - style={{ - position: "absolute", - top: 12, - right: 12, - width: 28, - height: 28, - borderRadius: 6, - border: "2px solid #1E293B", - background: starred ? "#FACC15" : "#ffffff", - display: "flex", - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - flexShrink: 0, - }} - > - - - -
-
- -
- {subconcepts.map((sub, i) => { - const isMastered = masteredSubconcepts?.has(sub.labelHtml) ?? false; - const isDropTarget = - dropIndex === i && dragIndex !== null && dragIndex !== i; - return ( -
{ - e.stopPropagation(); - e.dataTransfer.effectAllowed = "move"; - setDragIndex(i); - }} - onDragOver={(e) => { - if (dragIndex === null) return; - e.preventDefault(); - e.stopPropagation(); - setDropIndex(i); - }} - onDrop={(e) => { - if (dragIndex === null) return; - e.preventDefault(); - e.stopPropagation(); - if (dragIndex !== i) { - const reordered = [...subconcepts]; - const [moved] = reordered.splice(dragIndex, 1); - reordered.splice(i, 0, moved); - onSubconceptsReordered(id, reordered); - } - setDragIndex(null); - setDropIndex(null); - }} - onDragEnd={() => { - setDragIndex(null); - setDropIndex(null); - }} - onDoubleClick={(e) => { - if (!enableEditing) return; - e.stopPropagation(); - onSubconceptDoubleClick?.(id, String(sub.id)); - }} - style={{ - position: "relative", - background: isDropTarget - ? "#e2e8f0" - : highlightedSubconcepts?.has(sub.labelHtml) - ? toPastel(color) - : "#fff", - padding: "5px 6px", - textAlign: "center", - fontFamily: "Helvetica, Arial, sans-serif", - fontSize: 17, - fontWeight: 550, - lineHeight: 1.3, - color: "#1E293B", - whiteSpace: "pre-line", - cursor: enableEditing ? "grab" : undefined, - opacity: dragIndex === i ? 0.5 : 1, - }} - > -
{ - e.stopPropagation(); - onSubconceptMastered?.(sub.labelHtml); - }} - onDoubleClick={(e) => e.stopPropagation()} - style={{ - position: "absolute", - left: 6, - top: "50%", - transform: "translateY(-50%)", - width: 14, - height: 14, - borderRadius: 3, - borderTop: "1px solid #1E293B", - borderLeft: "1px solid #1E293B", - borderRight: "2.5px solid #1E293B", - borderBottom: "2.5px solid #1E293B", - background: isMastered ? color : "#ffffff", - display: "flex", - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - flexShrink: 0, - }} - > - {isMastered && ( - - - - )} -
- - {enableEditing && ( - - )} -
- ); - })} - {enableEditing && ( - - )} -
- - -
- ); -} - -function DetailNode({ data, id }: NodeProps) { - const onDelete = useContext(DeleteDetailContext); - - const cardType = data.cardType as string; - const itemLabel = data.itemLabel as string; - const conceptColor = data.conceptColor as string; - const greyed = data.greyed as boolean | undefined; - const bgColor = greyed ? "#f1f5f9" : toPastel(conceptColor); - const borderColor = greyed ? "#cbd5e1" : "#000000"; - const pillBg = greyed ? "#cbd5e1" : conceptColor; - const textColor = greyed ? "#94a3b8" : "#1E293B"; - - return ( -
- - - {/* X button */} -
{ - e.stopPropagation(); - onDelete(id); - }} - > - - - - -
- - {/* Pill label */} - - {cardType} - - - {/* Item label */} -
- {itemLabel} -
- - {/* Content */} -
- {cardType === "Example" ? ( -
- ) : ( -
- )} -
- - -
- ); -} - -const nodeTypes: NodeTypes = { major: MajorNode, detail: DetailNode }; - -export default function ScaffoldConceptGraph({ - majorConcepts, - positions, - conceptContent, - prereqEdgeData, - highlightedIds, - highlightedSubconcepts, - onConceptClick, - starredIds, - onStarClick, - onReset, - onDetailAdded, - onDetailDeleted, - restoredDetailCards, - onDetailMoved, - onMajorMoved, - masteredSubconcepts, - onSubconceptMastered, - onPaneClick, - onSubconceptsReordered, - onConceptDoubleClick, - onSubconceptDoubleClick, - onAddSubconcept, -}: ScaffoldConceptGraphProps) { - // majorConcepts/positions/prereqEdgeData were only stable for the lifetime - // of a mounted instance until concept/subconcept create/update mutations - // started invalidating the graph query in place, so this memo can now be - // recomputed after mount (e.g. after creating/editing a concept or - // subconcept). useNodesState only consumes its initial argument once, so - // the effect below re-syncs "major" nodes whenever majorConcepts changes, - // without clobbering locally-added detail cards or dragged node positions. - // (Prereq edges are already re-synced from majorConcepts/initialEdges by - // the highlightedIds effect further down.) - const { nodes: initialNodes, edges: initialEdges } = useMemo( - () => buildScaffoldGraphElements(positions, majorConcepts, prereqEdgeData), - [positions, majorConcepts, prereqEdgeData], - ); - - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - const hasSelection = highlightedIds.size > 0; - const restoredRef = useRef(false); - const nodesRef = useRef(nodes); - useEffect(() => { - nodesRef.current = nodes; - }, [nodes]); - - // Re-sync "major" nodes whenever the fetched concept graph changes (e.g. - // after creating a concept, editing a concept, or adding/editing a - // subconcept). Existing major nodes keep their current position (preserving - // drags/local reordering); their data (label, color, subconcepts, etc.) is - // refreshed from the latest fetch. New concepts are appended, and any major - // nodes no longer present in the graph are removed. Non-"major" nodes (e.g. - // detail cards) are left untouched. - const majorConceptsRef = useRef(majorConcepts); - useEffect(() => { - if (majorConceptsRef.current === majorConcepts) return; - majorConceptsRef.current = majorConcepts; - - setNodes((nds) => { - const freshMajorById = new Map(initialNodes.map((n) => [n.id, n])); - const merged = nds - .filter((n) => n.type !== "major" || freshMajorById.has(n.id)) - .map((n) => { - if (n.type !== "major") return n; - const fresh = freshMajorById.get(n.id); - if (!fresh) return n; - // Spread n.data first so any local additions from other effects - // (e.g. highlighted/starred flags, onConceptDoubleClick handlers set - // by the highlightedIds effect below) survive; fresh.data only ever - // contains labelHtml/color/subconcepts (see - // buildScaffoldGraphElements), so those are the only keys refreshed. - return { ...n, data: { ...n.data, ...fresh.data } }; - }); - const existingIds = new Set(merged.map((n) => n.id)); - const newMajors = initialNodes.filter((n) => !existingIds.has(n.id)); - return [...merged, ...newMajors]; - }); - }, [majorConcepts, initialNodes, setNodes]); - - const highlightedIdsRef = useRef(highlightedIds); - useEffect(() => { - highlightedIdsRef.current = highlightedIds; - }, [highlightedIds]); - - useEffect(() => { - if (restoredRef.current || !restoredDetailCards?.length) return; - restoredRef.current = true; - - const newNodes = restoredDetailCards.map((card, i) => { - // card.conceptId is the parent concept's numeric id as a string (the React - // Flow node id). Content is keyed by each concept's own numeric id, so a - // subconcept card resolves its subconcept id via its label. - const concept = majorConcepts.find( - (c) => String(c.id) === card.conceptId, - ); - const conceptLabel = concept?.labelHtml.replace(/\n/g, " ") ?? ""; - const isConceptItself = card.itemLabel === conceptLabel; - const subconceptId = concept?.subconcepts.find( - (s) => s.labelHtml === card.itemLabel, - )?.id; - const contentKey = isConceptItself - ? card.conceptId - : String(subconceptId); - const cardContent = - conceptContent[contentKey]?.[cardKeyMap[card.cardType]] ?? ""; - - return { - id: `detail-restored-${i}-${card.cardType}-${card.itemLabel}`, - type: "detail" as const, - position: { x: card.posX, y: card.posY }, - data: { - cardType: card.cardType, - itemLabel: card.itemLabel, - conceptId: card.conceptId, - conceptColor: card.conceptColor, - cardContent, - }, - }; - }); - const newEdges = restoredDetailCards.map((card, i) => ({ - id: `edge-restored-${i}`, - source: card.conceptId, - target: newNodes[i].id, - targetHandle: "bottom", - type: "default", - style: { - stroke: card.conceptColor, - strokeWidth: 4, - strokeDasharray: "4 2", - }, - markerEnd: { type: MarkerType.ArrowClosed, color: card.conceptColor }, - data: { conceptColor: card.conceptColor }, - })); - - setNodes((prev) => [...prev, ...newNodes]); - setEdges((prev) => [...prev, ...newEdges]); - }, [restoredDetailCards, setNodes, setEdges, majorConcepts, conceptContent]); - - const rfInstance = useRef | null>(null); - - const handleReset = useCallback(() => { - setNodes(initialNodes); - setEdges(initialEdges); - onReset(); - setTimeout(() => rfInstance.current?.fitView({ padding: 0.08 }), 50); - }, [onReset, setNodes, setEdges, initialNodes, initialEdges]); - - const handleDeleteDetail = useCallback( - (id: string) => { - setNodes((nds) => { - const node = nds.find((n) => n.id === id); - if (node?.data) { - onDetailDeleted?.( - node.data.cardType as string, - node.data.itemLabel as string, - ); - } - return nds.filter((n) => n.id !== id); - }); - setEdges((eds) => eds.filter((e) => e.source !== id && e.target !== id)); - }, - [setNodes, setEdges, onDetailDeleted], - ); - - // A MajorNode reports a drag-and-drop reorder of its subconcepts: update the - // node's own data (nodes are snapshotted by useNodesState, so the prop-driven - // rebuild won't do it) and tell the page so it can persist the new order. - const handleSubconceptsReordered = useCallback( - (nodeId: string, reordered: SubconceptLike[]) => { - setNodes((nds) => - nds.map((node) => - node.id === nodeId - ? { ...node, data: { ...node.data, subconcepts: reordered } } - : node, - ), - ); - onSubconceptsReordered?.( - Number(nodeId), - reordered.map((sub) => sub.id), - ); - }, - [setNodes, onSubconceptsReordered], - ); - - const onDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - }, []); - - const onDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - const raw = e.dataTransfer.getData("application/scaffold-card"); - if (!raw) return; - - const { cardType, itemLabel, conceptId, conceptColor, cardContent } = - JSON.parse(raw); - const position = rfInstance.current?.screenToFlowPosition({ - x: e.clientX, - y: e.clientY, - }); - if (!position) return; - - const nodeId = `detail-${Date.now()}`; - - const isHighlighted = highlightedIdsRef.current.has(conceptId); - const hasSelectionNow = highlightedIdsRef.current.size > 0; - const edgeColor = - hasSelectionNow && !isHighlighted ? "#cbd5e1" : conceptColor; - - setNodes((nds) => [ - ...nds, - { - id: nodeId, - type: "detail", - position, - data: { cardType, itemLabel, conceptColor, cardContent, conceptId }, - }, - ]); - - setEdges((eds) => [ - ...eds, - { - id: `detail-edge-${nodeId}`, - source: conceptId, - target: nodeId, - targetHandle: "bottom", - type: "default", - style: { stroke: edgeColor, strokeWidth: 4, strokeDasharray: "4 2" }, - markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor }, - data: { conceptColor }, - }, - ]); - - onDetailAdded?.({ - cardType, - itemLabel, - conceptId, - conceptColor, - posX: position.x, - posY: position.y, - }); - }, - [setNodes, setEdges, onDetailAdded], - ); - - // Update node appearance when highlighted set changes - useEffect(() => { - setNodes((nds) => - nds.map((node) => { - if (node.type === "detail") { - const greyed = - hasSelection && !highlightedIds.has(node.data.conceptId as string); - return { - ...node, - style: { ...node.style, opacity: greyed ? 0.5 : 1 }, - data: { ...node.data, greyed }, - }; - } - return { - ...node, - data: { - ...node.data, - highlighted: highlightedIds.has(node.id), - hasSelection, - highlightedSubconcepts: - highlightedSubconcepts.get(node.id) ?? new Set(), - starred: starredIds.has(node.id), - onStarClick: () => onStarClick(node.id), - onConceptDoubleClick, - onSubconceptDoubleClick, - onAddSubconcept, - masteredSubconcepts: masteredSubconcepts, - onSubconceptMastered: (sub: string) => onSubconceptMastered(sub), - }, - }; - }), - ); - }, [ - highlightedIds, - hasSelection, - highlightedSubconcepts, - starredIds, - masteredSubconcepts, - onSubconceptMastered, - onStarClick, - onConceptDoubleClick, - onSubconceptDoubleClick, - onAddSubconcept, - setNodes, - ]); - - // Update edge appearance when highlighted set changes - useEffect(() => { - if (!hasSelection) { - setEdges((eds) => [ - ...initialEdges, - ...eds - .filter( - (e) => - e.id.startsWith("detail-edge-") || - e.id.startsWith("edge-restored-"), - ) - .map((e) => { - const color = (e.data?.conceptColor as string) ?? "#64748B"; - return { - ...e, - style: { stroke: color, strokeWidth: 4, strokeDasharray: "4 2" }, - markerEnd: { type: MarkerType.ArrowClosed, color }, - }; - }), - ]); - return; - } - setEdges((eds) => [ - ...prereqEdgeData.map((e) => { - const source = String(e.sourceId); - const target = String(e.targetId); - const isHighlighted = - highlightedIds.has(source) && highlightedIds.has(target); - const color = - e.color ?? - majorConcepts.find((c) => c.id === e.sourceId)?.color ?? - "#64748B"; - return { - id: `prereq-${e.id}`, - source, - target, - sourceHandle: "top", - targetHandle: "bottom", - type: "default", - animated: isHighlighted, - style: { - stroke: isHighlighted ? color : "#CBD5E1", - strokeWidth: isHighlighted ? 5 : 4, - strokeDasharray: isHighlighted ? undefined : "5 3", - opacity: isHighlighted ? 1 : 0.2, - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: isHighlighted ? color : "#CBD5E1", - }, - }; - }), - ...eds - .filter( - (e) => - e.id.startsWith("detail-edge-") || - e.id.startsWith("edge-restored-"), - ) - .map((e) => { - const isHighlighted = highlightedIds.has(e.source); - const color = (e.data?.conceptColor as string) ?? "#64748B"; - return { - ...e, - style: { - stroke: isHighlighted ? color : "#cbd5e1", - strokeWidth: 4, - strokeDasharray: "4 2", - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: isHighlighted ? color : "#cbd5e1", - }, - }; - }), - ]); - }, [ - highlightedIds, - hasSelection, - setEdges, - initialEdges, - prereqEdgeData, - majorConcepts, - ]); - - const onNodeClick = useCallback( - (_e: React.MouseEvent, node: Node) => { - if (node.type === "detail") return; - onConceptClick(node.id); - }, - [onConceptClick], - ); - - const handleNodesChange = useCallback( - (changes: NodeChange[]) => { - onNodesChange(changes); - changes.forEach((change) => { - if ( - change.type === "position" && - change.dragging === false && - change.position - ) { - const node = nodesRef.current.find((n) => n.id === change.id); - if (node?.type === "detail") { - onDetailMoved?.( - node.data.cardType as string, - node.data.itemLabel as string, - change.position.x, - change.position.y, - ); - } else if (node?.type === "major") { - onMajorMoved?.(node.id, change.position.x, change.position.y); - } - } - }); - }, - [onNodesChange, onDetailMoved, onMajorMoved], - ); - - return ( - - - -
-
- - -
- ) => { - rfInstance.current = instance; - }} - onDrop={onDrop} - onDragOver={onDragOver} - fitView - fitViewOptions={{ padding: 0.08 }} - minZoom={0.1} - onPaneClick={onPaneClick} - > - - - -
-
-
-
- ); -} diff --git a/frontend/src/main/components/Scaffold/ScaffoldTopBar.tsx b/frontend/src/main/components/Scaffold/ScaffoldTopBar.tsx deleted file mode 100644 index 1313120..0000000 --- a/frontend/src/main/components/Scaffold/ScaffoldTopBar.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { useState } from "react"; -import type { Assessment, Question, Course } from "main/types/conceptGraph"; -import type { CourseAccess } from "main/components/Courses/CourseMenu"; -import AssessmentSelect from "main/components/Scaffold/AssessmentSelect"; -import QuestionSearch from "main/components/Scaffold/QuestionSearch"; -import LinkToSettings from "main/components/Scaffold/LinkToSettings"; -import StarStatus from "main/components/Scaffold/StarStatus"; -import UnlockAssessmentsModal from "main/components/Scaffold/UnlockAssessmentsModal"; -import { useStaffTools } from "main/utils/useStaffTools"; - -// Formats the course-identifying banner text shown above the top bar, e.g. -// "CMPSC 5B, S26, UCSB, phtcon@ucsb.edu (3)". -function formatCourseInfo(courseInfo: CourseAccess): string { - const { courseName, term, school, instructorEmail, id } = courseInfo; - return `${courseName}, ${term}, ${school.displayName}, ${instructorEmail} (${id})`; -} - -interface ScaffoldTopBarProps { - // Undefined while the course is still loading; the settings link is - // omitted until it arrives. - course?: Course; - // Undefined while still loading; the course-identifying banner above the - // bar is omitted until it arrives. Unlike `course` (staff-only), this is - // available to any user with access to the course. - courseInfo?: CourseAccess; - courseId: number; - assessments: Assessment[]; - selectedAssessmentId: string; - onSelectAssessment: (id: string) => void; - questions: Question[]; - selectedQuestionId: string; - onSelectQuestion: (id: string) => void; - numStarredConcepts: number; - numTotalConcepts: number; -} - -export default function ScaffoldTopBar({ - course, - courseInfo, - courseId, - assessments, - selectedAssessmentId, - onSelectAssessment, - questions, - selectedQuestionId, - onSelectQuestion, - numStarredConcepts, - numTotalConcepts, -}: ScaffoldTopBarProps) { - const { enableEditing } = useStaffTools(); - const [showUnlockModal, setShowUnlockModal] = useState(false); - - return ( -
- {courseInfo && ( -
- {formatCourseInfo(courseInfo)} -
- )} -
- {enableEditing && ( - - )} - - setShowUnlockModal(false)} - courseId={courseId} - /> -
- -
-
- {course && ( - - )} - -
-
-
- ); -} diff --git a/frontend/src/main/components/Scaffold/StarStatus.tsx b/frontend/src/main/components/Scaffold/StarStatus.tsx deleted file mode 100644 index 7e09409..0000000 --- a/frontend/src/main/components/Scaffold/StarStatus.tsx +++ /dev/null @@ -1,61 +0,0 @@ -export default function StarStatus({ - numStarredConcepts, - numTotalConcepts, -}: { - numStarredConcepts: number; - numTotalConcepts: number; -}) { - return ( -
-
- - - -
- - {numStarredConcepts} / {numTotalConcepts} - -
- ); -} diff --git a/frontend/src/main/components/Scaffold/UnlockAssessmentsModal.tsx b/frontend/src/main/components/Scaffold/UnlockAssessmentsModal.tsx deleted file mode 100644 index 9cb6cfc..0000000 --- a/frontend/src/main/components/Scaffold/UnlockAssessmentsModal.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import Modal from "react-bootstrap/Modal"; -import { Form } from "react-bootstrap"; -import { toast } from "react-toastify"; -import { useBackend, useBackendMutation } from "main/utils/useBackend"; -import type { AssessmentManagementDTO } from "main/types/conceptGraph"; - -interface UnlockAssessmentsModalProps { - show: boolean; - onHide: () => void; - courseId: number; -} - -// Lets an instructor see every assessment for the course (locked and unlocked) and toggle -// which ones are visible to students in the AssessmentSelect dropdown. Self-contained: owns -// its own fetch (only while open) and its own mutation, since the trigger button and this -// modal are siblings in ScaffoldTopBar rather than needing state lifted to ConceptGraphPage. -export default function UnlockAssessmentsModal({ - show, - onHide, - courseId, -}: UnlockAssessmentsModalProps) { - const { data: assessments = [] } = useBackend( - ["/api/assessments/all", courseId], - { method: "GET", url: "/api/assessments/all", params: { courseId } }, - [], - false, - { enabled: show }, - ); - - const setLockedMutation = useBackendMutation< - { assessmentId: string; locked: boolean }, - AssessmentManagementDTO - >( - ({ assessmentId, locked }) => ({ - method: "PUT", - url: "/api/assessments/lock", - params: { courseId, assessmentId, locked }, - }), - { - onError: (_error, { locked }) => { - toast(`Failed to ${locked ? "lock" : "unlock"} assessment`); - }, - }, - ["/api/assessments/all", "/api/assessments"], - ); - - return ( - - Unlock Assessments - - {assessments.length === 0 ? ( - - No assessments are available for this course yet. - - ) : ( - assessments.map((assessment) => ( - - - setLockedMutation.mutate({ - assessmentId: assessment.id, - locked: !e.target.checked, - }) - } - /> - - {assessment.name} - - - )) - )} - - - ); -} diff --git a/frontend/src/main/components/Scaffold/types.ts b/frontend/src/main/components/Scaffold/types.ts deleted file mode 100644 index 2a143b2..0000000 --- a/frontend/src/main/components/Scaffold/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Props shared by the course link components (LinkToScaffold, LinkToSettings). -// Each renders a link for one course. When the caller reuses the same testId -// across table rows, pass rowIndex to keep the rendered ids unique; when the -// testId is already unique, omit it and no suffix is added. -export interface CourseLinkProps { - course: { - id: number | string; - courseName: string; - }; - rowIndex?: number; - testId?: string; -} diff --git a/frontend/src/main/components/Settings/CanvasApiForm.jsx b/frontend/src/main/components/Settings/CanvasApiForm.jsx deleted file mode 100644 index 9bc18af..0000000 --- a/frontend/src/main/components/Settings/CanvasApiForm.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Button, Form } from "react-bootstrap"; -import { useForm } from "react-hook-form"; -import { useBackend } from "main/utils/useBackend"; -import React from "react"; - -function CanvasApiForm({ - submitAction, - buttonLabel = "Connect Canvas", - courseId, -}) { - const { - register, - formState: { errors }, - handleSubmit, - reset, - setError, - } = useForm(); - - const { data: canvasInfo } = useBackend( - [`/api/courses/getCanvasInfo?courseId=${courseId}`], - // Stryker disable next-line StringLiteral : The default value for an empty ("") method is GET. Therefore, there is no way to kill a mutation that transforms "GET" to "" - { method: "GET", url: `/api/courses/getCanvasInfo?courseId=${courseId}` }, - // Stryker disable next-line all : don't test default value of empty list - [], - ); - - const testIdPrefix = "CanvasApiForm"; - - return ( -
{ - const token = data.canvasApiToken.trim(); - const courseIdVal = data.canvasCourseId.trim(); - - if (!token && !courseIdVal) { - setError("root", { message: "Please fill in at least one field." }); - return; - } - submitAction({ canvasApiToken: token, canvasCourseId: courseIdVal }); - reset(); - })} - > - - Canvas API Token - - - - Canvas Course ID - - - {errors.root && ( - - {errors.root.message} - - )} - - -
- ); -} - -export default CanvasApiForm; diff --git a/frontend/src/main/components/Settings/CourseOptionsForm.jsx b/frontend/src/main/components/Settings/CourseOptionsForm.jsx deleted file mode 100644 index 1eafac8..0000000 --- a/frontend/src/main/components/Settings/CourseOptionsForm.jsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from "react"; -import { Form } from "react-bootstrap"; -import { toast } from "react-toastify"; -import { useBackend, useBackendMutation } from "main/utils/useBackend"; -import { titleCaseFromOption } from "main/utils/courseOptionsUtils"; - -function CourseOptionsForm({ courseId, canEdit }) { - const { data: optionsMap } = useBackend( - [`/api/course/options/?courseId=${courseId}`], - { - // Stryker disable next-line StringLiteral : GET and "" are equivalent mutationss - method: "GET", - url: "/api/course/options", - params: { courseId }, - }, - {}, - ); - - const objectToAxiosParams = ({ option, enabled }) => ({ - url: "/api/course/options", - method: "POST", - params: { courseId, option, enabled }, - }); - - const onSuccessOptionUpdated = (data, variables) => { - toast( - `${titleCaseFromOption(variables.option)} set to ${data[variables.option]}`, - ); - }; - - const courseOptionMutation = useBackendMutation( - objectToAxiosParams, - { onSuccess: onSuccessOptionUpdated }, - [`/api/course/options/?courseId=${courseId}`], - ); - - const entries = Object.entries(optionsMap); - - return ( -
-
Course Options
- {entries.map(([option, enabled]) => ( - - courseOptionMutation.mutate({ - option, - enabled: event.target.checked, - }) - } - data-testid={`CourseOptionsForm-toggle-${option}`} - /> - ))} -
- ); -} - -export default CourseOptionsForm; diff --git a/frontend/src/main/components/Users/UsersTable.tsx b/frontend/src/main/components/Users/UsersTable.tsx index 971fbea..028ff1e 100644 --- a/frontend/src/main/components/Users/UsersTable.tsx +++ b/frontend/src/main/components/Users/UsersTable.tsx @@ -8,7 +8,7 @@ export type User = { familyName: string; email: string; admin: boolean; - instructor: boolean; + researcher: boolean; }; const columns: LegacyColumn[] = [ @@ -37,9 +37,9 @@ const columns: LegacyColumn[] = [ }, // convert boolean to string for display }, { - header: "Instructor", - id: "instructor", - accessorKey: "instructor", + header: "Researcher", + id: "researcher", + accessorKey: "researcher", cell: ({ cell }: { cell: Cell }) => { return String(cell.getValue()); }, // convert boolean to string for display diff --git a/frontend/src/main/data/conceptContent.ts b/frontend/src/main/data/conceptContent.ts deleted file mode 100644 index 2944cc5..0000000 --- a/frontend/src/main/data/conceptContent.ts +++ /dev/null @@ -1,823 +0,0 @@ -export interface ConceptContent { - description?: string; - example?: string; - practiceUrl?: string; -} - -export const conceptContent: Record = { - // ── DATA TYPES ────────────────────────────────────────────────────────────── - - "data-types": { - description: - "Data is any piece of information that your program stores or works with. In Python, the basic data types are integers, floats, strings, and booleans. Data types help Python store and use information correctly.", - example: '7\n8.12\n"I love learning Python"\nFalse', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/697821834/", - }, - "data-types:Numeric (integers, floats)": { - description: - "Numeric data types are used to store numbers in Python. Integers are whole numbers. Floats, or floating point numbers, are numbers with a decimal point. Both can be positive or negative.", - example: "29\n-6.88888", - practiceUrl: "https://us.prairielearn.com/", - }, - "data-types:Strings": { - description: - "A string is how Python stores text. A string can be a single character, an entire paragraph, or even an empty space. Strings are created by wrapping text in matching single or double quotation marks. Strings cannot be changed after they are created, but you can create new strings by performing operations on existing strings.", - example: - "# Empty string\n''\n'This is a string.'\n\"This is also a string.\"", - practiceUrl: "https://us.prairielearn.com/", - }, - "data-types:Booleans": { - description: - "A boolean data type can only be one of two values: True or False. The value must be capitalized with no quotation marks around it.", - example: "True\nFalse", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── VARIABLES ──────────────────────────────────────────────────────────────── - - variables: { - description: - "A variable is a named container that stores a piece of data so your program can use it later. A variable is like a labeled box, where the label is the variable's name, and whatever is inside the box is its value. Variables let you reference, update, and reuse information throughout your program.", - example: - 'int_variable = 5\nstring_variable = "CS 8"\nboolean_variable = True', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/697992232/", - }, - "variables:Variable names": { - description: - "A variable name is the label you give a variable so you can refer to it in your code. Names are case-sensitive and can only contain letters, numbers, or underscores. They can't start with a number or contain spaces, and they can't be Python keywords like 'if', 'else', 'for', etc. Good names clearly describe what the variable holds.", - example: - '# Vague variable name\nx = "Alejandro"\n\n# Specific variable name\nstudent_name = "Kyle"', - practiceUrl: "https://us.prairielearn.com/", - }, - "variables:Variable assignment": { - description: - "Variable assignment creates a variable and gives it a value using the = sign. You can reassign a variable later to overwrite its old value. A variable's data type changes based on what value is assigned to it.", - example: - '# Create variable1 to store a string\nvariable1 = "ABC"\n\n# Reassign variable1 to store an integer\nvariable1 = 10\n\n# Update variable1 based on its current value\nvariable1 = variable1 + 3', - practiceUrl: "https://us.prairielearn.com/", - }, - "variables:+= and -=": { - description: - '+= and -= are shortcuts for updating a variable based on its current value. "+=" adds the value on the right to the variable on the left and stores the result back in that variable. "-=" subtracts the value on the right from the variable on the left and stores the result back in that variable.', - example: - "x = 10\n\n# Same as x = x + 3\nx += 3\n\n# Same as x = x - 5\nx -= 5", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── ARITHMETIC OPERATIONS ──────────────────────────────────────────────────── - - "arithmetic-ops": { - description: - "Arithmetic operations are the basic math actions you can perform on numbers in Python. Just like a calculator, Python can add, subtract, multiply, divide, and raise numbers to a power using simple symbols.", - example: "8 + 3\n10 / 2", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/697992231/", - }, - "arithmetic-ops:Simple arithmetic": { - description: - "The four basic arithmetic operators in Python are: + (add), - (subtract), * (multiply), and ** (raise to a power).", - example: "5 + 3\n5 - 3\n5 * 3\n5 ** 3", - practiceUrl: "https://us.prairielearn.com/", - }, - "arithmetic-ops:Division": { - description: - "Python has two kinds of division. Regular division (/) always returns a decimal result, even if both operands are integers and the result is a whole number. Floor division (//) rounds the result down to the nearest whole number.", - example: "# Evaluates to 1.25\n5 / 4\n\n# Evaluates to 1\n5 // 4", - practiceUrl: "https://us.prairielearn.com/", - }, - "arithmetic-ops:Modulo": { - description: - "The modulo operator (%) returns the remainder left over after dividing two numbers. It is useful for checking things like whether a number is even or odd. You can use modulo with floats, but the result will not always be precise due to rounding errors.", - example: - "# Evaluates to 1\n10 % 3\n\n# Evaluates to 0\n10 % 2\n\n# Evaluates to 0.5\n1.5 % 1", - practiceUrl: "https://us.prairielearn.com/", - }, - "arithmetic-ops:Order of operations": { - description: - "Python follows the standard PEMDAS order when evaluating expressions: parentheses, exponents, multiplication, division, addition, then subtraction.", - example: "# Evaluates to 14\n2 + 3 * 4\n\n# Evaluates to 20\n(2 + 3) * 4", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── STRING OPERATIONS ──────────────────────────────────────────────────────── - - "string-ops": { - description: - "String operations are actions you can perform on text in Python. Python gives you tools to combine, slice, and format strings any time your program needs to work with or display text.", - example: 'greeting = "Hello" + " World!"\nword1 = greeting[0:5]', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/697992235/", - }, - "string-ops:String concatenation": { - description: - "Concatenation joins two or more strings together into one using the + symbol. The strings are combined exactly as they are, so you must include any spaces yourself.", - example: - 'word1 = "Hello"\nword2 = "World"\ngreeting = word1 + " " + word2\n\n# Two ways to append to a string\ngreeting = greeting + "!"\ngreeting += "!"', - practiceUrl: "https://us.prairielearn.com/", - }, - "string-ops:String formatting (f-strings)": { - description: - "An f-string lets you embed a variable or expression directly inside a string. Place an f before the opening quote, then put variable names or expressions inside curly braces wherever you want their values to appear.", - example: - '# Stores "The sum of 5 and 3 is 8"\nresult = f"The sum of 5 and 3 is {5 + 3}"', - practiceUrl: "https://us.prairielearn.com/", - }, - "string-ops:String slicing": { - description: - "String slicing lets a program extract a smaller portion of a string by specifying a start and stop index: string_variable[start:stop]. The start index is included in the result, but the stop index is not. Leaving out the start or stop index tells Python to slice from the beginning or all the way to the end. String slicing also accepts an optional third value called the step: string_variable[start:stop:step]. The step controls how many positions Python jumps forward each time it picks a value. The default step is 1, meaning Python picks every character. A step of 2 picks every other character, a step of 3 picks every third character, and so on. A negative step counts backwards through the string.", - example: - 'name = "Brennan"\n\n# Extracts Bren\nname[0:4]\n\n# Every other character: Benn\nnumbers[::2]\n\n# Reverses the string\nname[::-1]', - practiceUrl: "https://us.prairielearn.com/", - }, - "string-ops:Comments": { - description: - "A comment is a note in code that Python ignores when running the program. Comments help readers understand what the code is doing and why. They are created by starting a line with a # symbol.", - example: "# This is a comment: Python will not run this line", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── BOOLEAN EXPRESSIONS ────────────────────────────────────────────────────── - - "boolean-expr": { - description: - "A boolean expression is an expression that evaluates to either True or False. Python uses these to make decisions — for example, checking whether two values are equal or a condition is met.", - example: "x == y\n5 != 3\n(a and b) or (x and y)", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/697992230/", - }, - "boolean-expr:Comparison: ==, <, <=, !=": { - description: - 'Comparison operators compare two values and return True or False. == checks if two values are equal, != checks if they are not equal, and <, >, <=, >= compare which value is larger or smaller. <= means "less than or equal to" and >= means "greater than or equal to".', - example: - "# Evaluate to True\n5 == 5\n5 != 3\n5 > 3\n\n# Evaluate to False\n5 < 3\n5 != 5\n5 <= 4", - practiceUrl: "https://us.prairielearn.com/", - }, - "boolean-expr:Logical: and, or, not": { - description: - 'Logical operators combine or reverse boolean expressions. "and" returns True only if both sides are True, "or" returns True if at least one side is True, and "not" flips a boolean value from True to False or vice versa.', - example: - "# Evaluate to True\nTrue and True\nnot False\nTrue or False\n\n# Evaluate to False\nTrue and False\nnot True\nFalse or False", - practiceUrl: "https://us.prairielearn.com/", - }, - "boolean-expr:Membership: in, not in": { - description: - 'Membership operators check whether a value exists inside a sequence, like if a character is in a string. "in" returns True if the value is found; "not in" returns True if it is not found.', - example: - "# Evaluate to True\n'P' in 'Python'\n'z' not in 'Python'\n\n# Evaluate to False\n'x' in 'Python'\n'n' not in 'Python'", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── CONDITIONAL STATEMENTS ─────────────────────────────────────────────────── - - conditionals: { - description: - 'A conditional statement lets your program make decisions by running certain code only when a specific condition is met. In Python, conditionals are built using if, elif, and else combined with boolean expressions. The boolean expression is the "condition" in a conditional statement. ', - example: - 'test_grade = 85\n\nif test_grade >= 70:\n class_grade = "Pass"\nelse:\n class_grade = "Fail"', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000002/", - }, - "conditionals:if": { - description: - "An if statement checks a condition. If that condition is True, it runs the indented block of code beneath it. If the condition is False, Python skips that block entirely.", - example: - 'test_grade = 85\n\nif test_grade >= 80 and test_grade < 90:\n letter_grade = "B"', - practiceUrl: "https://us.prairielearn.com/", - }, - "conditionals:else": { - description: - "An else statement goes after an if statement and runs its block when the if condition is False. It acts as a fallback: \"if the condition wasn't met, do this instead.\" You always need an if statement before an else statement, but you don't need an else statement after an if statement.", - example: - 'test_grade = 85\n\nif test_grade >= 70:\n class_grade = "Pass"\nelse:\n class_grade = "Fail"', - practiceUrl: "https://us.prairielearn.com/", - }, - "conditionals:elif": { - description: - 'An elif ("else if") statement lets you check multiple conditions in order. Python evaluates each from top to bottom and runs the first block whose condition is True, skipping all the rest. You always need an if statement before an elif statement. You don\'t need an else statement after an elif statement.', - example: - 'if test_grade >= 90:\n class_grade = "A"\nelif test_grade >= 80:\n class_grade = "B"\nelif test_grade >= 70:\n class_grade = "C"\nelse:\n class_grade = "Did not pass"', - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── FUNCTIONS ──────────────────────────────────────────────────────────────── - - functions: { - description: - "A function is a reusable block of code that performs a specific task. Instead of writing the same code over and over, you can define it once and call it whenever you need it. Functions help keep your code organized, readable, and easier to manage.", - example: - 'def greeting(name):\n hello = "Hello, " + name\n return(hello)', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000004/", - }, - "functions:Defining functions": { - description: - "Defining a function means creating it and giving it a name. Use the def keyword followed by the function name, parentheses, and a colon. The indented code beneath that line is the block that will run whenever the function is called.", - example: "def sum_function():\n sum = 3 + 5", - practiceUrl: "https://us.prairielearn.com/", - }, - "functions:Calling functions": { - description: - "Calling a function means actually running it. You call a function by writing its name followed by parentheses. The code inside a function (the block indented under the function definition line) will only run if the function is called, and it can be called as many times as you want.", - example: - "# Function definition\ndef sum_function():\n sum = 3 + 5\n\n# Function call\nsum_function()", - practiceUrl: "https://us.prairielearn.com/", - }, - "functions:Function parameters": { - description: - 'A parameter is a variable listed inside a function\'s parentheses when the function is defined. It acts as a placeholder for a value that will be passed in when the function is called. This lets you write one function that can work with different inputs. To create a function with multiple parameters, separate the variables with commas inside the parentheses. A function does not have to have parameters — if there is no variable inside the parentheses in the function, the function has no parameters. When calling a function that has parameters, "pass in" the values by listing them inside the function call parentheses. The values that you pass in to a function call are called inputs. ', - example: - "# Parameters are a and b\ndef sum_function(a, b):\n sum = a + b\n\n# Call with inputs x and y\nx = 5\ny = 3\nsum_function(x, y)", - practiceUrl: "https://us.prairielearn.com/", - }, - "functions:Return statements": { - description: - 'A return statement sends a value back out of a function so it can be used elsewhere in your program. A return statement is also used to exit a function: any code after the return statement will not run. A return statement starts with the "return" keyword followed by the value being returned (the "return value"). If a function does not have a return statement, it returns None by default. If you assign a variable to a function call (like this: variable_name = sum_function() ), the return value will be stored in the variable. ', - example: - "def sum_function(a, b):\n sum = a + b\n return(sum)\n\n# total stores the return value: 8\ntotal = sum_function(5, 3)", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── BUILT-IN FUNCTIONS ─────────────────────────────────────────────────────── - - "built-in-fns": { - description: - "Built-in functions are ready-made functions that come with Python — you don't need to define them yourself. They handle common tasks like measuring length, finding min/max values, or converting data types. There are over 60 built-in functions in Python, but the most commonly used ones are print(), input(), len(), min(), max(), int(), str(), float(), and range().", - example: '# Returns 3\nlen("CS8")\n\n# Returns 2\nmin(8, 2, 13, 4)', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000012/", - }, - "built-in-fns:len()": { - description: - "len() returns the number of items in a sequence — such as the number of characters in a string or the number of values in a list. When calling the len() function, pass in the sequence as input. ", - example: - "# Returns 5\nlen(\"Hello\")\n\nletter_list = ['a', 'b', 'c']\n\n# Returns 3\nlen(letter_list)", - practiceUrl: "https://us.prairielearn.com/", - }, - "built-in-fns:min() and max()": { - description: - "min() returns the smallest value in a group of numbers; max() returns the largest. You can pass in multiple values separated by commas, or a single list of numbers.", - example: - "# Returns 4\nmin(8, 6, 13, 4)\n\nnum_list = [6, 2, 9, 12]\n\n# Returns 12\nmax(num_list)", - practiceUrl: "https://us.prairielearn.com/", - }, - "built-in-fns:range()": { - description: - "range() returns a list of integers. range() takes up to three input values: range(start, stop, step). At least one input value is required. If you pass in one number as input, it will be the stop value: the default starting value is 0 and the default step is 1. If you pass in two numbers as input, they will be the start and stop values. If you pass in three inputs, the step controls how much the sequence increments by each time. The stop value is never included in the result — the list that range() returns always stops just before it. The step can also be negative, which lets you count backwards.", - example: - "# Returns [0, 1, 2, 3]\nrange(4)\n\n# Returns [3, 4, 5, 6]\nrange(3, 7)\n\n# Returns [2, 4, 6]\nrange(0, 8, 2)\n\n# Returns [8, 7, 6, 5]\nrange(8, 4, -1)", - practiceUrl: "https://us.prairielearn.com/", - }, - "built-in-fns:Type casting": { - description: - "Type casting means converting a value from one data type to another. Python has built-in functions for this: int() converts a value to an integer, float() converts to a float, bool() converts to a boolean, and str() converts to a string. All of these functions only work if it is possible to convert the original value to the new data type: anything can be converted to a string, but trying to convert a string with letters in it to an integer or float will cause an error. Converting an integer to a float adds .0 to the end of the integer value, and converting a float to an integer removes anything after the decimal point. ", - example: - '# Converts to integer 5\na = 5.2\nint(a)\n\n# Converts to float 6.0\nb = 6\nfloat(b)\n\n# Converts to string "7"\nc = 7\nstr(c)\n\n# Converts to integer 5\nd = "5"\nint(d)', - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── INPUT & OUTPUT ─────────────────────────────────────────────────────────── - - "input-output": { - description: - "Input and output are how a program communicates with the user. Output is information the program displays; input is information it receives from the user. Python provides print() for output and input() for input.", - example: - 'print("This sentence will be printed out.")\nname = input("What is your name?")', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000014/", - }, - "input-output:print()": { - description: - "print() displays information to the screen. You can pass in strings, variables, or expressions. Multiple values separated by commas will be printed with a space between them. By default, print() adds a new line after each call.", - example: - '# Prints "4"\nprint(2 + 2)\n\n# Prints "My name is Kate"\nname = "Kate"\nprint("My name is ", name)', - practiceUrl: "https://us.prairielearn.com/", - }, - "input-output:input()": { - description: - "input() pauses the program and waits for the user to type something and press Enter. Whatever the user types is returned as a string. You can pass a prompt message into input() to display instructions or a question to the user.", - example: - 'name = input("What is your name? ")\nprint("Hello, " + name + "!")\n\n# Type casting to store user input as an integer\nage = int(input("How old are you? "))\nprint(age + 1)', - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── DICTIONARIES ───────────────────────────────────────────────────────────── - - dictionaries: { - description: - 'A dictionary is a data type that stores information as pairs of keys and values. Instead of accessing data by position, you look up a value using its unique key — similar to looking up a definition using a word in a real dictionary. The keys in a dictionary are unique, meaning there is only one of each key, but multiple keys can be "mapped" to the same value.', - example: 'student_ages = {"Alejandro": 19, "Kyle": 20, "Nikita": 20}', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000003/", - }, - "dictionaries:Creating a dictionary": { - description: - "A dictionary is created using curly braces {} with each key-value pair separated by a colon. Multiple pairs are separated by commas. Keys are typically strings or numbers, and values can be any data type.", - example: - 'student = {"name": "Abby", "age": 20, "major": "Political Science"}', - practiceUrl: "https://us.prairielearn.com/", - }, - "dictionaries:Accessing a value": { - description: - "A value in a dictionary is accessed using its key inside square brackets []. If the key doesn't exist, Python will return an error. If the key is a string, it must be in quotes.", - example: - 'student = {"name": "Abby", "age": 20, "major": "Political Science"}\n\n# Stores the integer 20\nAbby_age = student["age"]', - practiceUrl: "https://us.prairielearn.com/", - }, - "dictionaries:Adding/updating a key": { - description: - 'A new key-value pair is added using the format: dictionary_name["new_key"] = value. If the key already exists in the dictionary, its value will be overwritten.', - example: - 'student = {"name": "Abby", "age": 20}\n\n# Adds key "major" with value "Political Science"\nstudent["major"] = "Political Science"', - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── LISTS ──────────────────────────────────────────────────────────────────── - - lists: { - description: - "A list is a data type that stores multiple values in a specific order. A list can hold any combination of data types, and its values can be changed at any time.", - example: "numbers = [1, 2, 3, 4, 5]", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/697992236/", - }, - "lists:Creating a list": { - description: - "A list is created using square brackets [] with each value separated by a comma. A list can hold any data type, including a mix of different types. A list can also be empty.", - example: 'mixed = [1, "hello", True]\nempty = []', - practiceUrl: "https://us.prairielearn.com/", - }, - "lists:Accessing a value": { - description: - "A value in a list is accessed using the value's numbered position, or index, inside square brackets. List indexes start at 0, so the first item is at index 0, the second at index 1, and so on. A negative index counts from the end of the list.", - example: - 'names = ["Alice", "Bob", "Charlie"]\n\n# Stores "Alice"\nfirst_name = names[0]\n\n# Stores "Bob"\nsecond_name = names[1]\n\n# Stores "Charlie"\nlast_name = names[-1]', - practiceUrl: "https://us.prairielearn.com/", - }, - "lists:List slicing": { - description: - "List slicing lets a program extract a smaller portion of a list by specifying a start and stop index: list[start:stop]. The start index is included in the result, but the stop index is not. Leaving out the start or stop index tells Python to slice from the beginning or all the way to the end. List slicing also accepts an optional third value called the step: list[start:stop:step]. The step controls how many positions Python jumps forward each time it picks a value. The default step is 1, meaning Python picks every item. A step of 2 picks every other item, a step of 3 picks every third item, and so on. A negative step counts backwards through the list. A string can also be sliced with the same rules. ", - example: - "numbers = [10, 20, 30, 40, 50, 60, 70]\n\n# Extracts [30, 40, 50]\nnumbers[2:5]\n\n# Every other value: [10, 30, 50, 70]\nnumbers[::2]\n\n# Reverses the list\nnumbers[::-1]", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── NESTED LISTS ───────────────────────────────────────────────────────────── - - "nested-lists": { - description: - "A nested list is a list that contains other lists as its values. Nested lists are useful for representing structured data with rows and columns, like a grid or table. Each inner list can be accessed and worked with just like a regular list.", - example: "grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000013/", - }, - "nested-lists:Creating a nested list": { - description: - "A nested list is created the same way as a regular list, except some or all of its values are themselves lists. Each inner list is separated by a comma, just like any other value.", - example: - "# A list containing three lists\ngrid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]", - practiceUrl: "https://us.prairielearn.com/", - }, - "nested-lists:Accessing values": { - description: - "A value in a nested list is accessed using two indexes in a row. The first index selects the inner list, and the second selects the value within that inner list.", - example: - "grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]\n\n# Access the first inner list: [1, 2, 3]\ngrid[0]\n\n# Access the second item in the first inner list: 2\ngrid[0][1]\n\n# Access the last item in the last inner list: 9\ngrid[2][2]", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── LOOPS ──────────────────────────────────────────────────────────────────── - - loops: { - description: - "A loop runs the same block of code multiple times without writing it out repeatedly. A program uses loops to repeat an action, work through a sequence of values, or keep doing something until a condition is met. Python has two kinds of loops: for loops and while loops.", - example: - "x = 0\nwhile x < 3:\n x += 1\n\nnum_list = [10, 5, 20, 3, 15]\ntotal = 0\n\nfor num in num_list:\n total += num", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000010/", - }, - "loops:For loops": { - description: - "A for loop repeats a block of code once for each item in a sequence (like a list or a range of numbers). The first line of a for loop has this format: for loop_variable in sequence, with a colon at the end of the line. All the code inside of the for loop is indented. The loop variable takes on each value in the sequence one at a time, and the indented block runs once for each value in the sequence. One run of the indented block inside of a for loop is called a loop iteration. ", - example: - 'vowels = "aeiou"\nvowel_count = 0\n\nfor letter in "Computer":\n if letter in vowels:\n vowel_count += 1', - practiceUrl: "https://us.prairielearn.com/", - }, - "loops:While loops": { - description: - "A while loop repeats a block of code as long as a condition remains True. Once the condition becomes False, the loop stops. Use a while loop when you don't know in advance how many times you need to repeat something.", - example: "x = 0\nwhile x < 3:\n x += 1", - practiceUrl: "https://us.prairielearn.com/", - }, - "loops:Break": { - description: - "The break keyword immediately stops a loop and exits it, even if there are still items left in the sequence or the condition is still True. It is useful when a program needs to stop looping as soon as a specific condition is met.", - example: - "numbers = [3, 7, 1, 9, 4, 6]\ntarget = 9\n\nfor num in numbers:\n if num == target:\n break", - practiceUrl: "https://us.prairielearn.com/", - }, - "loops:Continue": { - description: - "continue skips the rest of the current loop iteration and jumps straight to the next one. Unlike break, it doesn't stop the loop entirely — it just moves on to the next item in the sequence.", - example: - "num_list = [10, -5, 20, -3, 15]\ntotal = 0\n\n# Sums only positive numbers\nfor num in num_list:\n if num < 0:\n continue\n total += num", - practiceUrl: "https://us.prairielearn.com/", - }, - "loops:Iteration": { - description: - 'Iteration means going through a sequence one item at a time. Every time a loop moves to the next item, that is one iteration. A data type is "iterable" if it can hold multiple individual values that can be stepped through, like a list or a string.', - example: - 'a_count = 0\n\n# Iterates over the string "alphabet"\nfor letter in "alphabet":\n if letter == \'a\':\n a_count += 1', - practiceUrl: "https://us.prairielearn.com/", - }, - "loops:Accumulator pattern": { - description: - "The accumulator pattern starts a variable at an initial value before a loop, then updates it with each iteration to build up a result — like a running total, a count, or a growing string. The variable must be declared before the loop so it doesn't reset on each iteration.", - example: - "num_list = [10, 5, 20, 3, 15]\ntotal = 0\n\nfor num in num_list:\n total += num", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── NESTED LOOPS ───────────────────────────────────────────────────────────── - - "nested-loops": { - description: - "A nested loop is a loop inside the indented block of another loop. For every single iteration of the outer loop, the inner loop runs all the way through completely. A program uses nested loops any time it needs to work through data that has multiple layers, like a grid or a nested list.", - example: - "nested_list = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]\ntotal = 0\n\nfor row in nested_list:\n for value in row:\n total += value", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000018/", - }, - "nested-loops:Creating a nested loop": { - description: - "A nested loop is created by indenting a second loop inside the block of an outer loop. The inner loop finishes all its iterations before the outer loop moves to its next one.", - example: - "x = 0\n\nwhile x < 3:\n x += 1\n y = 3\n while y > 0:\n y -= 1", - practiceUrl: "https://us.prairielearn.com/", - }, - "nested-loops:Iterating over a nested list": { - description: - "A program uses a nested loop to iterate over a nested list by using the outer loop to go through each inner list, and the inner loop to go through each value within that inner list.", - example: - "nested_list = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]\ntotal = 0\n\nfor row in nested_list:\n for value in row:\n total += value", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── FILES ──────────────────────────────────────────────────────────────────── - - files: { - description: - "A program can interact with files stored on a computer to save or retrieve information. Unlike variables, file data is stored permanently. Python has built-in tools for opening, reading, and writing files. Working with a file requires knowing its filepath string.", - example: '# Filepath string example\n"downloads/CS8_data/data.txt"', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000016/", - }, - "files:Opening & closing files": { - description: - 'Before reading or writing a file, a program must open it using open(), passing in the filename and a mode: "r" for reading or "w" for writing. When done, the file should be closed with .close(). Using the "with" keyword is preferred because it closes the file automatically.', - example: - '# Opening and closing manually\nfile = open("data.txt", "r")\nfile.close()\n\n# Opening with "with" — closes automatically\nwith open("data.txt", "r") as file:', - practiceUrl: "https://us.prairielearn.com/", - }, - "files:Reading a file": { - description: - ".read() returns the entire file as one string. .readlines() returns a list where each item is one line from the file as a separate string.", - example: - '# contents stores all of data.txt as one string\nwith open("data.txt", "r") as file:\n contents = file.read()\n\n# lines_list stores each line as a separate string\nwith open("data.txt", "r") as file:\n lines_list = file.readlines()', - practiceUrl: "https://us.prairielearn.com/", - }, - "files:Writing to a file": { - description: - 'A program writes to a file using "w" mode and the .write() method. If the file doesn\'t exist, Python creates it automatically. Opening an existing file in "w" mode will erase its previous contents.', - example: - 'with open("output.txt", "w") as file:\n file.write("Hello, World!")\n\n# output.txt now contains: Hello, World!', - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── TUPLES ─────────────────────────────────────────────────────────────────── - - tuples: { - description: - "A tuple is a data type that stores multiple values in a specific order, similar to a list. The key difference is that a tuple's values cannot be changed after it is created. A program uses tuples to store data that should stay fixed.", - example: - 'student1 = ("Alejandro", 20, "Data Science")\nstudent2 = ("Christos", 18, "Computer Science")', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/697992234/", - }, - "tuples:Creating a tuple": { - description: - "A tuple is created using parentheses () with each value separated by a comma. It can hold any mix of data types. To create a tuple with only one value, a trailing comma is required after that value.", - example: - 'coordinates = (10, 20)\nmixed_tuple = (1, "hello", True)\n\n# A tuple with one value\nsingle = (42,)\n\n# An empty tuple\nempty = ()', - practiceUrl: "https://us.prairielearn.com/", - }, - "tuples:Accessing a value": { - description: - "A value in a tuple is accessed using its index inside square brackets, just like a list. Tuple indexes start at 0, and negative indexes count from the end.", - example: - "coordinates = (10, 20, 30)\n\n# Access the first value: 10\ncoordinates[0]\n\n# Access the last value: 30\ncoordinates[-1]\n\n# Access a range of values: (20, 30)\ncoordinates[1:]", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── SETS ───────────────────────────────────────────────────────────────────── - - sets: { - description: - 'A set is a data type that stores multiple unique values with no guaranteed order. A program uses sets when duplicates are not allowed and order is not important. Because sets are unordered, values cannot be accessed by index. You can use the "in" operator to check if a value is in a set.', - example: 'fruits = {"apple", "banana", "cherry"}\nnumbers = {1, 2, 3}', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/697992233/", - }, - "sets:Creating a set": { - description: - "A set is created using curly braces {} with each value separated by a comma. Duplicate values are automatically removed. To create an empty set, use set() — using {} alone creates an empty dictionary instead.", - example: - "# Duplicates are removed: numbers stores {1, 2, 3}\nnumbers = {1, 2, 2, 3, 3, 3}\n\n# An empty set\nempty = set()", - practiceUrl: "https://us.prairielearn.com/", - }, - "sets:Adding an element": { - description: - "To add an element to a set, use set_variable.add(new_element). If the element already exists in the set, nothing changes — sets only store unique items.", - example: - 'fruits = {"apple", "banana"}\n\n# Adds "cherry" to the set\nfruits.add("cherry")\n\n# Adding a duplicate has no effect\nfruits.add("apple")', - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── BUILT-IN METHODS ───────────────────────────────────────────────────────── - - methods: { - description: - "A method is a function that is associated with a specific object. Most Python data types come with their own set of built-in methods to perform specific actions. Some methods return a new value; others modify the original object directly.", - example: - "# .add() is a built-in set method\nnum_set = {1, 2, 3}\nnum_set.add(4)", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000007/", - }, - "methods:Calling a method": { - description: - "A method is called by attaching it to a value or variable using a dot, followed by the method name and parentheses. Some methods take inputs inside the parentheses; some do not.", - example: "numbers = [3, 1, 4, 1, 5]\n\n# Returns 2\nnumbers.count(1)", - practiceUrl: "https://us.prairielearn.com/", - }, - "methods:List methods": { - description: - "Four useful built-in list methods are .append(), .pop(), .sort(), and .reverse(). .append(item) adds an item to the end, .pop(index) removes and returns an item at a given index, .sort() sorts least to greatest, and .reverse() reverses the order.", - example: - "num_list = [4, 5, 6, 8, 2]\n\n# num_list becomes [4, 5, 6, 8, 2, 7]\nnum_list.append(7)\n\n# num_list becomes [5, 6, 8, 2, 7]\nnum_list.pop(0)\n\n# num_list becomes [7, 2, 8, 5, 6]\nnum_list.reverse()\n\n# num_list becomes [2, 5, 6, 7, 8]\nnum_list.sort()", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── MUTABILITY ─────────────────────────────────────────────────────────────── - - mutability: { - description: - "Mutability describes whether the value of an object can be changed after it is created. Mutable objects can be modified in place. Immutable objects are fixed — any operation that seems to modify one actually creates a brand new object instead.", - example: - '# The list is changed directly to [99, 2, 3]\nnumbers = [1, 2, 3]\nnumbers[0] = 99\n\n# Strings cannot be changed in place\nname = "Alice"\n# This would cause an error: name[0] = "a"', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000006/", - }, - "mutability:Mutable objects": { - description: - "Lists and dictionaries are mutable — a program can modify their contents directly without creating a new object. This means two variables pointing to the same list or dictionary will both reflect any changes made to it.", - example: - "a = [1, 2, 3]\n\n# b points to the same list as a\nb = a\n\n# Modifying b in place also changes a\nb[0] = 99\n\n# a: [99, 2, 3]\n# b: [99, 2, 3]", - practiceUrl: "https://us.prairielearn.com/", - }, - "mutability:Immutable objects": { - description: - "Tuples and strings are immutable — their contents cannot be changed after creation. If a program needs a modified version, it must create a brand new object. Trying to change an item in place will cause an error.", - example: - '# Trying to change a character directly would cause an error\nname = "Alice"\n# name[0] = "a" → Error\n\n# A new string must be created instead\nname = "a" + name[1:]', - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── STRING METHODS ─────────────────────────────────────────────────────────── - - "string-methods": { - description: - "String methods are built-in methods that perform common operations on strings, called using dot notation on a string value or variable. Because strings are immutable, string methods never modify the original string — they always return a new one.", - example: - 'name = "Nikita"\nuppercase_name = name.upper()\nword_list = "hello world".split()', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000017/", - }, - "string-methods:upper() and lower()": { - description: - ".upper() returns a new string with all characters converted to uppercase. .lower() returns a new string with all characters converted to lowercase.", - example: - 'name = "Nikita"\n\n# result: "NIKITA"\nuppercase_name = name.upper()\n\n# result: "nikita"\nlowercase_name = name.lower()', - practiceUrl: "https://us.prairielearn.com/", - }, - "string-methods:replace()": { - description: - ".replace() returns a new string with every occurrence of a specified value swapped out for a new value. Pass in the value to find and the replacement value as inputs.", - example: - 'sentence = "I like cats and cats like me."\n\n# result: "I like dogs and dogs like me."\nnew_sentence = sentence.replace("cats", "dogs")', - practiceUrl: "https://us.prairielearn.com/", - }, - "string-methods:split()": { - description: - ".split() breaks a string into a list of smaller strings, dividing at a specified separator. By default it splits on whitespace. The separator itself is not included in the result.", - example: - 'sentence = "hello world"\n\n# result: ["hello", "world"]\nword_list = sentence.split()\n\ndate = "2024-05-08"\n\n# result: ["2024", "05", "08"]\ndate_list = date.split("-")', - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── IMPORTS & MODULES ──────────────────────────────────────────────────────── - - modules: { - description: - "A module is a file containing pre-written Python code that a program can use. Instead of writing everything from scratch, a program can import a module to gain access to functions and tools that are already built.", - example: - "import random\n\n# Returns a random integer between 1 and 10\nrandom_int = random.randint(1, 10)", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000015/", - }, - "modules:Importing modules": { - description: - "A module is imported using the import keyword followed by the module name. Once imported, its functions are accessed using dot notation. You can also import a specific function from a module using the from keyword — that function can then be used without dot notation.", - example: - "# Import the entire module\nimport math\n\n# Import a specific function from a module\nfrom random import randint", - practiceUrl: "https://us.prairielearn.com/", - }, - "modules:random": { - description: - "The random module provides tools for generating random values. It is commonly used in games, simulations, and programs that need unpredictable behavior. randint() returns a random integer between two values, and choice() picks a random item from a list.", - example: - 'import random\n\n# Returns a random integer between 1 and 10\nrandom_int = random.randint(1, 10)\n\nfruits = ["apple", "banana", "cherry"]\n\n# A random item from the fruits list\nrandom_fruit = random.choice(fruits)', - practiceUrl: "https://us.prairielearn.com/", - }, - "modules:math": { - description: - "The math module provides mathematical functions and constants beyond basic arithmetic. Commonly used functions include sqrt() for square roots, floor() for rounding down, and ceil() for rounding up.", - example: - "import math\n\n# Returns the square root: 4.0\nmath.sqrt(16)\n\n# Rounds down to the nearest integer: 3\nmath.floor(3.9)\n\n# Rounds up to the nearest integer: 4\nmath.ceil(3.1)", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── TESTING ────────────────────────────────────────────────────────────────── - - testing: { - description: - "Testing is the process of checking that a program's code works correctly. Instead of manually verifying results by eye, a program can include automated tests that verify the output of functions for a given input. Writing tests helps catch mistakes early and makes bugs easier to find.", - example: - "def multiply(a, b):\n return a * b\n\ndef test_multiply():\n assert multiply(2, 3) == 6", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000009/", - }, - "testing:assert statements": { - description: - "An assert statement checks whether a condition is True. If it is, nothing happens and the program continues. If it is False, Python raises an AssertionError, signaling that something is not working as expected. Assert statements are the building block of writing tests.", - example: - "# This passes: no error\nassert 2 + 2 == 4\n\n# This fails and raises an AssertionError\nassert 2 + 2 == 5", - practiceUrl: "https://us.prairielearn.com/", - }, - "testing:Writing test functions": { - description: - "A test function is a function dedicated to testing one specific function in a program. Test function names typically start with test_. The test function calls the function being tested and uses assert statements to verify that the output is correct.", - example: - "def add(a, b):\n return a + b\n\ndef test_add():\n assert add(2, 3) == 5\n assert add(0, 0) == 0\n assert add(-1, 1) == 0", - practiceUrl: "https://us.prairielearn.com/", - }, - "testing:Edge cases": { - description: - "An edge case is an unusual or extreme input that might cause a function to behave unexpectedly. Good tests always include edge cases in addition to normal inputs. Common edge cases include empty values, zero, negative numbers, and the smallest or largest possible inputs.", - example: - "def divide(a, b):\n return a / b\n\ndef test_divide():\n # Normal case\n assert divide(10, 2) == 5.0\n\n # Edge case: dividing by a negative number\n assert divide(10, -2) == -5.0\n\n # Edge case: dividing zero by a number\n assert divide(0, 5) == 0.0", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── ERRORS & DEBUGGING ─────────────────────────────────────────────────────── - - "errors-debugging": { - description: - "An error occurs when Python encounters something in the code that it cannot execute. Debugging is the process of finding and fixing those errors. Understanding the different types of errors and how to read Python's error messages makes it easier to figure out what went wrong and where.", - example: - "# The code below causes an error\nsum = x + \n\nSyntaxError: invalid syntax", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000005/", - }, - "errors-debugging:Syntax errors": { - description: - "A syntax error occurs when the code breaks the rules of the Python language and Python cannot understand it. Python catches these before the program even runs. Common causes include missing colons, mismatched parentheses, or misspelled keywords. An IndentationError is a specific type of syntax error that happens when the indentation in a program is not consistent.", - example: - "# Missing colon after if statement\nif x > 5\n x = 10\n\n# Missing closing parenthesis\nresult = (2 + 3", - practiceUrl: "https://us.prairielearn.com/", - }, - "errors-debugging:Runtime errors": { - description: - "A runtime error occurs while the program is running — the syntax is correct, but something goes wrong during execution. Common examples include accessing an index in a sequence that doesn't exist or using the wrong data type for an operation. Common runtime error types include IndexError (index is out of bounds), TypeError (incorrect data type), ValueError (correct data type but inappropriate value), and NameError (variable is not defined).", - example: - '# Accessing an index that doesn\'t exist: IndexError\nnumbers = [1, 2, 3]\nnumbers[5]\n\n# Concatenating the wrong data type: TypeError\nresult = "hello" + 5\n\n# Trying to convert a non-numeric string to an integer: ValueError\nnum = int("abc")\n\n# Using a variable that hasn\'t been defined: NameError\nprint(x)', - practiceUrl: "https://us.prairielearn.com/", - }, - "errors-debugging:try / except": { - description: - "A try/except block lets a program handle a runtime error gracefully instead of crashing. Python attempts to run the code in the try block, and if an error occurs, it jumps to the except block and runs that instead.", - example: - 'try:\n result = "hello" + 5\nexcept TypeError:\n result = "hello"', - practiceUrl: "https://us.prairielearn.com/", - }, - "errors-debugging:Reading tracebacks": { - description: - "A traceback is the error message Python displays when a runtime error occurs. It shows exactly where the error happened and what type it was. The most important parts are the last line (the error type and description) and the line just above it (the exact code that caused the problem).", - example: - '# This code causes a traceback\nnumbers = [1, 2, 3]\nnumbers[5]\n\n# Traceback (most recent call last):\n# File "main.py", line 2, in \n# numbers[5]\n# IndexError: list index out of range', - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── DATA REPRESENTATION ────────────────────────────────────────────────────── - - "data-rep": { - description: - 'Data representation is the way computers store and display information using different numbering systems. These numbering systems are called bases. Since computers work in binary internally, other systems like hexadecimal and decimal exist to represent the same values in more compact or human-readable forms. The same value can be represented in different bases - even though the symbols look different, the value stays the same across each base. A Python "literal" is a value written directly in the code, which can be in decimal, binary, or hexadecimal depending on the prefix.', - example: "# Decimal\n10\n\n# Binary\n0b1010\n\n# Hex\n0xA", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/697992229/", - }, - "data-rep:Binary": { - description: - "Binary is the most fundamental language a computer speaks, using only two digits: 0 and 1. Every piece of data on a computer is stored as a series of 1s and 0s. Each position in a binary number represents a power of 2, so binary is a base-2 numbering system. In Python, a binary number is written using the prefix 0b.", - example: - "# The number 10 in binary:\n0b1010\n\n# The number 50 in binary:\n0b110010", - practiceUrl: "https://us.prairielearn.com/", - }, - "data-rep:Hex": { - description: - "Hexadecimal is a base-16 numbering system that uses 16 symbols — digits 0-9 and letters A through F — to represent values more compactly than binary. Each hex digit represents 4 binary digits. In Python, a hex number is written using the prefix 0x.", - example: "# The number 10 in hex:\n0xA\n\n# The number 50 in hex:\n0x32", - practiceUrl: "https://us.prairielearn.com/", - }, - "data-rep:Decimal": { - description: - "Decimal is the base-10 numbering system that humans use in everyday life. It uses the digits 0-9, and each position represents a power of 10. In Python, numbers written without a prefix are treated as decimal by default.", - example: "# A regular decimal number with no prefix\n42", - practiceUrl: "https://us.prairielearn.com/", - }, - "data-rep:Converting between bases": { - description: - "To convert any number from base b to decimal, multiply each digit by b raised to the power of its position, then add everything together. Positions are counted from right to left, starting at 0.\n\nTo convert a decimal number to base b, repeatedly divide by b, keeping track of the remainders. The remainders, read bottom to top, give you the result.\n\nSteps:\n1. Divide the number by b, note the remainder\n2. Divide the quotient by b, note the remainder\n3. Repeat until the quotient is 0\n4. The remainders, read in reverse order, give the result in base b.", - example: - "# Convert binary value 1011 to decimal\n(1 * 2**3) + (0 * 2**2) + (1 * 2**1) + (1 * 2**0) = 8 + 0 + 2 + 1 = 11\n\n# Convert hex value 2F to decimal (A=10, B=11,…, F=15)\n(2 * 16**1) + (15 * 16**0) = 32 + 15 = 47\n\n# Convert decimal value 11 to binary\n# 11 ÷ 2 = 5 with remainder 1 \n# 5 ÷ 2 = 2 with remainder 1 \n# 2 ÷ 2 = 1 with remainder 0 \n# 1 ÷ 2 = 0 remainder 1\n# Result: 1011", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── RECURSION ──────────────────────────────────────────────────────────────── - - recursion: { - description: - "Recursion is a technique where a function calls itself to solve a problem. Instead of using a loop, a recursive function breaks a problem down into a smaller version of the same problem, solving it step by step until it reaches a simple stopping point. For example, getting the nth value in the Fibonacci sequence (where each number is the sum of the two numbers before it: 0, 1, 1, 2, 3, 5, 8…) is naturally recursive because to find fib(n), you need fib(n-1) and fib(n-2) - the same problem, just smaller. The simple stopping point is the beginning of the sequence, which is always 0 and 1. ", - example: - "def fibonacci_recursive(n):\n # Base case\n if n <= 1:\n return n\n\n # Recursive step\n return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)", - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000011/", - }, - "recursion:Base case": { - description: - "The base case is the condition that stops a recursive function from calling itself. Every recursive function must have one — without it, the function would call itself forever. The base case handles the simplest version of the problem and returns a result directly.", - example: - "def countdown(n):\n # Base case: stop when n reaches 0\n if n == 0:\n return 0\n return countdown(n - 1)", - practiceUrl: "https://us.prairielearn.com/", - }, - "recursion:State change": { - description: - "State change means each recursive call moves the problem closer to the base case by changing the input in some way. Without state change, the function would never reach the base case and would call itself forever. Typically this means passing in a smaller or simpler value each time.", - example: - "def countdown(n):\n if n == 0:\n return 0\n # State change: n gets smaller with each call\n return countdown(n - 1)", - practiceUrl: "https://us.prairielearn.com/", - }, - "recursion:Recursive step": { - description: - "The recursive step is the line where the function calls itself with a changed input. Each recursive step handles one piece of the problem and passes the rest to the next call, until the base case is eventually reached.", - example: - "def factorial(n):\n # Base case: factorial of 0 is 1\n if n == 0:\n return 1\n # Recursive step: multiply n by factorial of n - 1\n return n * factorial(n - 1)", - practiceUrl: "https://us.prairielearn.com/", - }, - - // ── MAIN FUNCTION ──────────────────────────────────────────────────────────── - - "main-fn": { - description: - 'The if __name__ == "__main__" block is the standard way to designate the entry point of a program — the place where execution begins. It also controls which parts of the code only run when the file is executed directly, not when it is imported by another file.', - example: - 'if __name__ == "__main__":\n\n # Function calls go here\n function1()\n function2()', - practiceUrl: - "https://us.prairielearn.com/pl/course_instance/213067/instance_question/698000008/", - }, - "main-fn:Program structure": { - description: - 'A well-structured Python program follows a consistent layout: imports at the top, then function definitions, then the if __name__ == "__main__" block at the very bottom. This ensures everything is defined before it is used and makes the program easy to read.', - example: - 'def function1():\n # Code\n\ndef function2():\n # Code\n\nif __name__ == "__main__":\n\n function1()\n function2()', - practiceUrl: "https://us.prairielearn.com/", - }, - 'main-fn:Using if __name__ == "__main__"': { - description: - 'Every Python file has a built-in variable called __name__. When you run a file directly, Python sets __name__ to the string "__main__", so any code inside the if __name__ == "__main__" block runs automatically. Code outside this block — like function definitions — is still accessible to other files that import it, but will not run automatically.', - example: - 'def function1():\n # Code\n\nif __name__ == "__main__":\n\n # function1() runs when this file is executed directly\n function1()', - practiceUrl: "https://us.prairielearn.com/", - }, -}; diff --git a/frontend/src/main/data/conceptGraph.ts b/frontend/src/main/data/conceptGraph.ts deleted file mode 100644 index 1d7baff..0000000 --- a/frontend/src/main/data/conceptGraph.ts +++ /dev/null @@ -1,258 +0,0 @@ -export interface MajorConcept { - name: string; - label: string; - color: string; - subconcepts: string[]; -} - -export const majorConcepts: MajorConcept[] = [ - { - name: "data-types", - label: "Basic \n Data Types", - color: "#c99ffe", - subconcepts: ["Numeric (integers, floats)", "Strings", "Booleans"], - }, - { - name: "data-rep", - label: "Data \n Representation", - color: "#feaef2", - subconcepts: ["Binary", "Hex", "Decimal", "Converting between bases"], - }, - { - name: "variables", - label: "Variables", - color: "#feaef2", - subconcepts: ["Variable names", "Variable assignment", "+= and -="], - }, - { - name: "arithmetic-ops", - label: "Arithmetic \n Operations", - color: "#feaef2", - subconcepts: [ - "Simple arithmetic", - "Division", - "Modulo", - "Order of operations", - ], - }, - { - name: "string-ops", - label: "String \n Operations", - color: "#93ebff", - subconcepts: [ - "String concatenation", - "String formatting (f-strings)", - "String slicing", - "Comments", - ], - }, - { - name: "boolean-expr", - label: "Boolean \n Expressions", - color: "#feaef2", - subconcepts: [ - "Comparison: ==, <, <=, !=", - "Logical: and, or, not", - "Membership: in, not in", - ], - }, - { - name: "conditionals", - label: "Conditional \n Statements", - color: "#93ebff", - subconcepts: ["if", "else", "elif"], - }, - { - name: "functions", - label: "Functions", - color: "#93ebff", - subconcepts: [ - "Defining functions", - "Calling functions", - "Function parameters", - "Return statements", - ], - }, - { - name: "built-in-fns", - label: "Built-in \n Functions", - color: "#fe9a71", - subconcepts: ["len()", "min() and max()", "range()", "Type casting"], - }, - { - name: "main-fn", - label: 'if __name__ \n == "__main__":', - color: "#fe9a71", - subconcepts: ["Program structure", 'Using if __name__ == "__main__"'], - }, - { - name: "dictionaries", - label: "Dictionaries", - color: "#93ebff", - subconcepts: [ - "Creating a dictionary", - "Accessing a value", - "Adding/updating a key", - ], - }, - { - name: "input-output", - label: "Input \n & Output", - color: "#2bcd9c", - subconcepts: ["input()", "print()"], - }, - { - name: "loops", - label: "Loops", - color: "#fe9a71", - subconcepts: [ - "For loops", - "While loops", - "Break", - "Continue", - "Iteration", - "Accumulator pattern", - ], - }, - { - name: "lists", - label: "Lists", - color: "#93ebff", - subconcepts: ["Creating a list", "Accessing a value", "List slicing"], - }, - { - name: "nested-lists", - label: "Nested \n Lists", - color: "#fe9a71", - subconcepts: ["Creating a nested list", "Accessing values"], - }, - { - name: "nested-loops", - label: "Nested \n Loops", - color: "#2bcd9c", - subconcepts: ["Creating a nested loop", "Iterating over a nested list"], - }, - { - name: "tuples", - label: "Tuples", - color: "#93ebff", - subconcepts: ["Creating a tuple", "Accessing a value"], - }, - { - name: "sets", - label: "Sets", - color: "#93ebff", - subconcepts: ["Creating a set", "Adding an element"], - }, - { - name: "recursion", - label: "Recursion", - color: "#fe9a71", - subconcepts: ["Base case", "State change", "Recursive step"], - }, - { - name: "files", - label: "Files", - color: "#2bcd9c", - subconcepts: [ - "Opening & closing files", - "Reading a file", - "Writing to a file", - ], - }, - { - name: "methods", - label: "Built-in \n Methods", - color: "#fe9a71", - subconcepts: ["Calling a method", "List methods"], - }, - { - name: "string-methods", - label: "String \n Methods", - color: "#2bcd9c", - subconcepts: ["upper() and lower()", "replace()", "split()"], - }, - { - name: "modules", - label: "Imports & Modules", - color: "#2bcd9c", - subconcepts: ["Importing modules", "random", "math"], - }, - { - name: "mutability", - label: "Mutability", - color: "#fe9a71", - subconcepts: ["Mutable objects", "Immutable objects"], - }, - { - name: "testing", - label: "Testing", - color: "#fe9a71", - subconcepts: ["assert statements", "Writing test functions", "Edge cases"], - }, - { - name: "errors-debugging", - label: "Errors & \n Debugging", - color: "#93ebff", - subconcepts: [ - "Syntax errors", - "Runtime errors", - "try / except", - "Reading tracebacks", - ], - }, -]; - -export const prereqEdgeData = [ - // Up from Level 1 - { source: "data-types", target: "variables" }, - { source: "data-types", target: "arithmetic-ops" }, - { source: "data-types", target: "data-rep" }, - { source: "data-types", target: "boolean-expr" }, - - // Up from Level 2 - { source: "variables", target: "lists" }, - { source: "variables", target: "functions" }, - { source: "variables", target: "dictionaries" }, - { source: "variables", target: "sets" }, - { source: "variables", target: "tuples" }, - { source: "variables", target: "string-ops" }, - { source: "boolean-expr", target: "conditionals" }, - { source: "boolean-expr", target: "loops" }, - { source: "variables", target: "errors-debugging" }, - { source: "arithmetic-ops", target: "errors-debugging" }, - { source: "arithmetic-ops", target: "recursion" }, - { source: "arithmetic-ops", target: "loops" }, - { source: "arithmetic-ops", target: "string-ops" }, - - // Up from Level 3 - { source: "conditionals", target: "main-fn" }, - { source: "conditionals", target: "recursion" }, - { source: "functions", target: "testing" }, - { source: "functions", target: "main-fn" }, - { source: "functions", target: "built-in-fns" }, - { source: "functions", target: "methods" }, - { source: "functions", target: "recursion" }, - { source: "lists", target: "nested-lists" }, - { source: "lists", target: "mutability" }, - { source: "lists", target: "loops" }, - { source: "dictionaries", target: "mutability" }, - { source: "tuples", target: "mutability" }, - { source: "sets", target: "mutability" }, - - // Up from Level 4 - { source: "loops", target: "nested-loops" }, - { source: "built-in-fns", target: "input-output" }, - { source: "built-in-fns", target: "files" }, - { source: "methods", target: "string-methods" }, - { source: "methods", target: "modules" }, - { source: "nested-lists", target: "nested-loops" }, -]; - -/* -Level 1: Basic Data Types -Level 2: Data Representation, Variables, Arithmetic Operations,Boolean Expressions -Level 3: String Operations, Functions, Lists, Dictionaries, Tuples, Sets, Conditional Statements, Errors & Debugging -Level 4: Testing, Recursion, Built-in Functions, Nested Lists, Built-in Methods, Mutability, Nested Loops, Main Function -Level 5: Input & Output, String Methods, Files, Imports & Modules -*/ diff --git a/frontend/src/main/data/conceptGraphPositions.ts b/frontend/src/main/data/conceptGraphPositions.ts deleted file mode 100644 index 92256de..0000000 --- a/frontend/src/main/data/conceptGraphPositions.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const positions: Record = { - // Row 5 - "nested-loops": { x: 645, y: 0 }, - "input-output": { x: 955, y: 0 }, - files: { x: 1265, y: 0 }, - modules: { x: 1575, y: 0 }, - "string-methods": { x: 1885, y: 0 }, - // Row 4 - "main-fn": { x: 180, y: 300 }, - loops: { x: 490, y: 300 }, - recursion: { x: 800, y: 300 }, - "nested-lists": { x: 1110, y: 300 }, - "built-in-fns": { x: 1420, y: 300 }, - methods: { x: 1730, y: 300 }, - mutability: { x: 2040, y: 300 }, - testing: { x: 2350, y: 300 }, - // Row 3 - conditionals: { x: 180, y: 650 }, - "errors-debugging": { x: 490, y: 650 }, - lists: { x: 800, y: 650 }, - functions: { x: 1110, y: 650 }, - "string-ops": { x: 1420, y: 650 }, - dictionaries: { x: 1730, y: 650 }, - tuples: { x: 2040, y: 650 }, - sets: { x: 2350, y: 650 }, - // Row 2 - "arithmetic-ops": { x: 800, y: 1000 }, - "boolean-expr": { x: 1110, y: 1000 }, - variables: { x: 1420, y: 1000 }, - "data-rep": { x: 1730, y: 1000 }, - // Row 1 - "data-types": { x: 1265, y: 1350 }, -}; diff --git a/frontend/src/main/layouts/BasicLayout/BasicLayout.tsx b/frontend/src/main/layouts/BasicLayout/BasicLayout.tsx index ac08e0f..267a102 100644 --- a/frontend/src/main/layouts/BasicLayout/BasicLayout.tsx +++ b/frontend/src/main/layouts/BasicLayout/BasicLayout.tsx @@ -1,4 +1,4 @@ -import { useEffect, type ReactNode } from "react"; +import type { ReactNode } from "react"; import AppNavbar from "main/components/Nav/AppNavbar"; import Footer from "main/components/Nav/Footer"; import { useCurrentUser, useLogout } from "main/utils/currentUser"; @@ -6,27 +6,13 @@ import { useSystemInfo } from "main/utils/systemInfo"; interface BasicLayoutProps { children: ReactNode; - // When true (the Scaffold Canvas pages), the viewport is pinned so the - // React Flow canvas handles all pan/zoom itself: a class is added to - // that index.css uses to disable page scrolling. Every other page - // scrolls normally. - lockScroll?: boolean; } -export default function BasicLayout({ - children, - lockScroll = false, -}: BasicLayoutProps) { +export default function BasicLayout({ children }: BasicLayoutProps) { const doLogout = useLogout().mutate; const currentUser = useCurrentUser(); const { data: systemInfo } = useSystemInfo(); - useEffect(() => { - if (!lockScroll) return; - document.body.classList.add("scaffold-canvas-active"); - return () => document.body.classList.remove("scaffold-canvas-active"); - }, [lockScroll]); - return (
({ - url: "/api/courses/post", - method: "POST", - params: { - courseName: course.courseName, - term: course.term, - school: course.school, - }, - }); - - const onSuccess = (course) => { - toast(`Course ${course.courseName} created`); - setViewModal(false); - }; - - const mutation = useBackendMutation(objectToAxiosParams, { onSuccess }, [ - "/api/courses/list/admins", - ]); - - const onSubmit = async (data) => { - data.school = data.school.key; - mutation.mutate(data); - }; - - const createCourse = () => setViewModal(true); - - return ( - -
-

Courses

- - - -
-
- ); -} diff --git a/frontend/src/main/pages/Admin/AdminsIndexPage.tsx b/frontend/src/main/pages/Admin/AdminsIndexPage.tsx index 0b3cead..b7b5cb5 100644 --- a/frontend/src/main/pages/Admin/AdminsIndexPage.tsx +++ b/frontend/src/main/pages/Admin/AdminsIndexPage.tsx @@ -6,7 +6,7 @@ import RoleEmailTable, { } from "main/components/Users/RoleEmailTable"; export default function AdminsIndexPage(): React.JSX.Element { - const { data: instructors } = useBackend( + const { data: admins } = useBackend( ["/api/admin/all"], { method: "GET", url: "/api/admin/all" }, // Stryker disable next-line all : don't test default value of empty list @@ -31,7 +31,7 @@ export default function AdminsIndexPage(): React.JSX.Element { {createButton()}

Admins

({ - url: "/api/admin/instructors/post", + url: "/api/admin/researchers/post", method: "POST", params: { - email: instructor.email, + email: researcher.email, }, }); - const onSuccess = (instructor: RoleEmailFormFields) => { - toast(`New instructor added - email: ${instructor.email}`); - if (!storybook) navigation("/admin/instructors"); + const onSuccess = (researcher: RoleEmailFormFields) => { + toast(`New researcher added - email: ${researcher.email}`); + if (!storybook) navigation("/admin/researchers"); }; const mutation = useBackendMutation( objectToAxiosParams, { onSuccess }, - ["/api/admin/instructors/all"], // mutation makes this key stale so that pages relying on it reload + ["/api/admin/researchers/all"], // mutation makes this key stale so that pages relying on it reload ); const onSubmit = async (data: RoleEmailFormFields) => { @@ -43,7 +43,7 @@ export default function InstructorsCreatePage({ return (
-

Add New Instructor

+

Add New Researcher

diff --git a/frontend/src/main/pages/Admin/InstructorsIndexPage.tsx b/frontend/src/main/pages/Admin/ResearchersIndexPage.tsx similarity index 57% rename from frontend/src/main/pages/Admin/InstructorsIndexPage.tsx rename to frontend/src/main/pages/Admin/ResearchersIndexPage.tsx index 29524c3..817ce2f 100644 --- a/frontend/src/main/pages/Admin/InstructorsIndexPage.tsx +++ b/frontend/src/main/pages/Admin/ResearchersIndexPage.tsx @@ -5,10 +5,10 @@ import RoleEmailTable, { } from "main/components/Users/RoleEmailTable"; import { Link } from "react-router"; -export default function InstructorsIndexPage(): React.JSX.Element { - const { data: instructors } = useBackend( - ["/api/admin/instructors/get"], - { method: "GET", url: "/api/admin/instructors/get" }, +export default function ResearchersIndexPage(): React.JSX.Element { + const { data: researchers } = useBackend( + ["/api/admin/researchers/get"], + { method: "GET", url: "/api/admin/researchers/get" }, // Stryker disable next-line all : don't test default value of empty list [], ); @@ -17,10 +17,10 @@ export default function InstructorsIndexPage(): React.JSX.Element { return ( - New Instructor + New Researcher ); }; @@ -29,12 +29,12 @@ export default function InstructorsIndexPage(): React.JSX.Element {
{createButton()} -

Instructors

+

Researchers

diff --git a/frontend/src/main/pages/ConceptGraphPage.tsx b/frontend/src/main/pages/ConceptGraphPage.tsx deleted file mode 100644 index df85244..0000000 --- a/frontend/src/main/pages/ConceptGraphPage.tsx +++ /dev/null @@ -1,1388 +0,0 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from "react"; -import { useParams } from "react-router"; -import { Spinner } from "react-bootstrap"; -import { toast } from "react-toastify"; -import ScaffoldConceptGraph from "main/components/Scaffold/ScaffoldConceptGraph"; -import ConceptModal from "main/components/Concept/ConceptModal"; -import SubConceptModal from "main/components/Concept/SubConceptModal"; - -import BasicLayout from "main/layouts/BasicLayout/BasicLayout"; - -import { useQueryClient } from "@tanstack/react-query"; -import { useBackend, useBackendMutation } from "main/utils/useBackend"; -import type { - Assessment, - Course, - Question, - QuestionConcept, - MajorConceptDTO, - SubconceptDTO, - ConceptContentDTO, - PositionDTO, - EdgeDTO, - UserStateResponse, -} from "main/types/conceptGraph"; -import LoginScreen from "main/components/Auth/LoginScreen"; -import ScaffoldTopBar from "main/components/Scaffold/ScaffoldTopBar"; -import type { CourseAccess } from "main/components/Courses/CourseMenu"; -import { useCurrentUser } from "main/utils/currentUser"; -import { StaffToolsProvider } from "main/utils/staffTools"; -import { useStaffTools } from "main/utils/useStaffTools"; -import { - normalize, - toPastel, - computeScaffoldSubgraph, -} from "main/utils/conceptGraphUtils"; - -// Database-driven counterpart to LegacyHomePage.tsx, rendered at /course/{courseId}. -// All backend access goes through useBackend/useBackendMutation (React Query), -// against the concepts/*, user-state, and user-activity endpoints, so this page -// can eventually support any course, not just the one LegacyHomePage.tsx has -// baked in. LegacyHomePage.tsx itself is untouched and talks to the frozen -// /api/legacy/* endpoints via main/api/legacyClient.ts instead. - -interface SavedDetailCard { - cardType: string; - itemLabel: string; - conceptId: string; - conceptColor: string; - posX: number; - posY: number; -} - -interface EditableConcept { - id: number; - label: string; - description?: string | null; - example?: string | null; -} - -interface EditableSubconcept extends EditableConcept { - parentId?: number; - parentLabel?: string; - parent?: { - id: number; - label: string; - }; -} - -type ConceptModalState = - { mode: "create" } | { mode: "edit"; conceptId: number }; -type SubConceptModalState = - | { mode: "create"; parentConceptId: number } - | { mode: "edit"; subConceptId: number }; - -const DEFAULT_NEW_CONCEPT_POSITION = { x: 0, y: 0 }; - -function htmlToPlainText(html?: string | null) { - return html - ?.replace(//gi, "\n") - .replace(/<[^>]+>/g, "") - .replace(/ /g, " ") - .trim(); -} - -function getEditableConceptLabel(concept?: EditableConcept | MajorConceptDTO) { - if (!concept) return ""; - return "label" in concept - ? concept.label - : (htmlToPlainText(concept.labelHtml) ?? ""); -} - -// The staff tools (debug tooltips, subconcept reordering) act on this page's -// concept graph, so their provider is mounted here — not at the app root — -// and the Footer's toggles only appear here. -export default function ConceptGraphPage() { - const { courseId } = useParams<{ courseId: string }>(); - return ( - - {/* One mount per course: navigating between courses through the Courses - menu keeps this route's element mounted, but everything below — - per-course user state seeding, selections, and ScaffoldConceptGraph's - mount-time snapshot of the graph — assumes a fresh mount per course. - Keying by courseId enforces that; the React Query cache lives outside - the components, so returning to a visited course is still instant. */} - - - ); -} - -function ConceptGraphPageContent() { - const { courseId: courseIdParam } = useParams<{ courseId: string }>(); - const courseId = Number(courseIdParam); - const courseIdIsValid = - courseIdParam !== undefined && !Number.isNaN(courseId); - - const currentUser = useCurrentUser(); - const { registerNewConceptHandler, registerRealignConceptsHandler } = - useStaffTools(); - // Derive the numeric id from the users table; null when not logged in. - const userId: number | null = currentUser?.loggedIn - ? (currentUser.root.user?.id ?? null) - : null; - - const queryClient = useQueryClient(); - - const [selectedAssessmentId, setSelectedAssessmentId] = useState(""); - const [selectedQuestionId, setSelectedQuestionId] = useState(""); - - // Course-scoped: the backend returns [] when the course has no associated - // PlRepo/PlInstance yet, rather than an error. - const { data: assessments = [] } = useBackend( - ["/api/assessments", courseId], - { method: "GET", url: "/api/assessments", params: { courseId } }, - [], - false, - { enabled: courseIdIsValid }, - ); - - // The course endpoint is staff-only; for students the request fails and the - // UI simply omits staff affordances like the settings link, so the expected - // error must not toast. - const { data: course } = useBackend( - ["/api/courses", courseId], - { method: "GET", url: `/api/courses/${courseId}` }, - undefined, - true, - { enabled: courseIdIsValid, retry: false }, - ); - - // Unlike /api/courses/{id} above (staff-only), this endpoint is available to - // any user with access to the course (student, staff, instructor, or admin), - // so it drives the course-identifying banner shown to everyone at the top of - // the page. - const { data: courseInfo } = useBackend( - ["/api/courses/list", courseId], - { method: "GET", url: `/api/courses/list/${courseId}` }, - undefined, - true, - { enabled: courseIdIsValid, retry: false }, - ); - - const { data: questions = [] } = useBackend( - ["/api/assessments", selectedAssessmentId, "questions"], - { - method: "GET", - url: `/api/assessments/${selectedAssessmentId}/questions`, - }, - [], - false, - { enabled: !!selectedAssessmentId }, - ); - - const { data: questionConcepts = [] } = useBackend( - ["/api/questions", selectedQuestionId, "concepts"], - { method: "GET", url: `/api/questions/${selectedQuestionId}/concepts` }, - [], - false, - { enabled: !!selectedQuestionId }, - ); - const [highlightedIds, setHighlightedIds] = useState>(new Set()); - const [selectedConceptId, setSelectedConceptId] = useState( - null, - ); - const [highlightedSubconcepts, setHighlightedSubconcepts] = useState< - Map> - >(new Map()); - const [selectedItem, setSelectedItem] = useState(null); - const [starredIds, setStarredIds] = useState>(new Set()); - const [savedDetailCards, setSavedDetailCards] = useState( - [], - ); - const [initialDetailCards, setInitialDetailCards] = useState< - SavedDetailCard[] - >([]); - const [addedDetailKeys, setAddedDetailKeys] = useState>( - new Set(), - ); - const [masteredSubconcepts, setMasteredSubconcepts] = useState>( - new Set(), - ); - - // Graph data fetched from the backend for this course. These four queries - // feed state that the user mutates locally (dragging, reordering), so window - // focus must not silently refetch and clobber the local copy; retry is off so - // a failure surfaces as the error screen right away, as the old fetch did. - const graphQueryKey = ["/api/concepts/graph", courseId]; - const graphQuery = useBackend( - graphQueryKey, - { method: "GET", url: "/api/concepts/graph", params: { courseId } }, - [], - false, - { enabled: courseIdIsValid, retry: false, refetchOnWindowFocus: false }, - ); - const contentQuery = useBackend>( - ["/api/concepts/content", courseId], - { method: "GET", url: "/api/concepts/content", params: { courseId } }, - {}, - false, - { enabled: courseIdIsValid, retry: false, refetchOnWindowFocus: false }, - ); - const positionsQuery = useBackend>( - ["/api/concepts/positions", courseId], - { method: "GET", url: "/api/concepts/positions", params: { courseId } }, - {}, - false, - { enabled: courseIdIsValid, retry: false, refetchOnWindowFocus: false }, - ); - const edgesQuery = useBackend( - ["/api/concepts/edges", courseId], - { method: "GET", url: "/api/concepts/edges", params: { courseId } }, - [], - false, - { enabled: courseIdIsValid, retry: false, refetchOnWindowFocus: false }, - ); - - // Shared, instructor-controlled top-level positions (only POST - // /api/course/scaffold/reset changes them). - const positions = useMemo( - () => positionsQuery.data ?? {}, - [positionsQuery.data], - ); - const conceptContent = useMemo( - () => contentQuery.data ?? {}, - [contentQuery.data], - ); - const prereqEdgeData = useMemo( - () => edgesQuery.data ?? [], - [edgesQuery.data], - ); - - const graphDataError = - graphQuery.isError || - contentQuery.isError || - positionsQuery.isError || - edgesQuery.isError - ? `Failed to load concept graph data for course ${courseId}.` - : null; - // initialData makes these queries "successful" from the first render, so - // gate the page on isFetched (the first real response) instead. - const graphDataLoaded = - graphQuery.isFetched && - contentQuery.isFetched && - positionsQuery.isFetched && - edgesQuery.isFetched; - - // The graph comes straight from the query cache; subconcept reordering - // updates the cache optimistically (see handleSubconceptsReordered), and a - // refetch (e.g. invalidation after a rejected reorder) snaps it back to the - // authoritative order. Do NOT mirror this into useState: a mirror seeded by - // an effect lags one render behind, and ScaffoldConceptGraph snapshots the - // graph on mount — when all four queries resolve in one render batch, it - // would mount against the empty pre-effect value and stay blank. - const graphData = graphQuery.data; - const majorConcepts = useMemo(() => graphData ?? [], [graphData]); - - // Private, per-user overrides of top-level concept positions (dragged by this - // user); takes precedence over the shared `positions` above. Cleared - // course-wide by an instructor's scaffold reset. - const [topLevelPositions, setTopLevelPositions] = useState< - Record - >({}); - const [conceptModalState, setConceptModalState] = - useState(null); - const [subConceptModalState, setSubConceptModalState] = - useState(null); - - const userStateQuery = useBackend( - ["/api/user-state", userId, courseId], - { - method: "GET", - url: "/api/user-state", - params: { userid: userId, courseId }, - }, - undefined, - false, - { - enabled: !!userId && courseIdIsValid, - retry: false, - refetchOnWindowFocus: false, - }, - ); - - const saveUserStateMutation = useBackendMutation<{ - userid: number; - courseId: number; - starred_ids: string[]; - detail_cards: unknown[]; - mastered_subconcepts: string[]; - top_level_positions: Record; - }>((body) => ({ method: "POST", url: "/api/user-state", data: body }), {}); - - const logActivityMutation = useBackendMutation<{ - userid: number; - courseId: number; - event_type: string; - payload: object; - }>((body) => ({ method: "POST", url: "/api/user-activity", data: body }), {}); - - const reorderMutation = useBackendMutation< - { parentConceptId: number; orderedSubconceptIds: number[] }, - SubconceptDTO[] - >( - ({ parentConceptId, orderedSubconceptIds }) => ({ - method: "PUT", - url: "/api/concepts/subconcepts/reorder", - params: { parentConceptId }, - data: orderedSubconceptIds, - }), - { - // If the backend rejects the reorder, refetch the graph so the local - // order snaps back to the authoritative one (replaces the default toast; - // the snap-back itself is the user-visible signal). - onError: () => queryClient.invalidateQueries({ queryKey: graphQueryKey }), - }, - ); - - // "Realign Concepts" (Footer button, editing-mode only): recomputes levels, - // colors, and layout from the edges via POST /api/course/scaffold/reset. - const realignConceptsMutation = useBackendMutation( - () => ({ - method: "POST", - url: "/api/course/scaffold/reset", - params: { courseId }, - }), - { - onSuccess: () => { - toast("Concepts realigned successfully."); - }, - }, - [ - "/api/concepts/graph", - "/api/concepts/positions", - "/api/concepts/edges", - "/api/concepts/content", - ], - ); - - const conceptsPath = `/api/concepts/course?courseId=${courseId}`; - const editableConceptsQueryKey = [conceptsPath]; - const editableSubconceptsQueryKey = ["/api/concepts/subconcepts", courseId]; - const conceptMutationInvalidations = [ - conceptsPath, - "/api/concepts/top-level", - "/api/concepts/subconcepts", - "/api/concepts/graph", - "/api/concepts/content", - ]; - const subConceptMutationInvalidations = [ - conceptsPath, - "/api/concepts/subconcepts", - "/api/concepts/graph", - "/api/concepts/content", - ]; - - const { data: editableConcepts = [] } = useBackend( - editableConceptsQueryKey, - { method: "GET", url: conceptsPath }, - [], - true, - { enabled: currentUser?.loggedIn && courseIdIsValid }, - ); - - const { data: editableSubconcepts = [] } = useBackend( - editableSubconceptsQueryKey, - { - method: "GET", - url: "/api/concepts/subconcepts", - params: { courseId }, - }, - [], - true, - { enabled: currentUser?.loggedIn && courseIdIsValid }, - ); - - const masteredSubconceptsRef = useRef>(masteredSubconcepts); - useEffect(() => { - masteredSubconceptsRef.current = masteredSubconcepts; - }, [masteredSubconcepts]); - - const starredIdsRef = useRef>(starredIds); - useEffect(() => { - starredIdsRef.current = starredIds; - }, [starredIds]); - - const savedDetailCardsRef = useRef(savedDetailCards); - useEffect(() => { - savedDetailCardsRef.current = savedDetailCards; - }, [savedDetailCards]); - - const topLevelPositionsRef = - useRef>(topLevelPositions); - useEffect(() => { - topLevelPositionsRef.current = topLevelPositions; - }, [topLevelPositions]); - - const { mutate: logActivityMutate } = logActivityMutation; - const logActivity = useCallback( - (eventType: string, payload: object) => { - if (!userId || !courseIdIsValid) return; - logActivityMutate({ - userid: userId, - courseId, - event_type: eventType, - payload, - }); - }, - [userId, courseId, courseIdIsValid, logActivityMutate], - ); - - const openCreateConceptModal = useCallback( - () => setConceptModalState({ mode: "create" }), - [], - ); - - useEffect(() => { - if (!currentUser?.loggedIn || !courseIdIsValid) { - registerNewConceptHandler(null); - return; - } - registerNewConceptHandler(openCreateConceptModal); - return () => registerNewConceptHandler(null); - }, [ - currentUser?.loggedIn, - courseIdIsValid, - openCreateConceptModal, - registerNewConceptHandler, - ]); - - const { mutate: realignConceptsMutate } = realignConceptsMutation; - const realignConcepts = useCallback(() => { - realignConceptsMutate(); - }, [realignConceptsMutate]); - - useEffect(() => { - if (!currentUser?.loggedIn || !courseIdIsValid) { - registerRealignConceptsHandler(null); - return; - } - registerRealignConceptsHandler(realignConcepts); - return () => registerRealignConceptsHandler(null); - }, [ - currentUser?.loggedIn, - courseIdIsValid, - realignConcepts, - registerRealignConceptsHandler, - ]); - - // Log the login once per visit, as soon as we know who the user is. - const loginLoggedRef = useRef(false); - useEffect(() => { - if (!userId || !courseIdIsValid || loginLoggedRef.current) return; - loginLoggedRef.current = true; - logActivity("login", { consented: true }); - }, [userId, courseIdIsValid, logActivity]); - - // Seed the locally-mutated state from the user's saved state exactly once; - // later query refetches must not clobber unsaved local changes. - const userStateSeededRef = useRef(false); - const savedUserState = userStateQuery.data; - useEffect(() => { - if (!savedUserState || userStateSeededRef.current) return; - userStateSeededRef.current = true; - setStarredIds(new Set(savedUserState.starred_ids as string[])); - const cards = (savedUserState.detail_cards as SavedDetailCard[]) ?? []; - setSavedDetailCards(cards); - setAddedDetailKeys( - new Set(cards.map((c) => `${c.cardType}:${c.itemLabel}`)), - ); - setInitialDetailCards(cards); - setMasteredSubconcepts( - new Set((savedUserState.mastered_subconcepts ?? []) as string[]), - ); - setTopLevelPositions(savedUserState.top_level_positions ?? {}); - }, [savedUserState]); - - const { mutate: saveUserStateMutate } = saveUserStateMutation; - const persistState = useCallback( - ( - stars: Set, - cards: SavedDetailCard[], - mastered: Set = masteredSubconceptsRef.current, - topLevelPos: Record< - string, - { x: number; y: number } - > = topLevelPositionsRef.current, - ) => { - if (!userId || !courseIdIsValid) return; - const starredIdsArray = Array.from(stars); - const masteredArray = Array.from(mastered); - saveUserStateMutate({ - userid: userId, - courseId, - starred_ids: starredIdsArray, - detail_cards: cards, - mastered_subconcepts: masteredArray, - top_level_positions: topLevelPos, - }); - // Mirror the save into the cached user state, so that remounting this - // course (switching away and back) seeds from the state the user last - // saw — the cache otherwise still holds the snapshot fetched when the - // course was first opened, and the once-per-mount seed would restore it. - queryClient.setQueryData( - ["/api/user-state", userId, courseId], - { - starred_ids: starredIdsArray, - detail_cards: cards, - mastered_subconcepts: masteredArray, - top_level_positions: topLevelPos, - }, - ); - }, - [userId, courseId, courseIdIsValid, saveUserStateMutate, queryClient], - ); - - // Private overrides take precedence over the shared, instructor-controlled positions. - const effectivePositions = useMemo( - () => ({ ...positions, ...topLevelPositions }), - [positions, topLevelPositions], - ); - - const handleMajorMoved = (name: string, posX: number, posY: number) => { - setTopLevelPositions((prev) => { - const next = { ...prev, [name]: { x: posX, y: posY } }; - persistState( - starredIdsRef.current, - savedDetailCardsRef.current, - masteredSubconceptsRef.current, - next, - ); - return next; - }); - }; - - const handleSubconceptMastered = (sub: string) => { - setMasteredSubconcepts((prev) => { - const next = new Set(prev); - if (next.has(sub)) { - next.delete(sub); - } else { - next.add(sub); - } - persistState(starredIdsRef.current, savedDetailCardsRef.current, next); - return next; - }); - }; - - // An author (with editing enabled) drag-and-dropped a card's - // subconcepts. ScaffoldConceptGraph already updated its own nodes; mirror the - // new order into the cached graph data (so anything rebuilt from it agrees) - // and persist it via the reorder mutation, whose onError refetches so the - // order snaps back to the authoritative one. - const handleSubconceptsReordered = ( - parentConceptId: number, - orderedSubconceptIds: number[], - ) => { - queryClient.setQueryData(graphQueryKey, (prev) => - prev?.map((concept) => - concept.id === parentConceptId - ? { - ...concept, - subconcepts: orderedSubconceptIds - .map((subId) => - concept.subconcepts.find((sub) => sub.id === subId), - ) - .filter((sub): sub is SubconceptDTO => sub !== undefined), - } - : concept, - ), - ); - reorderMutation.mutate({ parentConceptId, orderedSubconceptIds }); - }; - - const conceptModalInitialContents = useMemo(() => { - if (!conceptModalState || conceptModalState.mode === "create") - return undefined; - - const editableConcept = editableConcepts.find( - (concept) => concept.id === conceptModalState.conceptId, - ); - if (editableConcept) return editableConcept; - - const fallbackConcept = majorConcepts.find( - (concept) => concept.id === conceptModalState.conceptId, - ); - const fallbackContent = conceptContent[String(conceptModalState.conceptId)]; - return { - label: htmlToPlainText(fallbackConcept?.labelHtml) ?? "", - description: htmlToPlainText(fallbackContent?.descriptionHtml) ?? "", - example: htmlToPlainText(fallbackContent?.exampleHtml) ?? "", - }; - }, [conceptModalState, editableConcepts, majorConcepts, conceptContent]); - - const subConceptModalInitialContents = useMemo(() => { - if (!subConceptModalState) return undefined; - - if (subConceptModalState.mode === "create") { - const parentConcept = - editableConcepts.find( - (concept) => concept.id === subConceptModalState.parentConceptId, - ) ?? - majorConcepts.find( - (concept) => concept.id === subConceptModalState.parentConceptId, - ); - - return { - parentId: subConceptModalState.parentConceptId, - parentLabel: getEditableConceptLabel(parentConcept), - }; - } - - const editableSubconcept = editableSubconcepts.find( - (subconcept) => subconcept.id === subConceptModalState.subConceptId, - ); - if (editableSubconcept) { - return { - ...editableSubconcept, - parentId: - editableSubconcept.parentId ?? editableSubconcept.parent?.id ?? "", - parentLabel: - editableSubconcept.parentLabel ?? - editableSubconcept.parent?.label ?? - "", - }; - } - - const fallbackParent = majorConcepts.find((concept) => - concept.subconcepts.some( - (subconcept) => subconcept.id === subConceptModalState.subConceptId, - ), - ); - const fallbackSubconcept = fallbackParent?.subconcepts.find( - (subconcept) => subconcept.id === subConceptModalState.subConceptId, - ); - const fallbackContent = - conceptContent[String(subConceptModalState.subConceptId)]; - return { - parentId: fallbackParent?.id ?? "", - parentLabel: htmlToPlainText(fallbackParent?.labelHtml) ?? "", - label: htmlToPlainText(fallbackSubconcept?.labelHtml) ?? "", - description: htmlToPlainText(fallbackContent?.descriptionHtml) ?? "", - example: htmlToPlainText(fallbackContent?.exampleHtml) ?? "", - }; - }, [ - subConceptModalState, - editableConcepts, - editableSubconcepts, - majorConcepts, - conceptContent, - ]); - - const createConceptMutation = useBackendMutation( - (concept: EditableConcept) => ({ - url: "/api/concept", - method: "POST", - data: { - courseId, - label: concept.label, - description: concept.description, - example: concept.example, - ...DEFAULT_NEW_CONCEPT_POSITION, - }, - }), - { - onSuccess: async (concept: EditableConcept) => { - toast(`Concept ${concept.label} created`); - setConceptModalState(null); - }, - }, - conceptMutationInvalidations, - ); - - const updateConceptMutation = useBackendMutation( - (concept: EditableConcept) => ({ - url: `/api/concept/put?conceptId=${ - conceptModalState?.mode === "edit" ? conceptModalState.conceptId : "" - }`, - method: "PUT", - data: { - label: concept.label, - description: concept.description, - example: concept.example, - }, - }), - { - onSuccess: async (concept: EditableConcept) => { - toast(`Concept ${concept.label} updated`); - setConceptModalState(null); - }, - }, - conceptMutationInvalidations, - ); - - const createSubconceptMutation = useBackendMutation( - (subconcept: EditableSubconcept) => ({ - url: "/api/concept/subconcept", - method: "POST", - data: { - courseId, - parentConceptId: Number(subconcept.parentId), - label: subconcept.label, - description: subconcept.description, - example: subconcept.example, - }, - }), - { - onSuccess: async (subconcept: EditableSubconcept) => { - toast(`SubConcept ${subconcept.label} created`); - setSubConceptModalState(null); - }, - }, - subConceptMutationInvalidations, - ); - - const updateSubconceptMutation = useBackendMutation( - (subconcept: EditableSubconcept) => ({ - url: `/api/concept/subconcept/put?conceptId=${ - subConceptModalState?.mode === "edit" - ? subConceptModalState.subConceptId - : "" - }`, - method: "PUT", - data: { - label: subconcept.label, - description: subconcept.description, - example: subconcept.example, - }, - }), - { - onSuccess: async (subconcept: EditableSubconcept) => { - toast(`SubConcept ${subconcept.label} updated`); - setSubConceptModalState(null); - }, - }, - subConceptMutationInvalidations, - ); - - const handlePaneClick = () => { - if (!selectedQuestionId) { - setSelectedConceptId(null); - setSelectedItem(null); - setHighlightedIds(new Set()); - } - }; - - // Selecting a different assessment clears the question selection and any - // question-driven highlighting; the questions query above refetches on its - // own because its key includes selectedAssessmentId. - useEffect(() => { - if (!selectedAssessmentId) return; - /* eslint-disable react-hooks/set-state-in-effect */ - setSelectedQuestionId(""); - setHighlightedIds(new Set()); - setSelectedConceptId(null); - setHighlightedSubconcepts(new Map()); - /* eslint-enable react-hooks/set-state-in-effect */ - }, [selectedAssessmentId]); - - useEffect(() => { - if (!selectedQuestionId) return; - // eslint-disable-next-line react-hooks/set-state-in-effect - setSelectedConceptId(null); - logActivity("question_viewed", { questionId: selectedQuestionId }); - }, [logActivity, selectedQuestionId]); - - // Recompute the question-driven highlighting whenever the question's - // concepts arrive or the edges finish loading, so a question selected before - // that point still gets its full prerequisite chain highlighted rather than - // only the tagged concepts. - useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect */ - if (!selectedQuestionId) { - setHighlightedIds(new Set()); - setHighlightedSubconcepts(new Map()); - return; - } - setHighlightedIds( - computeScaffoldSubgraph( - questionConcepts.map((c) => c.concept_id), - prereqEdgeData, - ), - ); - const subMap = new Map>(); - questionConcepts.forEach((c) => { - if (c.subconcept_label) { - if (!subMap.has(c.concept_id)) subMap.set(c.concept_id, new Set()); - subMap.get(c.concept_id)!.add(normalize(c.subconcept_label)); - } - }); - setHighlightedSubconcepts(subMap); - /* eslint-enable react-hooks/set-state-in-effect */ - }, [selectedQuestionId, questionConcepts, prereqEdgeData]); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setSelectedItem(null); - }, [selectedConceptId]); - - useEffect(() => { - if (!selectedItem || !selectedConceptId) return; - logActivity("detail_visited", { - conceptId: selectedConceptId, - itemLabel: selectedItem, - }); - }, [logActivity, selectedConceptId, selectedItem]); - - const selectedConcept = selectedConceptId - ? majorConcepts.find((c) => String(c.id) === selectedConceptId) - : null; - - const handleConceptClick = (id: string) => { - setSelectedConceptId(id); - if (!selectedQuestionId) { - setHighlightedIds(computeScaffoldSubgraph([id], prereqEdgeData)); - } - logActivity("concept_clicked", { conceptId: id }); - }; - - const handleStarClick = (id: string) => { - setStarredIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - persistState(next, savedDetailCardsRef.current); - return next; - }); - }; - - const handleReset = () => { - setStarredIds(new Set()); - setHighlightedIds(new Set()); - setHighlightedSubconcepts(new Map()); - setSelectedConceptId(null); - setSelectedItem(null); - setSelectedQuestionId(""); - setSavedDetailCards([]); - setAddedDetailKeys(new Set()); - setMasteredSubconcepts(new Set()); - setTopLevelPositions({}); - persistState(new Set(), [], new Set(), {}); - }; - - const handleDetailAdded = (card: SavedDetailCard) => { - const key = `${card.cardType}:${card.itemLabel}`; - setAddedDetailKeys((prev) => new Set([...prev, key])); - setSavedDetailCards((prev) => { - const next = [...prev, card]; - persistState(starredIdsRef.current, next); - return next; - }); - logActivity("detail_added_to_graph", { - cardType: card.cardType, - itemLabel: card.itemLabel, - conceptId: card.conceptId, - }); - }; - - const handleDetailDeleted = (cardType: string, itemLabel: string) => { - const key = `${cardType}:${itemLabel}`; - setAddedDetailKeys((prev) => { - const s = new Set(prev); - s.delete(key); - return s; - }); - setSavedDetailCards((prev) => { - const next = prev.filter( - (c) => !(c.cardType === cardType && c.itemLabel === itemLabel), - ); - persistState(starredIdsRef.current, next); - return next; - }); - }; - - const handleDetailMoved = ( - cardType: string, - itemLabel: string, - posX: number, - posY: number, - ) => { - setSavedDetailCards((prev) => { - const next = prev.map((c) => - c.cardType === cardType && c.itemLabel === itemLabel - ? { ...c, posX, posY } - : c, - ); - persistState(starredIdsRef.current, next); - return next; - }); - }; - - const handleConceptDoubleClick = (conceptId: string) => { - setConceptModalState({ mode: "edit", conceptId: Number(conceptId) }); - }; - - const handleSubconceptDoubleClick = ( - _parentConceptId: string, - subconceptId: string, - ) => { - setSubConceptModalState({ - mode: "edit", - subConceptId: Number(subconceptId), - }); - }; - - const handleAddSubconcept = (parentConceptId: string) => { - setSubConceptModalState({ - mode: "create", - parentConceptId: Number(parentConceptId), - }); - }; - - if (!currentUser?.loggedIn) { - return ( - - - - ); - } - - if (!courseIdIsValid) { - return ( - -
Invalid course id.
-
- ); - } - - if (graphDataError) { - return ( - -
{graphDataError}
-
- ); - } - - if (!graphDataLoaded) { - return ( - -
- - Loading... - -
-
- ); - } - - return ( - -
- {/* ── Top bar ── */} - { - setSelectedAssessmentId(id); - setSelectedQuestionId(""); - }} - questions={questions} - selectedQuestionId={selectedQuestionId} - onSelectQuestion={setSelectedQuestionId} - numStarredConcepts={starredIds.size} - numTotalConcepts={majorConcepts.length} - /> - - {/* ── Graph ── */} -
- -
- { - if (!show) setConceptModalState(null); - }} - initialContents={conceptModalInitialContents} - onSubmitAction={(concept: EditableConcept) => - conceptModalState?.mode === "edit" - ? updateConceptMutation.mutate(concept) - : createConceptMutation.mutate(concept) - } - buttonText={conceptModalState?.mode === "edit" ? "Update" : "Create"} - modalTitle={ - conceptModalState?.mode === "edit" - ? "Edit Concept" - : "Create Concept" - } - /> - { - if (!show) setSubConceptModalState(null); - }} - initialContents={subConceptModalInitialContents} - onSubmitAction={(subconcept: EditableSubconcept) => - subConceptModalState?.mode === "edit" - ? updateSubconceptMutation.mutate(subconcept) - : createSubconceptMutation.mutate(subconcept) - } - buttonText={ - subConceptModalState?.mode === "edit" ? "Update" : "Create" - } - modalTitle={ - subConceptModalState?.mode === "edit" - ? "Edit SubConcept" - : "Create SubConcept" - } - /> - - {/* ── Bottom toolbar ── */} -
- {/* Main toolbar row */} -
- {selectedConcept ? ( - <> - {/* Main concept button */} - - - {/* Divider */} -
- - {/* Subconcept buttons */} -
- {selectedConcept.subconcepts.map((sub) => ( - - ))} -
- - {/* Close button */} -
{ - setSelectedConceptId(null); - setSelectedItem(null); - if (!selectedQuestionId) setHighlightedIds(new Set()); - }} - > - - - - -
- - ) : ( -
- {selectedQuestionId - ? "Click a concept card to explore it." - : "Select a question, or click any concept card to explore it."} -
- )} -
- - {/* Expanded cards — only shown when a button is clicked */} - {selectedConcept && - selectedItem !== null && - (() => { - // selectedItem holds a subconcept's labelHtml, or the major - // concept's node id (its numeric id as a string). Content is - // keyed by each concept's own numeric id. - const isMajorConcept = - selectedItem === String(selectedConcept.id); - const selectedItemLabel = isMajorConcept - ? selectedConcept.labelHtml.replace(/\n/g, " ") - : (selectedItem ?? ""); - const subconceptId = selectedConcept.subconcepts.find( - (s) => s.labelHtml === selectedItem, - )?.id; - const contentKey = isMajorConcept - ? String(selectedConcept.id) - : String(subconceptId); - const content = conceptContent[contentKey]; - - return ( -
- {( - [ - { label: "Description", key: "descriptionHtml" }, - { label: "Example", key: "exampleHtml" }, - ] as { - label: string; - key: "descriptionHtml" | "exampleHtml"; - }[] - ).map((card) => { - const isAdded = addedDetailKeys.has( - `${card.label}:${selectedItemLabel}`, - ); - return ( -
{ - e.dataTransfer.setData( - "application/scaffold-card", - JSON.stringify({ - cardType: card.label, - itemLabel: selectedItemLabel, - conceptId: String(selectedConcept.id), - conceptColor: selectedConcept.color, - cardContent: content?.[card.key] ?? "", - }), - ); - e.dataTransfer.effectAllowed = "move"; - } - } - style={{ - flex: 1, - background: toPastel(selectedConcept.color), - borderRadius: 8, - border: "1px solid #000000", - padding: "10px 14px", - textAlign: "left", - cursor: isAdded ? "default" : "grab", - opacity: isAdded ? 0.8 : 1, - }} - > - - {card.label} - -
- {card.key === "exampleHtml" ? ( -
- ) : ( -
- )} -
-
- ); - })} - - {isMajorConcept && ( - - Practice with a
- PrairieLearn
question -
- )} -
- ); - })()} -
-
- - ); -} diff --git a/frontend/src/main/pages/Courses/InstructorCourseShowPage.jsx b/frontend/src/main/pages/Courses/InstructorCourseShowPage.jsx deleted file mode 100644 index 118ec0d..0000000 --- a/frontend/src/main/pages/Courses/InstructorCourseShowPage.jsx +++ /dev/null @@ -1,178 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useBackend } from "main/utils/useBackend"; - -import BasicLayout from "main/layouts/BasicLayout/BasicLayout"; -import { useCurrentUser } from "main/utils/currentUser"; -import { useNavigate, useParams } from "react-router"; - -import Modal from "react-bootstrap/Modal"; -import { Button, Tab, Tabs, OverlayTrigger, Tooltip } from "react-bootstrap"; -import EnrollmentTabComponent from "main/components/Courses/TabComponent/EnrollmentTabComponent"; -import StaffTabComponent from "main/components/Courses/TabComponent/StaffTabComponent"; -import SettingsTabComponent from "main/components/Courses/TabComponent/SettingsTabComponent"; -import JobTabComponent from "main/components/Courses/TabComponent/JobTabComponent"; -import { hasRole } from "main/utils/currentUser"; -import DownloadsTabComponent from "main/components/Courses/TabComponent/DownloadsTabComponent"; -import LinkToScaffold from "main/components/Scaffold/LinkToScaffold"; -import ScaffoldTabComponent from "main/components/Courses/TabComponent/ScaffoldTabComponent"; -import ConceptTabComponent from "main/components/Courses/TabComponent/ConceptTabComponent"; -import SubConceptTabComponent from "main/components/Courses/TabComponent/SubConceptTabComponent"; -import EdgeConceptTabComponent from "main/components/Courses/TabComponent/EdgeConceptTabComponent"; -import PLTabComponent from "main/components/Courses/TabComponent/PLTabComponent"; - -export default function InstructorCourseShowPage({ - testId = "InstructorCourseShowPage", - showSettingsTab = true, - staffTabIsInstructor = true, - canEditStudents = undefined, - canManageTeams = undefined, -}) { - const currentUser = useCurrentUser(); - const courseId = useParams().id; - const [showErrorModal, setShowErrorModal] = useState(false); - - const { - data: course, - error: _errorCourse, - status: _statusCourse, - failureCount: courseBackendFailureCount, - } = useBackend( - [`/api/courses/${courseId}`], - // Stryker disable next-line StringLiteral : GET and empty string are equivalent - { method: "GET", url: `/api/courses/${courseId}` }, - null, - true, - ); - - // Stryker disable OptionalChaining -- course?.instructorEmail is more readable than course && course.instructorEmail - const getCourseFailed = courseBackendFailureCount > 0; - const canEditCourseOptions = - hasRole(currentUser, "ROLE_ADMIN") || - currentUser?.root?.user?.email === course?.instructorEmail; - // Stryker enable OptionalChaining - - const navigate = useNavigate(); - useEffect(() => { - if (getCourseFailed) { - setShowErrorModal(true); - const timer = setTimeout(() => { - navigate("/", { replace: true }); - }, 3000); - // Stryker disable next-line BlockStatement - return () => { - clearTimeout(timer); - }; - } - }, [getCourseFailed, navigate]); - - return ( - - - - Course Not Found - - - Course not found. You will be returned to the course list in 3 - seconds. - - - - - - {!course ? ( -
Course: Loading...
- ) : ( -
-
-
- {course.orgName && ( - {course.orgName} - )} -
-
-

- -

- - {course.term} - -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- )} -
- ); -} diff --git a/frontend/src/main/pages/Courses/InstructorCoursesIndexPage.jsx b/frontend/src/main/pages/Courses/InstructorCoursesIndexPage.jsx deleted file mode 100644 index c4bf5eb..0000000 --- a/frontend/src/main/pages/Courses/InstructorCoursesIndexPage.jsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from "react"; -import { useBackend, useBackendMutation } from "main/utils/useBackend"; - -import BasicLayout from "main/layouts/BasicLayout/BasicLayout"; -import InstructorAdminCoursesTable from "main/components/Courses/InstructorAdminCoursesTable"; -import { useCurrentUser } from "main/utils/currentUser"; -import { Button } from "react-bootstrap"; -import { toast } from "react-toastify"; -import CourseModal from "main/components/Courses/CourseModal"; - -export default function InstructorCoursesIndexPage() { - const currentUser = useCurrentUser(); - - const { - data: courses, - error: _error, - status: _status, - } = useBackend( - ["/api/courses/list/instructors"], - { method: "GET", url: "/api/courses/list/instructors" }, - // Stryker disable next-line all : don't test default value of empty list - [], - ); - - const [viewModal, setViewModal] = React.useState(false); - - const objectToAxiosParams = (course) => ({ - url: "/api/courses/post", - method: "POST", - params: { - courseName: course.courseName, - term: course.term, - school: course.school, - }, - }); - - const onSuccess = (course) => { - toast(`Course ${course.courseName} created`); - setViewModal(false); - }; - - const mutation = useBackendMutation(objectToAxiosParams, { onSuccess }, [ - "/api/courses/list/instructors", - ]); - - const onSubmit = async (data) => { - data.school = data.school.key; - mutation.mutate(data); - }; - - const createCourse = () => setViewModal(true); - - return ( - -
-

Courses

- - - -
-
- ); -} diff --git a/frontend/src/main/pages/Help/AboutCitelines.jsx b/frontend/src/main/pages/Help/AboutCitelines.jsx new file mode 100644 index 0000000..178de5f --- /dev/null +++ b/frontend/src/main/pages/Help/AboutCitelines.jsx @@ -0,0 +1,18 @@ +import BasicLayout from "main/layouts/BasicLayout/BasicLayout"; + +export default function AboutCitelines() { + return ( + +

About Citelines

+ +

+ Citelines is a UCSB CS project. This application shell was built from{" "} + + proj-scaffold + + , and provides Google sign-in, Admin/Researcher role management, a + background jobs subsystem, and a Developer page. +

+
+ ); +} diff --git a/frontend/src/main/pages/Help/AboutScaffold.jsx b/frontend/src/main/pages/Help/AboutScaffold.jsx deleted file mode 100644 index 194a698..0000000 --- a/frontend/src/main/pages/Help/AboutScaffold.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import BasicLayout from "main/layouts/BasicLayout/BasicLayout"; - -export default function AboutScaffold() { - return ( - -

About Scaffold

- -

- Scaffold is a web-based learning tool designed to help students - visualize the prerequisite relationships between course concepts through - an interactive knowledge graph. -

- -

What Scaffold is for

- -

- Scaffold was designed around a single overarching goal: to reduce - extraneous cognitive load for students working through homework and - practice problems, particularly introductory programming courses. -

- -

- Extraneous cognitive load refers to mental effort that does not - contribute directly to learning the target material. In this context, - this is the effort of searching through course material like lecture - slides, textbook content, and prior assignments to identify which - concepts are needed to solve a given problem. -

- -

- Introductory programming courses present students with a large number of - interdependent concepts in a short period of time, making it difficult - for students to identify gaps in their understanding or recognize how - foundational concepts build toward more complex ones. -

- -

- Scaffold integrates with{" "} - PrairieLearn, an online - assessment platform, to display the specific concepts and subconcepts - relevant to each practice problem, allowing students to explore concept - descriptions, code examples, and prerequisite dependencies in context. -

- -

- By surfacing this information directly within a structured, interactive - interface, Scaffold allows students to direct their attention toward - understanding concepts rather than locating them. The design priorities - were: -

- -
    -
  1. - Provide a shortcut for navigating course content when stuck on a - problem -
  2. -
  3. - Make prerequisite relationships between concepts explicit and - navigable, and -
  4. -
  5. - Display course material clearly and concisely, without extraneous - information. -
  6. -
- -

Where did Scaffold come from

- -

- The initial prototype of Scaffold, with a design and implementation of - the Concept Graph, was designed and implemented by Kate Larrick as part - of her MS project in the{" "} - Department of Computer Science at the - University of California, Santa Barbara. She was advised by{" "} - Phill Conrad,{" "} - Diba Mirza and{" "} - Misha Sra in the - completion of this project. -

- -

- Phill Conrad expanded the prototype with features to add support for - multiple courses, and allow creating and editing of concept graphs. It - is open source, and the source code is available here:{" "} - - https://github.com/ucsb-cs156/proj-scaffold - -

- -

- The ongoing maintenance of the project will be shared between Prof. - Conrad and the students of{" "} - CMPSC 156, a course in - applied software engineering focused on legacy code projects. -

-
- ); -} diff --git a/frontend/src/main/pages/Home/HomePageLoggedIn.jsx b/frontend/src/main/pages/Home/HomePageLoggedIn.jsx index c52eb6f..c5a3976 100644 --- a/frontend/src/main/pages/Home/HomePageLoggedIn.jsx +++ b/frontend/src/main/pages/Home/HomePageLoggedIn.jsx @@ -1,115 +1,13 @@ -import CoursesTable from "main/components/Courses/CoursesTable"; import BasicLayout from "main/layouts/BasicLayout/BasicLayout"; -import { useBackend, useBackendMutation } from "main/utils/useBackend"; -import { toast } from "react-toastify"; -import { useCurrentUser } from "main/utils/currentUser"; -import InstructorAdminCoursesTable from "main/components/Courses/InstructorAdminCoursesTable"; -import CourseModal from "main/components/Courses/CourseModal"; -import Button from "react-bootstrap/Button"; -import React from "react"; -import { hasRole } from "main/utils/currentUser"; -import { StudentCoursesTable } from "main/components/Courses/StudentCoursesTable"; export default function HomePageLoggedIn() { - const currentUser = useCurrentUser(); - const { - data: staffCourses, - error: _staffError, - status: _staffStatus, - } = useBackend( - ["/api/courses/list/staff"], - // Stryker disable next-line StringLiteral : The default value for an empty ("") method is GET. - { method: "GET", url: "/api/courses/list/staff" }, - // Stryker disable next-line all : don't test default value of empty list - [], - ); - const { - data: instructorCourses, - error: _instructorError, - status: _instructorStatus, - } = useBackend( - ["/api/courses/list/instructors"], - // Stryker disable next-line StringLiteral : The default value for an empty ("") method is GET. Therefore, there is no way to kill a mutation that transforms "GET" to "" - { method: "GET", url: "/api/courses/list/instructors" }, - // Stryker disable next-line all : don't test default value of empty list - [], - false, - { - enabled: Boolean(hasRole(currentUser, "ROLE_INSTRUCTOR")), - }, - ); - - const [viewModal, setViewModal] = React.useState(false); - - const objectToAxiosParams = (course) => ({ - url: "/api/courses/post", - method: "POST", - params: { - courseName: course.courseName, - term: course.term, - school: course.school, - }, - }); - - const onSuccess = (course) => { - toast(`Course ${course.courseName} created`); - setViewModal(false); - }; - - const mutation = useBackendMutation(objectToAxiosParams, { onSuccess }, [ - "/api/courses/list/instructors", - ]); - - const onSubmit = async (data) => { - data.school = data.school.key; - mutation.mutate(data); - }; - - const createCourse = () => setViewModal(true); - return (
- {hasRole(currentUser, "ROLE_INSTRUCTOR") && ( - <> - - -

Your Instructor Courses

- {instructorCourses.length === 0 && ( -

- No instructor courses yet. Click the button above to create one. -

- )} - {instructorCourses.length > 0 && ( - <> - - - )} - - )} -

Your Student Courses

- -

Your Staff Courses

- {staffCourses.length === 0 &&

No staff courses yet.

} - {staffCourses.length > 0 && ( - - )} +

Citelines

+

+ This is a placeholder home page. +

); diff --git a/frontend/src/main/pages/Home/LegacyHomePage.tsx b/frontend/src/main/pages/Home/LegacyHomePage.tsx deleted file mode 100644 index ef55d2d..0000000 --- a/frontend/src/main/pages/Home/LegacyHomePage.tsx +++ /dev/null @@ -1,730 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import LegacyConceptGraph from "main/components/LegacyHomePage/LegacyConceptGraph"; - -import BasicLayout from "main/layouts/BasicLayout/BasicLayout"; - -import { - fetchAssessments, - fetchQuestions, - fetchQuestionConcepts, - fetchLegacyUserState, - logLegacyUserActivity, - saveLegacyUserState, -} from "main/api/legacyClient"; -import type { Assessment, Question } from "main/api/legacyClient"; -import { majorConcepts } from "main/data/conceptGraph"; -import LegacyQuestionSearch from "main/components/LegacyHomePage/LegacyQuestionSearch"; -import LegacyAssessmentSelect from "main/components/LegacyHomePage/LegacyAssessmentSelect"; -import { conceptContent, type ConceptContent } from "main/data/conceptContent"; -import { useCurrentUser } from "main/utils/currentUser"; -import { StaffToolsProvider } from "main/utils/staffTools"; -import { - normalize, - toPastel, - computeLegacySubgraph, -} from "main/utils/conceptGraphUtils"; - -interface SavedDetailCard { - cardType: string; - itemLabel: string; - conceptId: string; - conceptColor: string; - posX: number; - posY: number; -} - -// The staff tools (debug tooltips) act on this page's concept graph, so their -// provider is mounted here rather than at the app root. -export default function LegacyHomePage() { - return ( - - - - ); -} - -function LegacyHomePageContent() { - const currentUser = useCurrentUser(); - // Derive the numeric id from the users table; null when not logged in. - const userId: number | null = currentUser?.loggedIn - ? (currentUser.root.user?.id ?? null) - : null; - - const [assessments, setAssessments] = useState([]); - const [questions, setQuestions] = useState([]); - const [selectedAssessmentId, setSelectedAssessmentId] = useState(""); - const [selectedQuestionId, setSelectedQuestionId] = useState(""); - const [highlightedIds, setHighlightedIds] = useState>(new Set()); - const [selectedConceptId, setSelectedConceptId] = useState( - null, - ); - const [highlightedSubconcepts, setHighlightedSubconcepts] = useState< - Map> - >(new Map()); - const [selectedItem, setSelectedItem] = useState(null); - const [starredIds, setStarredIds] = useState>(new Set()); - const [savedDetailCards, setSavedDetailCards] = useState( - [], - ); - const [initialDetailCards, setInitialDetailCards] = useState< - SavedDetailCard[] - >([]); - const [addedDetailKeys, setAddedDetailKeys] = useState>( - new Set(), - ); - const [masteredSubconcepts, setMasteredSubconcepts] = useState>( - new Set(), - ); - - const masteredSubconceptsRef = useRef>(masteredSubconcepts); - useEffect(() => { - masteredSubconceptsRef.current = masteredSubconcepts; - }, [masteredSubconcepts]); - - const starredIdsRef = useRef>(starredIds); - useEffect(() => { - starredIdsRef.current = starredIds; - }, [starredIds]); - - const savedDetailCardsRef = useRef(savedDetailCards); - useEffect(() => { - savedDetailCardsRef.current = savedDetailCards; - }, [savedDetailCards]); - - // Load the user's saved state once on login. - const userStateLoadedRef = useRef(false); - useEffect(() => { - if (!userId || userStateLoadedRef.current) return; - userStateLoadedRef.current = true; - - logLegacyUserActivity({ - userid: userId, - event_type: "login", - payload: { consented: true }, - }); - - fetchLegacyUserState(userId).then((data) => { - if (data) { - setStarredIds(new Set(data.starred_ids as string[])); - const cards = (data.detail_cards as SavedDetailCard[]) ?? []; - setSavedDetailCards(cards); - setAddedDetailKeys( - new Set(cards.map((c) => `${c.cardType}:${c.itemLabel}`)), - ); - setInitialDetailCards(cards); - setMasteredSubconcepts( - new Set((data.mastered_subconcepts ?? []) as string[]), - ); - } - }); - }, [userId]); - - const persistState = useCallback( - async ( - stars: Set, - cards: SavedDetailCard[], - mastered: Set = masteredSubconceptsRef.current, - ) => { - if (!userId) return; - await saveLegacyUserState({ - userid: userId, - starred_ids: Array.from(stars), - detail_cards: cards, - mastered_subconcepts: Array.from(mastered), - }); - }, - [userId], - ); - - const handleSubconceptMastered = (sub: string) => { - setMasteredSubconcepts((prev) => { - const next = new Set(prev); - if (next.has(sub)) { - next.delete(sub); - } else { - next.add(sub); - } - persistState(starredIdsRef.current, savedDetailCardsRef.current, next); - return next; - }); - }; - - const logActivity = useCallback( - async (eventType: string, payload: object) => { - if (!userId) return; - await logLegacyUserActivity({ - userid: userId, - event_type: eventType, - payload, - }); - }, - [userId], - ); - - const handlePaneClick = () => { - if (!selectedQuestionId) { - setSelectedConceptId(null); - setSelectedItem(null); - setHighlightedIds(new Set()); - } - }; - - useEffect(() => { - fetchAssessments().then(setAssessments); - }, []); - - useEffect(() => { - if (!selectedAssessmentId) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setQuestions([]); - return; - } - setQuestions([]); - fetchQuestions(selectedAssessmentId).then(setQuestions); - setSelectedQuestionId(""); - setHighlightedIds(new Set()); - setSelectedConceptId(null); - setHighlightedSubconcepts(new Map()); - }, [selectedAssessmentId]); - - useEffect(() => { - if (!selectedQuestionId) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setHighlightedIds(new Set()); - - setHighlightedSubconcepts(new Map()); - return; - } - fetchQuestionConcepts(selectedQuestionId).then((concepts) => { - setHighlightedIds( - computeLegacySubgraph(concepts.map((c) => c.concept_id)), - ); - const subMap = new Map>(); - concepts.forEach((c) => { - if (c.subconcept_label) { - if (!subMap.has(c.concept_id)) subMap.set(c.concept_id, new Set()); - subMap.get(c.concept_id)!.add(normalize(c.subconcept_label)); - } - }); - setHighlightedSubconcepts(subMap); - }); - setSelectedConceptId(null); - logActivity("question_viewed", { questionId: selectedQuestionId }); - }, [logActivity, selectedQuestionId]); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setSelectedItem(null); - }, [selectedConceptId]); - - useEffect(() => { - if (!selectedItem || !selectedConceptId) return; - logActivity("detail_visited", { - conceptId: selectedConceptId, - itemLabel: selectedItem, - }); - }, [logActivity, selectedConceptId, selectedItem]); - - const selectedConcept = selectedConceptId - ? majorConcepts.find((c) => c.name === selectedConceptId) - : null; - - const handleConceptClick = (id: string) => { - setSelectedConceptId(id); - if (!selectedQuestionId) { - setHighlightedIds(computeLegacySubgraph([id])); - } - logActivity("concept_clicked", { conceptId: id }); - }; - - const handleStarClick = (id: string) => { - setStarredIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - persistState(next, savedDetailCardsRef.current); - return next; - }); - }; - - const handleReset = () => { - setStarredIds(new Set()); - setHighlightedIds(new Set()); - setHighlightedSubconcepts(new Map()); - setSelectedConceptId(null); - setSelectedItem(null); - setSelectedQuestionId(""); - setSavedDetailCards([]); - setAddedDetailKeys(new Set()); - setMasteredSubconcepts(new Set()); - persistState(new Set(), [], new Set()); - }; - - const handleDetailAdded = (card: SavedDetailCard) => { - const key = `${card.cardType}:${card.itemLabel}`; - setAddedDetailKeys((prev) => new Set([...prev, key])); - setSavedDetailCards((prev) => { - const next = [...prev, card]; - persistState(starredIdsRef.current, next); - return next; - }); - logActivity("detail_added_to_graph", { - cardType: card.cardType, - itemLabel: card.itemLabel, - conceptId: card.conceptId, - }); - }; - - const handleDetailDeleted = (cardType: string, itemLabel: string) => { - const key = `${cardType}:${itemLabel}`; - setAddedDetailKeys((prev) => { - const s = new Set(prev); - s.delete(key); - return s; - }); - setSavedDetailCards((prev) => { - const next = prev.filter( - (c) => !(c.cardType === cardType && c.itemLabel === itemLabel), - ); - persistState(starredIdsRef.current, next); - return next; - }); - }; - - const handleDetailMoved = ( - cardType: string, - itemLabel: string, - posX: number, - posY: number, - ) => { - setSavedDetailCards((prev) => { - const next = prev.map((c) => - c.cardType === cardType && c.itemLabel === itemLabel - ? { ...c, posX, posY } - : c, - ); - persistState(starredIdsRef.current, next); - return next; - }); - }; - - return ( - -
- {/* ── Top bar ── */} -
-
- Scaffold -
- { - setSelectedAssessmentId(id); - setSelectedQuestionId(""); - }} - /> -
- -
-
-
- - - -
- - {starredIds.size} / 26 - -
-
- - {/* ── Graph ── */} -
- -
- - {/* ── Bottom toolbar ── */} -
- {/* Main toolbar row */} -
- {selectedConcept ? ( - <> - {/* Main concept button */} - - - {/* Divider */} -
- - {/* Subconcept buttons */} -
- {selectedConcept.subconcepts.map((sub) => ( - - ))} -
- - {/* Close button */} -
{ - setSelectedConceptId(null); - setSelectedItem(null); - if (!selectedQuestionId) setHighlightedIds(new Set()); - }} - > - - - - -
- - ) : ( -
- {selectedQuestionId - ? "Click a concept card to explore it." - : "Select a question, or click any concept card to explore it."} -
- )} -
- - {/* Expanded cards — only shown when a button is clicked */} - {selectedConcept && - selectedItem !== null && - (() => { - const selectedItemLabel = - selectedItem === selectedConcept.name - ? selectedConcept.label.replace(/\n/g, " ") - : (selectedItem ?? ""); - const contentKey = - selectedItem === selectedConcept.name - ? selectedConcept.name - : `${selectedConcept.name}:${selectedItem}`; - const content = conceptContent[contentKey]; - const isMajorConcept = selectedItem === selectedConcept.name; - - return ( -
- {[ - { label: "Description", key: "description" }, - { label: "Example", key: "example" }, - ].map((card) => { - const isAdded = addedDetailKeys.has( - `${card.label}:${selectedItemLabel}`, - ); - return ( -
{ - e.dataTransfer.setData( - "application/scaffold-card", - JSON.stringify({ - cardType: card.label, - itemLabel: selectedItemLabel, - conceptId: selectedConcept.name, - conceptColor: selectedConcept.color, - cardContent: - content?.[ - card.key as keyof ConceptContent - ] ?? "", - }), - ); - e.dataTransfer.effectAllowed = "move"; - } - } - style={{ - flex: 1, - background: toPastel(selectedConcept.color), - borderRadius: 8, - border: "1px solid #000000", - padding: "10px 14px", - textAlign: "left", - cursor: isAdded ? "default" : "grab", - opacity: isAdded ? 0.8 : 1, - }} - > - - {card.label} - -
- {card.key === "example" ? ( -
-                              {content?.[card.key as keyof ConceptContent] ??
-                                `Example for "${selectedItemLabel}" will appear here.`}
-                            
- ) : ( - (content?.[card.key as keyof ConceptContent] ?? - `${card.label} for "${selectedItemLabel}" will appear here.`) - )} -
-
- ); - })} - - {isMajorConcept && ( - - Practice with a
- PrairieLearn
question -
- )} -
- ); - })()} -
-
- - ); -} diff --git a/frontend/src/main/pages/UserProfilePage.tsx b/frontend/src/main/pages/UserProfilePage.tsx deleted file mode 100644 index 2a69ca6..0000000 --- a/frontend/src/main/pages/UserProfilePage.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import BasicLayout from "main/layouts/BasicLayout/BasicLayout"; -import PatSection from "main/components/Profile/PatSection"; -import { hasRole, useCurrentUser } from "main/utils/currentUser"; - -const testIdPrefix = "UserProfilePage"; - -// Roles come back from the backend as e.g. "ROLE_ADMIN"; display them as "admin". -function friendlyRoleName(role: string): string { - return role.replace(/^ROLE_/, "").toLowerCase(); -} - -export default function UserProfilePage(): React.JSX.Element { - const currentUser = useCurrentUser(); - const canSeePat = - hasRole(currentUser, "ROLE_ADMIN") || - hasRole(currentUser, "ROLE_INSTRUCTOR"); - - if (!currentUser.loggedIn) { - return Loading...; - } - - const { user, rolesList } = currentUser.root; - - return ( - -
-

User Profile

- - - - - - - - - - - - - - - -
Name - {user.fullName ?? "Not specified"} -
Email{user.email}
Roles - {rolesList.map(friendlyRoleName).join(", ")} -
- - {canSeePat && ( - <> - -
- - - )} -
- - ); -} diff --git a/frontend/src/main/types/conceptGraph.ts b/frontend/src/main/types/conceptGraph.ts deleted file mode 100644 index 4878acc..0000000 --- a/frontend/src/main/types/conceptGraph.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Response shapes for the backend endpoints used by ConceptGraphPage and the -// components/Scaffold/* tree, which fetch via useBackend/useBackendMutation. -// The legacy counterparts live in main/api/legacyClient.ts and are deliberately -// separate copies — do not consolidate (see LegacyHomePage isolation). - -// id is the PlAssessment row id (as a string); pl_assessment_id is PrairieLearn's own numeric -// assessment id, null until the PL-API sync job (issue #71) fills it in. -export interface Assessment { - id: string; - pl_assessment_id: string | null; - name: string; -} - -export interface Question { - id: string; - assessment_id: string; - pl_question_uuid: string; - title: string; -} - -// Instructor-facing view of an assessment for the UnlockAssessmentsModal: includes locked -// assessments (which the plain Assessment list above excludes) and their lock state. -export interface AssessmentManagementDTO { - id: string; - name: string; - locked: boolean; -} - -export interface QuestionConcept { - id: string; - question_id: string; - concept_id: string; - subconcept_label: string | null; -} - -export interface Course { - id: number; - courseName: string; -} - -/** - * An HTML string that has already been rendered from Markdown and sanitized - * server-side (OWASP HTML sanitizer) — see MarkdownService on the backend. - * Safe to render via `dangerouslySetInnerHTML`; do not re-escape or otherwise - * transform it before doing so. - */ -export type SafeHtml = string; - -export interface SubconceptDTO { - id: number; - parentId: number; - labelHtml: SafeHtml; -} - -export interface MajorConceptDTO { - id: number; - labelHtml: SafeHtml; - color: string; - subconcepts: SubconceptDTO[]; -} - -export interface ConceptContentDTO { - id: number; - parentId: number | null; - descriptionHtml: SafeHtml | null; - exampleHtml: SafeHtml | null; - practiceUrl: string | null; -} - -export interface PositionDTO { - x: number; - y: number; -} - -export interface EdgeDTO { - id: number; - sourceId: number; - targetId: number; - // Set (bright red) when the edge is part of a prerequisite cycle; null otherwise. - color: string | null; -} - -export interface UserStateResponse { - starred_ids: string[]; - detail_cards: unknown[]; - mastered_subconcepts: string[]; - top_level_positions: Record; -} diff --git a/frontend/src/main/utils/conceptGraphUtils.ts b/frontend/src/main/utils/conceptGraphUtils.ts deleted file mode 100644 index f13c7e7..0000000 --- a/frontend/src/main/utils/conceptGraphUtils.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { prereqEdgeData } from "../data/conceptGraph"; - -export const normalize = (s: string) => s.replace(/\\n/g, "\n"); - -export function toPastel(hex: string, strength: number = 0.1): string { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `rgb(${Math.round(r * strength + 255 * (1 - strength))}, ${Math.round(g * strength + 255 * (1 - strength))}, ${Math.round(b * strength + 255 * (1 - strength))})`; -} - -// Same as computeLegacySubgraph, but parameterized with edges fetched from the -// backend (numeric concept ids) instead of the hardcoded legacy edge list, so -// it works for any course. Node ids are the concepts' numeric ids as strings, -// matching the React Flow node ids used by ScaffoldConceptGraph. -export function computeScaffoldSubgraph( - taggedIds: string[], - edges: { sourceId: number; targetId: number }[], -): Set { - const result = new Set(taggedIds); - let changed = true; - while (changed) { - changed = false; - for (const e of edges) { - const source = String(e.sourceId); - if (result.has(String(e.targetId)) && !result.has(source)) { - result.add(source); - changed = true; - } - } - } - return result; -} - -// Walk the prereq graph upward from tagged concepts to include all ancestors -// (legacy, hardcoded-data version used by LegacyHomePage.tsx). -export function computeLegacySubgraph(taggedIds: string[]): Set { - const result = new Set(taggedIds); - let changed = true; - while (changed) { - changed = false; - for (const { source, target } of prereqEdgeData) { - if (result.has(target) && !result.has(source)) { - result.add(source); - changed = true; - } - } - } - return result; -} diff --git a/frontend/src/main/utils/courseOptionsUtils.js b/frontend/src/main/utils/courseOptionsUtils.js deleted file mode 100644 index 15e68e9..0000000 --- a/frontend/src/main/utils/courseOptionsUtils.js +++ /dev/null @@ -1,7 +0,0 @@ -export function titleCaseFromOption(option) { - return option - .toLowerCase() - .split("_") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -} diff --git a/frontend/src/main/utils/layout.ts b/frontend/src/main/utils/layout.ts deleted file mode 100644 index 986ecef..0000000 --- a/frontend/src/main/utils/layout.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { type Node, type Edge, MarkerType, Position } from "@xyflow/react"; -import { majorConcepts, prereqEdgeData } from "../data/conceptGraph"; - -export function buildLegacyGraphElements( - positions: Record, -): { nodes: Node[]; edges: Edge[] } { - const nodes: Node[] = majorConcepts.map((concept) => ({ - id: concept.name, - type: "major", - position: positions[concept.name] ?? { x: 0, y: 0 }, - data: { - label: concept.label, - color: concept.color, - subconcepts: concept.subconcepts, - }, - sourcePosition: Position.Top, - targetPosition: Position.Bottom, - })); - - const edges: Edge[] = prereqEdgeData.map((e) => ({ - id: `prereq-${e.source}-${e.target}`, - source: e.source, - target: e.target, - sourceHandle: "top", - targetHandle: "bottom", - type: "default", - style: { - stroke: majorConcepts.find((c) => c.name === e.source)?.color, - strokeWidth: 4, - }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: majorConcepts.find((c) => c.name === e.source)?.color, - }, - })); - - return { nodes, edges }; -} - -export interface SubconceptLike { - id: number; - parentId: number; - labelHtml: string; -} - -export interface MajorConceptLike { - id: number; - labelHtml: string; - color: string; - subconcepts: SubconceptLike[]; -} - -export interface EdgeLike { - id: number; - sourceId: number; - targetId: number; - // Set (bright red) when the edge is part of a prerequisite cycle; null otherwise, - // in which case the edge takes its source concept's color. - color?: string | null; -} - -// Same shape as buildLegacyGraphElements, but fully parameterized so it can build a -// graph from database-fetched data instead of the hardcoded conceptGraph.ts -// imports, using numeric concept ids (as strings) for React Flow node ids. Kept -// separate (rather than changing buildLegacyGraphElements itself) so the original, -// hardcoded-data code path used by LegacyHomePage.tsx / ConceptGraph.tsx is -// completely untouched. -export function buildScaffoldGraphElements( - positions: Record, - majorConceptsData: MajorConceptLike[], - prereqEdgeDataArg: EdgeLike[], -): { nodes: Node[]; edges: Edge[] } { - const nodes: Node[] = majorConceptsData.map((concept) => ({ - id: String(concept.id), - type: "major", - position: positions[String(concept.id)] ?? { x: 0, y: 0 }, - data: { - labelHtml: concept.labelHtml, - color: concept.color, - subconcepts: concept.subconcepts, - }, - sourcePosition: Position.Top, - targetPosition: Position.Bottom, - })); - - const edges: Edge[] = prereqEdgeDataArg.map((e) => { - const color = - e.color ?? majorConceptsData.find((c) => c.id === e.sourceId)?.color; - return { - id: `prereq-${e.id}`, - source: String(e.sourceId), - target: String(e.targetId), - sourceHandle: "top", - targetHandle: "bottom", - type: "default", - style: { stroke: color, strokeWidth: 4 }, - markerEnd: { type: MarkerType.ArrowClosed, color }, - }; - }); - - return { nodes, edges }; -} diff --git a/frontend/src/main/utils/rosterStudentUtils.js b/frontend/src/main/utils/rosterStudentUtils.js deleted file mode 100644 index 199a562..0000000 --- a/frontend/src/main/utils/rosterStudentUtils.js +++ /dev/null @@ -1,9 +0,0 @@ -export function cellToAxiosParamsDelete(formData) { - return { - url: "/api/rosterstudents/delete", - method: "DELETE", - params: { - id: formData.id, - }, - }; -} diff --git a/frontend/src/main/utils/staffTools.tsx b/frontend/src/main/utils/staffTools.tsx deleted file mode 100644 index 4ac7453..0000000 --- a/frontend/src/main/utils/staffTools.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useCallback, useState, type ReactNode } from "react"; -import { useCurrentUser, hasRole } from "main/utils/currentUser"; -import { - DEFAULT_STAFF_TOOL_SETTINGS, - StaffToolsContext, - type StaffToolSettings, -} from "main/utils/staffToolsContext"; - -const STORAGE_KEY = "staffTools"; - -function readStoredSettings(): StaffToolSettings { - try { - const parsed = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? ""); - return { - debugMode: parsed.debugMode === true, - enableEditing: parsed.enableEditing === true, - }; - } catch { - // No stored value, corrupt JSON, or sessionStorage unavailable - // (e.g. blocked by browser settings): start from the defaults. - return DEFAULT_STAFF_TOOL_SETTINGS; - } -} - -// Provides the session-scoped Admin/Instructor tool toggles (see -// StaffToolSettings). Mount it only on the pages whose content the tools act -// on (the concept-graph pages); everywhere else the context's default keeps -// the tools off and the Footer toggles hidden. The flags are clamped to false -// for regular users even if sessionStorage says otherwise, and the toggles -// persist for the lifetime of the browser tab (sessionStorage), never -// server-side. -export function StaffToolsProvider({ children }: { children: ReactNode }) { - const currentUser = useCurrentUser(); - const canUseStaffTools = - hasRole(currentUser, "ROLE_ADMIN") || - hasRole(currentUser, "ROLE_INSTRUCTOR"); - - const [settings, setSettings] = - useState(readStoredSettings); - const [newConceptHandler, setNewConceptHandler] = useState< - (() => void) | null - >(null); - - const registerNewConceptHandler = useCallback( - (handler: (() => void) | null) => { - setNewConceptHandler(() => handler); - }, - [], - ); - - const [realignConceptsHandler, setRealignConceptsHandler] = useState< - (() => void) | null - >(null); - - const registerRealignConceptsHandler = useCallback( - (handler: (() => void) | null) => { - setRealignConceptsHandler(() => handler); - }, - [], - ); - - const setStaffTool = (tool: keyof StaffToolSettings, value: boolean) => { - const next = { ...settings, [tool]: value }; - try { - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(next)); - } catch { - // sessionStorage not available; the toggle still works for this render - // tree, it just won't survive a reload. - } - setSettings(next); - }; - - return ( - - {children} - - ); -} diff --git a/frontend/src/main/utils/staffToolsContext.ts b/frontend/src/main/utils/staffToolsContext.ts deleted file mode 100644 index 9a5b445..0000000 --- a/frontend/src/main/utils/staffToolsContext.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { createContext } from "react"; - -// Session-scoped toggles for Admin/Instructor authoring & debugging tools. -// Adding a new tool = add a key here (with its default) and a checkbox in -// Footer.tsx; storage, role-gating, and clamping are handled once by -// StaffToolsProvider. -export interface StaffToolSettings { - // Show a JSON tooltip with each concept-graph node's full backing data. - debugMode: boolean; - // Enable in-graph authoring affordances such as concept CRUD actions and - // subconcept reordering. - enableEditing: boolean; -} - -export const DEFAULT_STAFF_TOOL_SETTINGS: StaffToolSettings = { - debugMode: false, - enableEditing: false, -}; - -export interface StaffToolsContextType extends StaffToolSettings { - canUseStaffTools: boolean; - setStaffTool: (tool: keyof StaffToolSettings, value: boolean) => void; - newConceptHandler: (() => void) | null; - registerNewConceptHandler: (handler: (() => void) | null) => void; - realignConceptsHandler: (() => void) | null; - registerRealignConceptsHandler: (handler: (() => void) | null) => void; -} - -// The default (no provider mounted, e.g. on non-graph pages) is "no tools": -// flags off and canUseStaffTools false, which also hides the Footer toggles. -export const StaffToolsContext = createContext({ - ...DEFAULT_STAFF_TOOL_SETTINGS, - canUseStaffTools: false, - setStaffTool: () => {}, - newConceptHandler: null, - registerNewConceptHandler: () => {}, - realignConceptsHandler: null, - registerRealignConceptsHandler: () => {}, -}); diff --git a/frontend/src/main/utils/useStaffTools.ts b/frontend/src/main/utils/useStaffTools.ts deleted file mode 100644 index 281a1ed..0000000 --- a/frontend/src/main/utils/useStaffTools.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useContext } from "react"; -import { - StaffToolsContext, - type StaffToolsContextType, -} from "main/utils/staffToolsContext"; - -export function useStaffTools(): StaffToolsContextType { - return useContext(StaffToolsContext); -} diff --git a/frontend/src/stories/components/Concept/ConceptModal.stories.jsx b/frontend/src/stories/components/Concept/ConceptModal.stories.jsx deleted file mode 100644 index f43c684..0000000 --- a/frontend/src/stories/components/Concept/ConceptModal.stories.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import React, { useState } from "react"; -import { Button } from "react-bootstrap"; -import ConceptModal from "main/components/Concept/ConceptModal"; -import conceptsFixtures from "fixtures/conceptsFixtures"; - -export default { - title: "components/Concept/ConceptModal", - component: ConceptModal, -}; - -const Template = (args) => { - const [modal, setModalState] = useState(false); - - return ( - <> - - - - ); -}; - -export const Create = Template.bind({}); - -Create.args = { - buttonText: "Create", - onSubmitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; - -export const Update = Template.bind({}); - -Update.args = { - initialContents: conceptsFixtures.severalConcepts[0], - buttonText: "Update", - modalTitle: "Update Concept", - onSubmitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; diff --git a/frontend/src/stories/components/Concept/ConceptSelector.stories.jsx b/frontend/src/stories/components/Concept/ConceptSelector.stories.jsx deleted file mode 100644 index fa83ea1..0000000 --- a/frontend/src/stories/components/Concept/ConceptSelector.stories.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from "react"; -import { http, HttpResponse } from "msw"; -import ConceptSelector from "main/components/Concept/ConceptSelector"; -import conceptsFixtures from "fixtures/conceptsFixtures"; - -export default { - title: "components/Concept/ConceptSelector", - component: ConceptSelector, -}; - -const Template = (args) => ; - -export const Default = Template.bind({}); - -Default.args = { - courseId: 1, - onSelect: (concept) => - window.alert( - "Selected concept: " + (concept ? JSON.stringify(concept) : "none"), - ), -}; - -Default.parameters = { - msw: [ - http.get("/api/concepts/top-level", () => { - return HttpResponse.json(conceptsFixtures.severalConcepts); - }), - ], -}; - -export const Empty = Template.bind({}); - -Empty.args = { - courseId: 2, - onSelect: (concept) => - window.alert( - "Selected concept: " + (concept ? JSON.stringify(concept) : "none"), - ), -}; - -Empty.parameters = { - msw: [ - http.get("/api/concepts/top-level", () => { - return HttpResponse.json([]); - }), - ], -}; diff --git a/frontend/src/stories/components/Concept/ConceptTable.stories.jsx b/frontend/src/stories/components/Concept/ConceptTable.stories.jsx deleted file mode 100644 index 4189849..0000000 --- a/frontend/src/stories/components/Concept/ConceptTable.stories.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from "react"; -import ConceptTable from "main/components/Concept/ConceptTable"; -import conceptsFixtures from "fixtures/conceptsFixtures"; - -export default { - title: "components/Concept/ConceptTable", - component: ConceptTable, -}; - -const Template = (args) => ; - -export const Empty = Template.bind({}); - -Empty.args = { - concepts: [], -}; - -export const ManyConcepts = Template.bind({}); - -ManyConcepts.args = { - concepts: conceptsFixtures.severalConcepts, - editCallback: () => window.alert("Edit clicked"), - deleteCallback: () => window.alert("Delete clicked"), -}; diff --git a/frontend/src/stories/components/Concept/SubConceptModal.stories.jsx b/frontend/src/stories/components/Concept/SubConceptModal.stories.jsx deleted file mode 100644 index f7b19ca..0000000 --- a/frontend/src/stories/components/Concept/SubConceptModal.stories.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useState } from "react"; -import { Button } from "react-bootstrap"; -import SubConceptModal from "main/components/Concept/SubConceptModal"; -import subConceptsFixtures from "fixtures/subConceptsFixtures"; - -export default { - title: "components/Concept/SubConceptModal", - component: SubConceptModal, -}; - -const Template = (args) => { - const [modal, setModalState] = useState(false); - - return ( - <> - - - - ); -}; - -export const Create = Template.bind({}); - -Create.args = { - initialContents: { - parentId: 1, - parentLabel: "Variables", - }, - buttonText: "Create", - onSubmitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; - -export const Update = Template.bind({}); - -Update.args = { - initialContents: subConceptsFixtures.severalSubConcepts[0], - buttonText: "Update", - modalTitle: "Update SubConcept", - onSubmitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; diff --git a/frontend/src/stories/components/Concept/SubConceptTable.stories.jsx b/frontend/src/stories/components/Concept/SubConceptTable.stories.jsx deleted file mode 100644 index 702651a..0000000 --- a/frontend/src/stories/components/Concept/SubConceptTable.stories.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from "react"; -import SubConceptTable from "main/components/Concept/SubConceptTable"; -import subConceptsFixtures from "fixtures/subConceptsFixtures"; - -export default { - title: "components/Concept/SubConceptTable", - component: SubConceptTable, -}; - -const Template = (args) => ; - -export const Empty = Template.bind({}); - -Empty.args = { - subConcepts: [], -}; - -export const ManySubConcepts = Template.bind({}); - -ManySubConcepts.args = { - subConcepts: subConceptsFixtures.severalSubConcepts, - editCallback: () => window.alert("Edit clicked"), - deleteCallback: () => window.alert("Delete clicked"), -}; diff --git a/frontend/src/stories/components/CourseStaff/CourseStaffCSVUploadForm.stories.jsx b/frontend/src/stories/components/CourseStaff/CourseStaffCSVUploadForm.stories.jsx deleted file mode 100644 index fbb096e..0000000 --- a/frontend/src/stories/components/CourseStaff/CourseStaffCSVUploadForm.stories.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; -import CourseStaffCSVUploadForm from "main/components/CourseStaff/CourseStaffCSVUploadForm"; - -export default { - title: "components/CourseStaff/CourseStaffCSVUploadForm", - component: CourseStaffCSVUploadForm, -}; - -const Template = (args) => { - return ; -}; - -export const Default = Template.bind({}); -Default.args = { - submitAction: (data) => { - console.log("Submit", data); - }, -}; diff --git a/frontend/src/stories/components/CourseStaff/CourseStaffDeleteModal.stories.jsx b/frontend/src/stories/components/CourseStaff/CourseStaffDeleteModal.stories.jsx deleted file mode 100644 index 9ee96fc..0000000 --- a/frontend/src/stories/components/CourseStaff/CourseStaffDeleteModal.stories.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { useState } from "react"; -import { Button } from "react-bootstrap"; -import CourseStaffDeleteModal from "main/components/CourseStaff/CourseStaffDeleteModal"; - -export default { - title: "components/CourseStaff/CourseStaffDeleteModal", - component: CourseStaffDeleteModal, -}; - -const Template = (args) => { - const [modal, setModalState] = useState(false); - return ( -
- - -
- ); -}; - -export const Default = Template.bind({}); - -Default.args = { - onSubmitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; diff --git a/frontend/src/stories/components/CourseStaff/CourseStaffForm.stories.jsx b/frontend/src/stories/components/CourseStaff/CourseStaffForm.stories.jsx deleted file mode 100644 index bcd618c..0000000 --- a/frontend/src/stories/components/CourseStaff/CourseStaffForm.stories.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; -import CourseStaffForm from "main/components/CourseStaff/CourseStaffForm"; -import { courseStaffFixtures } from "fixtures/courseStaffFixtures"; - -export default { - title: "components/CourseStaff/CourseStaffForm", - component: CourseStaffForm, -}; - -const Template = (args) => { - return ; -}; - -export const Create = Template.bind({}); - -Create.args = { - buttonLabel: "Create", - submitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; - -export const Update = Template.bind({}); - -Update.args = { - initialContents: courseStaffFixtures.oneStaff[0], - buttonLabel: "Update", - submitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; diff --git a/frontend/src/stories/components/CourseStaff/CourseStaffTable.stories.jsx b/frontend/src/stories/components/CourseStaff/CourseStaffTable.stories.jsx deleted file mode 100644 index 5fd719e..0000000 --- a/frontend/src/stories/components/CourseStaff/CourseStaffTable.stories.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from "react"; -import CourseStaffTable from "main/components/CourseStaff/CourseStaffTable"; -import { courseStaffFixtures } from "fixtures/courseStaffFixtures"; -import { currentUserFixtures } from "fixtures/currentUserFixtures"; -import { http, HttpResponse } from "msw"; - -export default { - title: "components/CourseStaff/CourseStaffTable", - component: CourseStaffTable, -}; - -const Template = (args) => { - return ; -}; - -export const Empty = Template.bind({}); - -Empty.args = { - staff: [], - courseId: "", - currentUser: currentUserFixtures.userOnly, -}; - -export const ThreeItemsOrdinaryUser = Template.bind({}); - -ThreeItemsOrdinaryUser.args = { - staff: courseStaffFixtures.threeStaff, - currentUser: currentUserFixtures.userOnly, - courseId: "1", -}; - -export const ThreeItemsAdminUser = Template.bind({}); -ThreeItemsAdminUser.args = { - staff: courseStaffFixtures.threeStaff, - currentUser: currentUserFixtures.adminUser, - courseId: "1", -}; - -ThreeItemsAdminUser.parameters = { - msw: [ - http.delete("/api/coursestaff/delete", ({ request }) => { - const url = new URL(request.url); - window.alert( - "Invoked delete with URL: " + - url + - " and params: " + - JSON.stringify(Object.fromEntries(url.searchParams)), - ); - return HttpResponse.json( - {}, - { - status: 200, - }, - ); - }), - ], -}; - -export const ItemWithEachStatusAdminUser = Template.bind({}); -ItemWithEachStatusAdminUser.args = { - staff: courseStaffFixtures.sixStaff, - currentUser: currentUserFixtures.adminUser, -}; - -ItemWithEachStatusAdminUser.parameters = { - msw: [ - http.delete("/api/coursestaff/delete", ({ request }) => { - const url = new URL(request.url); - window.alert( - "Invoked delete with URL: " + - url + - " and params: " + - JSON.stringify(Object.fromEntries(url.searchParams)), - ); - return HttpResponse.json( - {}, - { - status: 200, - }, - ); - }), - ], -}; diff --git a/frontend/src/stories/components/Courses/CourseModal.stories.jsx b/frontend/src/stories/components/Courses/CourseModal.stories.jsx deleted file mode 100644 index 63d2b20..0000000 --- a/frontend/src/stories/components/Courses/CourseModal.stories.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import React, { useState } from "react"; -import coursesFixtures from "fixtures/coursesFixtures"; -import CourseModal from "main/components/Courses/CourseModal"; -import { Button } from "react-bootstrap"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { http, HttpResponse } from "msw"; -import { schoolList } from "fixtures/schoolFixtures"; - -export default { - title: "components/Courses/CourseModal", - component: CourseModal, -}; - -const QueryWrapper = ({ children }) => { - const queryClient = new QueryClient(); - return ( - {children} - ); -}; - -const Template = (args) => { - const [modal, setModalState] = useState(false); - return ( - - - - - ); -}; - -export const Create = Template.bind({}); - -Create.args = { - buttonText: "Create", - onSubmitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; - -Create.parameters = { - msw: [ - http.get("/api/systemInfo/schools", () => { - return HttpResponse.json(schoolList, { - status: 200, - }); - }), - ], -}; - -export const Update = Template.bind({}); - -Update.args = { - initialContents: coursesFixtures.severalCourses[0], - buttonText: "Update", - onSubmitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; - -Update.parameters = { - msw: [ - http.get("/api/systemInfo/schools", () => { - return HttpResponse.json(schoolList, { - status: 200, - }); - }), - ], -}; diff --git a/frontend/src/stories/components/Courses/CoursesTable.stories.jsx b/frontend/src/stories/components/Courses/CoursesTable.stories.jsx deleted file mode 100644 index 8694f0a..0000000 --- a/frontend/src/stories/components/Courses/CoursesTable.stories.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; - -import CoursesTable from "main/components/Courses/CoursesTable"; -import coursesFixtures from "fixtures/coursesFixtures"; - -export default { - title: "components/Courses/CoursesTable", - component: CoursesTable, -}; - -const Template = (args) => { - let [loading, setLoading] = React.useState(false); - - const isLoading = () => loading; - return ; -}; - -export const Empty = Template.bind({}); -export const ManyCourses = Template.bind({}); - -Empty.args = { - courses: [], -}; -Empty.parameters = {}; - -ManyCourses.args = { - courses: coursesFixtures.severalCourses, -}; -ManyCourses.parameters = {}; diff --git a/frontend/src/stories/components/Courses/InstructorCoursesTable.stories.jsx b/frontend/src/stories/components/Courses/InstructorCoursesTable.stories.jsx deleted file mode 100644 index 639015c..0000000 --- a/frontend/src/stories/components/Courses/InstructorCoursesTable.stories.jsx +++ /dev/null @@ -1,123 +0,0 @@ -import React from "react"; - -import InstructorAdminCoursesTable from "main/components/Courses/InstructorAdminCoursesTable"; -import coursesFixtures from "fixtures/coursesFixtures"; -import { currentUserFixtures } from "fixtures/currentUserFixtures"; -import { http, HttpResponse } from "msw"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { schoolList } from "fixtures/schoolFixtures"; - -export default { - title: "components/Courses/InstructorAdminCoursesTable", - component: InstructorAdminCoursesTable, -}; - -const QueryWrapper = ({ children }) => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, // Don't retry failed queries in Storybook - }, - }, - }); - return ( - {children} - ); -}; - -const Template = (args) => { - return ( - - - - ); -}; - -export const AdminUser = Template.bind({}); -export const InstructorUser = Template.bind({}); -export const EmptyTable = Template.bind({}); -export const AdminUserWithBadEmailError = Template.bind({}); - -AdminUser.args = { - courses: coursesFixtures.severalCourses, - currentUser: currentUserFixtures.adminUser, - storybook: true, - enableInstructorUpdate: true, -}; - -InstructorUser.args = { - courses: coursesFixtures.severalCourses, - currentUser: currentUserFixtures.instructorUser, - storybook: true, -}; -InstructorUser.parameters = { - msw: [ - http.get("/api/systemInfo/schools", () => { - return HttpResponse.json(schoolList, { - status: 200, - }); - }), - ], -}; - -EmptyTable.args = { - courses: [], - currentUser: currentUserFixtures.adminUser, - storybook: true, - enableInstructorUpdate: true, -}; -EmptyTable.parameters = {}; - -AdminUser.args = { - courses: coursesFixtures.severalCourses, - currentUser: currentUserFixtures.adminUser, - storybook: true, - enableInstructorUpdate: true, -}; -AdminUser.parameters = { - msw: [ - http.put("/api/courses/updateInstructor", ({ request }) => { - window.alert( - `Would have made HTTP request: ${request.method} ${request.url}`, - ); - return HttpResponse.text("Mocked response for storybook", { - status: 200, - }); - }), - http.get("/api/systemInfo/schools", () => { - return HttpResponse.json(schoolList, { - status: 200, - }); - }), - ], -}; - -AdminUserWithBadEmailError.args = { - courses: coursesFixtures.severalCourses, - currentUser: currentUserFixtures.adminUser, - storybook: true, - enableInstructorUpdate: true, -}; -AdminUserWithBadEmailError.parameters = { - msw: [ - http.put("/api/courses/updateInstructor", ({ request }) => { - window.alert( - `Would have made HTTP request: ${request.method} ${request.url}`, - ); - return HttpResponse.json( - { - message: "Email must belong to either an instructor or admin", - type: "IllegalArgumentException", - }, - { - status: 400, - }, - ); - }), - http.get("/api/systemInfo/schools", () => { - return HttpResponse.json(schoolList, { - status: 200, - }); - }), - ], -}; diff --git a/frontend/src/stories/components/Courses/SchoolDropdown.stories.jsx b/frontend/src/stories/components/Courses/SchoolDropdown.stories.jsx deleted file mode 100644 index 22df67a..0000000 --- a/frontend/src/stories/components/Courses/SchoolDropdown.stories.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import { SchoolTypeahead } from "main/components/Courses/SchoolTypeahead"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { schoolFixtures, schoolList } from "fixtures/schoolFixtures"; -import { http, HttpResponse } from "msw"; -import { useForm } from "react-hook-form"; - -const QueryWrapper = ({ children }) => { - const queryClient = new QueryClient(); - return ( - {children} - ); -}; - -export default { - title: "components/Courses/SchoolTypeahead", - component: SchoolTypeahead, -}; - -const Template = (args) => { - const { control } = useForm({ - defaultValues: { - school: args.initialData, - }, - }); - return ( - - - - ); -}; - -export const Default = Template.bind({}); - -Default.parameters = { - msw: [ - http.get("/api/systemInfo/schools", () => { - return HttpResponse.json(schoolList, { - status: 200, - }); - }), - ], -}; - -export const WithInitialData = Template.bind({}); -WithInitialData.args = { - initialData: schoolFixtures.ucsb, -}; - -WithInitialData.parameters = { - msw: [ - http.get("/api/systemInfo/schools", () => { - return HttpResponse.json(schoolList, { - status: 200, - }); - }), - ], -}; diff --git a/frontend/src/stories/components/Courses/StudentCoursesTable.stories.jsx b/frontend/src/stories/components/Courses/StudentCoursesTable.stories.jsx deleted file mode 100644 index 9162251..0000000 --- a/frontend/src/stories/components/Courses/StudentCoursesTable.stories.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { StudentCoursesTable } from "main/components/Courses/StudentCoursesTable"; -import { http, HttpResponse } from "msw"; -import { apiCurrentUserFixtures } from "fixtures/currentUserFixtures"; -import { systemInfoFixtures } from "fixtures/systemInfoFixtures"; -import coursesFixtures from "fixtures/coursesFixtures"; - -export default { - title: "components/Courses/StudentCoursesTable", - component: StudentCoursesTable, -}; - -// Create a wrapper that provides React Query context -const QueryWrapper = ({ children }) => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, // Don't retry failed queries in Storybook - cacheTime: 0, // Don't cache in Storybook - }, - }, - }); - - return ( - {children} - ); -}; - -const Template = ({ args }) => ( - - - -); - -export const Default = Template.bind({}); -Default.args = { - testId: "CoursesTable", -}; - -Default.parameters = { - msw: { - handlers: [ - http.get("/api/currentUser", () => { - return HttpResponse.json(apiCurrentUserFixtures.userOnly); - }), - http.get("/api/systemInfo", () => { - return HttpResponse.json(systemInfoFixtures.showingNeither); - }), - http.get("/api/courses/list/students", () => { - return HttpResponse.json(coursesFixtures.severalCourses); - }), - ], - }, -}; - -export const WithNoCourses = Template.bind({}); -WithNoCourses.args = { - testId: "CoursesTable", -}; -WithNoCourses.parameters = { - msw: { - handlers: [ - http.get("/api/currentUser", () => { - return HttpResponse.json(apiCurrentUserFixtures.userOnly); - }), - http.get("/api/systemInfo", () => { - return HttpResponse.json(systemInfoFixtures.showingNeither); - }), - http.get("/api/courses/list/students", () => { - return HttpResponse.json([]); - }), - ], - }, -}; diff --git a/frontend/src/stories/components/Courses/TabComponents/ConceptTabComponent.stories.jsx b/frontend/src/stories/components/Courses/TabComponents/ConceptTabComponent.stories.jsx deleted file mode 100644 index e89b548..0000000 --- a/frontend/src/stories/components/Courses/TabComponents/ConceptTabComponent.stories.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import ConceptTabComponent from "main/components/Courses/TabComponent/ConceptTabComponent"; -import conceptsFixtures from "fixtures/conceptsFixtures"; -import { http, HttpResponse } from "msw"; - -export default { - title: "components/Courses/TabComponents/ConceptTabComponent", - component: ConceptTabComponent, -}; - -const QueryWrapper = ({ children }) => { - const queryClient = new QueryClient(); - return ( - {children} - ); -}; - -const Template = (args) => ( - - - -); - -export const Default = Template.bind({}); -Default.args = { - courseId: 1, - testIdPrefix: "storybook", -}; - -Default.parameters = { - msw: [ - http.get("/api/concepts/course", () => - HttpResponse.json(conceptsFixtures.severalConcepts), - ), - http.post("/api/concept", async ({ request }) => { - const body = await request.json(); - window.alert( - "Invoked post on /api/concept with body: " + JSON.stringify(body), - ); - return HttpResponse.json({ id: 3, ...body }, { status: 200 }); - }), - ], -}; diff --git a/frontend/src/stories/components/Courses/TabComponents/EdgeConceptTabComponent.stories.jsx b/frontend/src/stories/components/Courses/TabComponents/EdgeConceptTabComponent.stories.jsx deleted file mode 100644 index bedfa25..0000000 --- a/frontend/src/stories/components/Courses/TabComponents/EdgeConceptTabComponent.stories.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import EdgeConceptTabComponent from "main/components/Courses/TabComponent/EdgeConceptTabComponent"; -import conceptsFixtures from "fixtures/conceptsFixtures"; -import edgesFixtures from "fixtures/edgesFixtures"; -import { http, HttpResponse } from "msw"; - -export default { - title: "components/Courses/TabComponents/EdgeConceptTabComponent", - component: EdgeConceptTabComponent, -}; - -const QueryWrapper = ({ children }) => { - const queryClient = new QueryClient(); - return ( - {children} - ); -}; - -const Template = (args) => ( - - - -); - -export const Default = Template.bind({}); -Default.args = { - courseId: 1, - testIdPrefix: "storybook", -}; - -Default.parameters = { - msw: [ - http.get("/api/concepts/course", () => - HttpResponse.json(conceptsFixtures.severalConcepts), - ), - http.get("/api/concepts/edges", () => - HttpResponse.json(edgesFixtures.severalEdges), - ), - http.post("/api/concepts/edges/post", ({ request }) => { - const url = new URL(request.url); - window.alert( - "Invoked post on /api/concepts/edges/post with params: " + - url.searchParams.toString(), - ); - return HttpResponse.json( - { - id: 99, - sourceId: Number(url.searchParams.get("sourceConceptId")), - targetId: Number(url.searchParams.get("targetConceptId")), - color: null, - }, - { status: 200 }, - ); - }), - http.delete("/api/concepts/edges/delete", ({ request }) => { - const url = new URL(request.url); - window.alert( - "Invoked delete on /api/concepts/edges/delete with params: " + - url.searchParams.toString(), - ); - return HttpResponse.json( - { - message: `ConceptEdge with id ${url.searchParams.get("id")} deleted`, - }, - { status: 200 }, - ); - }), - ], -}; diff --git a/frontend/src/stories/components/Courses/TabComponents/JobTabComponent.stories.jsx b/frontend/src/stories/components/Courses/TabComponents/JobTabComponent.stories.jsx deleted file mode 100644 index 3dd9025..0000000 --- a/frontend/src/stories/components/Courses/TabComponents/JobTabComponent.stories.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from "react"; -import { http, HttpResponse } from "msw"; -import JobTabComponent from "main/components/Courses/TabComponent/JobTabComponent"; - -export default { - title: "components/Courses/TabComponents/JobTabComponent", - component: JobTabComponent, -}; - -const Template = (args) => ; - -export const Empty = Template.bind({}); -Empty.args = { - courseId: 1, - testIdPrefix: "storybook", -}; -Empty.parameters = { - msw: [http.get("/api/jobs/course", () => HttpResponse.json([]))], -}; - -export const WithJobs = Template.bind({}); -WithJobs.args = { - courseId: 1, - testIdPrefix: "storybook", -}; -WithJobs.parameters = { - msw: [ - http.get("/api/jobs/course", () => - HttpResponse.json([ - { - id: 2, - status: "running", - jobName: "SyncCourseWithPlRepoJob", - createdAt: "2026-07-11T12:01:00", - updatedAt: "2026-07-11T12:01:30", - log: "Syncing course 1 (CS156) with PrairieLearn", - }, - { - id: 1, - status: "complete", - jobName: "SyncCourseWithPlRepoJob", - createdAt: "2026-07-11T12:00:00", - updatedAt: "2026-07-11T12:00:45", - log: "Syncing course 1 (CS156) with PrairieLearn\nQuestions: 3 added, 0 updated, 0 deleted, 0 unchanged", - }, - ]), - ), - ], -}; diff --git a/frontend/src/stories/components/Courses/TabComponents/PLTabComponent.stories.jsx b/frontend/src/stories/components/Courses/TabComponents/PLTabComponent.stories.jsx deleted file mode 100644 index a4c267e..0000000 --- a/frontend/src/stories/components/Courses/TabComponents/PLTabComponent.stories.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from "react"; -import { http, HttpResponse } from "msw"; -import PLTabComponent from "main/components/Courses/TabComponent/PLTabComponent"; - -export default { - title: "components/Courses/TabComponents/PLTabComponent", - component: PLTabComponent, -}; - -const Template = (args) => ; - -export const Default = Template.bind({}); -Default.args = { - courseId: 1, - testIdPrefix: "storybook", -}; -Default.parameters = { - msw: [ - http.get("/api/courses/1", () => - HttpResponse.json({ id: 1, plRepoId: null, plInstanceId: null }), - ), - http.put("/api/courses/updateGithubRepo", () => - HttpResponse.json({ id: 1, plRepoId: 9 }), - ), - ], -}; - -export const FullyConfigured = Template.bind({}); -FullyConfigured.args = { - courseId: 1, - testIdPrefix: "storybook", -}; -FullyConfigured.parameters = { - msw: [ - http.get("/api/courses/1", () => - HttpResponse.json({ - id: 1, - plRepoId: 9, - plRepoName: "ucsb-cs156/pl-demo", - plInstanceId: 55, - plInstanceShortName: "S26", - plInstanceNumericId: 213133, - }), - ), - ], -}; - -export const InstanceNotFound = Template.bind({}); -InstanceNotFound.args = { - courseId: 1, - testIdPrefix: "storybook", -}; -InstanceNotFound.parameters = { - msw: [ - http.put("/api/courses/updateGithubRepo", () => - HttpResponse.json({ id: 1, plRepoId: 9 }), - ), - http.put("/api/courses/updatePLInstance", () => - HttpResponse.json( - { type: "ForbiddenException", message: "course instance id not found" }, - { status: 403 }, - ), - ), - ], -}; - -export const MissingPat = Template.bind({}); -MissingPat.args = { - courseId: 1, - testIdPrefix: "storybook", -}; -MissingPat.parameters = { - msw: [ - http.put("/api/courses/updateGithubRepo", () => - HttpResponse.json( - { type: "ForbiddenException", message: "must set up Github PAT first" }, - { status: 403 }, - ), - ), - http.put("/api/courses/updatePLInstance", () => - HttpResponse.json( - { - type: "ForbiddenException", - message: "must set up PrairieLearn PAT first", - }, - { status: 403 }, - ), - ), - ], -}; diff --git a/frontend/src/stories/components/Courses/TabComponents/ScaffoldTabComponent.stories.jsx b/frontend/src/stories/components/Courses/TabComponents/ScaffoldTabComponent.stories.jsx deleted file mode 100644 index 479248e..0000000 --- a/frontend/src/stories/components/Courses/TabComponents/ScaffoldTabComponent.stories.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from "react"; -import { http, HttpResponse } from "msw"; -import ScaffoldTabComponent from "main/components/Courses/TabComponent/ScaffoldTabComponent"; - -export default { - title: "components/Courses/TabComponents/ScaffoldTabComponent", - component: ScaffoldTabComponent, -}; - -const Template = (args) => ; - -const sampleYaml = `# Concept graph for course 1 (Storybook 101) -# See docs/yaml-format.md for the format. -format: 1 -concepts: - - id: 1 - label: Recursion -edges: [] -`; - -export const Default = Template.bind({}); -Default.args = { - courseId: 1, - courseName: "Storybook 101", - term: "F26", - school: { key: "UCSB", displayName: "UCSB" }, - testIdPrefix: "storybook", -}; - -Default.parameters = { - msw: [ - http.get("/api/concepts/yaml/download", () => { - return new HttpResponse(sampleYaml, { - status: 200, - headers: { "Content-Type": "application/x-yaml" }, - }); - }), - http.post("/api/concepts/yaml/upload", ({ request }) => { - const url = new URL(request.url); - window.alert( - `Invoked concepts YAML upload with courseId=${url.searchParams.get("courseId")} via URL: ${url}`, - ); - return HttpResponse.json( - { - success: true, - errors: [], - conceptsCreated: 1, - subconceptsCreated: 0, - edgesCreated: 0, - practiceProblemsCreated: 0, - userStatesCleared: 0, - }, - { status: 200 }, - ); - }), - ], -}; diff --git a/frontend/src/stories/components/Courses/TabComponents/SubConceptTabComponent.stories.jsx b/frontend/src/stories/components/Courses/TabComponents/SubConceptTabComponent.stories.jsx deleted file mode 100644 index 95aeed8..0000000 --- a/frontend/src/stories/components/Courses/TabComponents/SubConceptTabComponent.stories.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from "react"; -import { http, HttpResponse } from "msw"; -import SubConceptTabComponent from "main/components/Courses/TabComponent/SubConceptTabComponent"; -import subConceptsFixtures from "fixtures/subConceptsFixtures"; -import conceptsFixtures from "fixtures/conceptsFixtures"; - -export default { - title: "components/Courses/TabComponents/SubConceptTabComponent", - component: SubConceptTabComponent, -}; - -const Template = (args) => ; - -export const Default = Template.bind({}); -Default.args = { - courseId: 1, - testIdPrefix: "storybook", -}; - -Default.parameters = { - msw: [ - http.get("/api/concepts/subconcepts", () => { - return HttpResponse.json(subConceptsFixtures.severalSubConcepts); - }), - http.get("/api/concepts/top-level", () => { - return HttpResponse.json(conceptsFixtures.severalConcepts); - }), - http.post("/api/concept/subconcept", async ({ request }) => { - const body = await request.json(); - window.alert( - `Create subconcept endpoint invoked with: ${JSON.stringify(body)}`, - ); - return HttpResponse.json( - { id: 99, label: body.label, parentId: body.parentConceptId }, - { status: 201 }, - ); - }), - ], -}; diff --git a/frontend/src/stories/components/LegacyHomePage/AssessmentSelect.stories.tsx b/frontend/src/stories/components/LegacyHomePage/AssessmentSelect.stories.tsx deleted file mode 100644 index f15d58d..0000000 --- a/frontend/src/stories/components/LegacyHomePage/AssessmentSelect.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import LegacyAssessmentSelect from "main/components/LegacyHomePage/LegacyAssessmentSelect"; -import type { Assessment } from "main/api/legacyClient"; - -const sampleAssessments: Assessment[] = [ - { id: "1", pl_assessment_id: "pl1", name: "HW1" }, - { id: "2", pl_assessment_id: "pl2", name: "HW2" }, - { id: "3", pl_assessment_id: "pl3", name: "Scaffold Final" }, -]; - -const meta: Meta = { - title: "components/LegacyHomePage/AssessmentSelect", - component: LegacyAssessmentSelect, - tags: ["autodocs"], - args: { - assessments: sampleAssessments, - selectedAssessmentId: "", - onSelect: () => {}, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Selected: Story = { - args: { - selectedAssessmentId: "2", - }, -}; diff --git a/frontend/src/stories/components/LegacyHomePage/LegacyConceptGraph.stories.tsx b/frontend/src/stories/components/LegacyHomePage/LegacyConceptGraph.stories.tsx deleted file mode 100644 index 35be9a1..0000000 --- a/frontend/src/stories/components/LegacyHomePage/LegacyConceptGraph.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import LegacyConceptGraph from "main/components/LegacyHomePage/LegacyConceptGraph"; - -const meta: Meta = { - title: "components/LegacyHomePage/LegacyConceptGraph", - component: LegacyConceptGraph, - parameters: { - layout: "fullscreen", - }, - tags: ["autodocs"], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - highlightedIds: new Set(), - highlightedSubconcepts: new Map>(), - onConceptClick: () => {}, - starredIds: new Set(), - onStarClick: () => {}, - onReset: () => {}, - masteredSubconcepts: new Set(), - onSubconceptMastered: () => {}, - }, -}; diff --git a/frontend/src/stories/components/LegacyHomePage/QuestionSearch.stories.tsx b/frontend/src/stories/components/LegacyHomePage/QuestionSearch.stories.tsx deleted file mode 100644 index 1477093..0000000 --- a/frontend/src/stories/components/LegacyHomePage/QuestionSearch.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import LegacyQuestionSearch from "main/components/LegacyHomePage/LegacyQuestionSearch"; -import type { Question } from "main/api/legacyClient"; - -const sampleQuestions: Question[] = [ - { - id: "1", - assessment_id: "a1", - pl_question_uuid: "u1", - title: "Loops 101", - }, - { - id: "2", - assessment_id: "a1", - pl_question_uuid: "u2", - title: "Recursion basics", - }, - { - id: "3", - assessment_id: "a1", - pl_question_uuid: "u3", - title: "Scaffold graph fundamentals", - }, -]; - -const meta: Meta = { - title: "components/LegacyHomePage/QuestionSearch", - component: LegacyQuestionSearch, - tags: ["autodocs"], - args: { - questions: sampleQuestions, - selectedQuestionId: "", - onSelect: () => {}, - disabled: false, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Disabled: Story = { - args: { - disabled: true, - }, -}; diff --git a/frontend/src/stories/components/Profile/PatSection.stories.tsx b/frontend/src/stories/components/Profile/PatSection.stories.tsx deleted file mode 100644 index 5e7d5ef..0000000 --- a/frontend/src/stories/components/Profile/PatSection.stories.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { http, HttpResponse } from "msw"; -import PatSection from "main/components/Profile/PatSection"; - -const meta: Meta = { - title: "components/Profile/PatSection", - component: PatSection, - tags: ["autodocs"], - args: { - title: "GitHub PAT", - endpoint: "/api/pat/github", - testIdPrefix: "PatSection", - }, -}; - -export default meta; -type Story = StoryObj; - -export const NoPatSet: Story = { - parameters: { - msw: [ - http.get("/api/pat/github", () => - HttpResponse.json( - { type: "EntityNotFoundException", message: "not found" }, - { status: 404 }, - ), - ), - ], - }, -}; - -export const PatOnFile: Story = { - parameters: { - msw: [ - http.get("/api/pat/github", () => - HttpResponse.json({ - id: 7, - userId: 1, - platform: "GITHUB", - lastFour: "3f2a", - expiresAt: "2026-12-31", - }), - ), - ], - }, -}; - -export const PrairieLearn: Story = { - args: { - title: "PrairieLearn PAT", - endpoint: "/api/pat/pl", - }, - parameters: { - msw: [ - http.get("/api/pat/pl", () => - HttpResponse.json( - { type: "EntityNotFoundException", message: "not found" }, - { status: 404 }, - ), - ), - ], - }, -}; diff --git a/frontend/src/stories/components/RosterStudent/DroppedStudentsTable.stories.jsx b/frontend/src/stories/components/RosterStudent/DroppedStudentsTable.stories.jsx deleted file mode 100644 index db530fc..0000000 --- a/frontend/src/stories/components/RosterStudent/DroppedStudentsTable.stories.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import { currentUserFixtures } from "fixtures/currentUserFixtures"; -import { rosterStudentFixtures } from "fixtures/rosterStudentFixtures"; -import React from "react"; -import DroppedStudentsTable from "main/components/RosterStudent/DroppedStudentsTable"; -import { http, HttpResponse } from "msw"; - -export default { - title: "components/RosterStudent/DroppedStudentsTable", - component: DroppedStudentsTable, -}; - -const Template = (args) => { - return ; -}; - -export const Empty = Template.bind({}); - -Empty.args = { - students: [], - courseId: "", - currentUser: currentUserFixtures.userOnly, -}; - -Empty.parameters = { - msw: [ - http.put("/api/rosterstudents/restore", ({ request }) => { - const url = new URL(request.url); - window.alert( - "Invoked put with URL: " + - url + - " and params: " + - JSON.stringify(Object.fromEntries(url.searchParams)), - ); - return HttpResponse.json( - {}, - { - status: 200, - }, - ); - }), - ], -}; - -export const ThreeItems = Template.bind({}); - -ThreeItems.args = { - students: rosterStudentFixtures.threeStudents, - currentUser: currentUserFixtures.userOnly, - courseId: "1", -}; - -ThreeItems.parameters = { - msw: [ - http.put("/api/rosterstudents/restore", ({ request }) => { - const url = new URL(request.url); - window.alert( - "Invoked put with URL: " + - url + - " and params: " + - JSON.stringify(Object.fromEntries(url.searchParams)), - ); - return HttpResponse.json( - {}, - { - status: 200, - }, - ); - }), - ], -}; diff --git a/frontend/src/stories/components/RosterStudent/RosterStudentCSVUploadForm.stories.jsx b/frontend/src/stories/components/RosterStudent/RosterStudentCSVUploadForm.stories.jsx deleted file mode 100644 index aadea56..0000000 --- a/frontend/src/stories/components/RosterStudent/RosterStudentCSVUploadForm.stories.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import RosterStudentCSVUploadForm from "main/components/RosterStudent/RosterStudentCSVUploadForm"; - -export default { - title: "components/RosterStudent/RosterStudentCSVUploadForm", - component: RosterStudentCSVUploadForm, -}; - -const Template = (args) => { - return ; -}; - -export const Default = Template.bind({}); - -Default.args = { - submitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; diff --git a/frontend/src/stories/components/RosterStudent/RosterStudentDeleteModal.stories.jsx b/frontend/src/stories/components/RosterStudent/RosterStudentDeleteModal.stories.jsx deleted file mode 100644 index 28eb524..0000000 --- a/frontend/src/stories/components/RosterStudent/RosterStudentDeleteModal.stories.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { useState } from "react"; -import { Button } from "react-bootstrap"; -import RosterStudentDeleteModal from "main/components/RosterStudent/RosterStudentDeleteModal"; - -export default { - title: "components/RosterStudent/RosterStudentDeleteModal", - component: RosterStudentDeleteModal, -}; - -const Template = (args) => { - const [modal, setModalState] = useState(false); - return ( -
- - -
- ); -}; - -export const Default = Template.bind({}); - -Default.args = { - onSubmitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; diff --git a/frontend/src/stories/components/RosterStudent/RosterStudentForm.stories.jsx b/frontend/src/stories/components/RosterStudent/RosterStudentForm.stories.jsx deleted file mode 100644 index 4e82f90..0000000 --- a/frontend/src/stories/components/RosterStudent/RosterStudentForm.stories.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; -import RosterStudentForm from "main/components/RosterStudent/RosterStudentForm"; -import { rosterStudentFixtures } from "fixtures/rosterStudentFixtures"; - -export default { - title: "components/RosterStudent/RosterStudentForm", - component: RosterStudentForm, -}; - -const Template = (args) => { - return ; -}; - -export const Create = Template.bind({}); - -Create.args = { - buttonLabel: "Create", - submitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; - -export const Update = Template.bind({}); - -Update.args = { - initialContents: rosterStudentFixtures.oneStudent[0], - buttonLabel: "Update", - submitAction: (data) => { - console.log("Submit was clicked with data: ", data); - window.alert("Submit was clicked with data: " + JSON.stringify(data)); - }, -}; diff --git a/frontend/src/stories/components/RosterStudent/RosterStudentTable.stories.jsx b/frontend/src/stories/components/RosterStudent/RosterStudentTable.stories.jsx deleted file mode 100644 index 75894f0..0000000 --- a/frontend/src/stories/components/RosterStudent/RosterStudentTable.stories.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from "react"; -import RosterStudentTable from "main/components/RosterStudent/RosterStudentTable"; -import { rosterStudentFixtures } from "fixtures/rosterStudentFixtures"; -import { currentUserFixtures } from "fixtures/currentUserFixtures"; -import { http, HttpResponse } from "msw"; - -export default { - title: "components/RosterStudent/RosterStudentTable", - component: RosterStudentTable, -}; - -const Template = (args) => { - return ; -}; - -export const Empty = Template.bind({}); - -Empty.args = { - students: [], - courseId: "", - currentUser: currentUserFixtures.userOnly, -}; - -export const ThreeItemsOrdinaryUser = Template.bind({}); - -ThreeItemsOrdinaryUser.args = { - students: rosterStudentFixtures.threeStudents, - currentUser: currentUserFixtures.userOnly, - courseId: "1", -}; - -export const ThreeItemsAdminUser = Template.bind({}); -ThreeItemsAdminUser.args = { - students: rosterStudentFixtures.threeStudents, - currentUser: currentUserFixtures.adminUser, - courseId: "1", -}; - -ThreeItemsAdminUser.parameters = { - msw: [ - http.delete("/api/rosterstudents/delete", ({ request }) => { - const url = new URL(request.url); - window.alert( - "Invoked delete with URL: " + - url + - " and params: " + - JSON.stringify(Object.fromEntries(url.searchParams)), - ); - return HttpResponse.json( - {}, - { - status: 200, - }, - ); - }), - ], -}; - -export const ItemWithEachStatusAdminUser = Template.bind({}); -ItemWithEachStatusAdminUser.args = { - students: rosterStudentFixtures.sixStudents, - currentUser: currentUserFixtures.adminUser, -}; - -ItemWithEachStatusAdminUser.parameters = { - msw: [ - http.delete("/api/rosterstudents/delete", ({ request }) => { - const url = new URL(request.url); - window.alert( - "Invoked delete with URL: " + - url + - " and params: " + - JSON.stringify(Object.fromEntries(url.searchParams)), - ); - return HttpResponse.json( - {}, - { - status: 200, - }, - ); - }), - ], -}; diff --git a/frontend/src/stories/components/Scaffold/AssessmentSelect.stories.tsx b/frontend/src/stories/components/Scaffold/AssessmentSelect.stories.tsx deleted file mode 100644 index dafbd6d..0000000 --- a/frontend/src/stories/components/Scaffold/AssessmentSelect.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import AssessmentSelect from "main/components/Scaffold/AssessmentSelect"; -import type { Assessment } from "main/types/conceptGraph"; - -const sampleAssessments: Assessment[] = [ - { id: "1", pl_assessment_id: "pl1", name: "HW1" }, - { id: "2", pl_assessment_id: "pl2", name: "HW2" }, - { id: "3", pl_assessment_id: "pl3", name: "Scaffold Final" }, -]; - -const meta: Meta = { - title: "components/Scaffold/AssessmentSelect", - component: AssessmentSelect, - tags: ["autodocs"], - args: { - assessments: sampleAssessments, - selectedAssessmentId: "", - onSelect: () => {}, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Selected: Story = { - args: { - selectedAssessmentId: "2", - }, -}; diff --git a/frontend/src/stories/components/Scaffold/LevelLegend.stories.tsx b/frontend/src/stories/components/Scaffold/LevelLegend.stories.tsx deleted file mode 100644 index f7b30eb..0000000 --- a/frontend/src/stories/components/Scaffold/LevelLegend.stories.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import LevelLegend from "main/components/Scaffold/LevelLegend"; - -const meta: Meta = { - title: "components/Scaffold/LevelLegend", - component: LevelLegend, - tags: ["autodocs"], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/stories/components/Scaffold/LinkToScaffold.stories.tsx b/frontend/src/stories/components/Scaffold/LinkToScaffold.stories.tsx deleted file mode 100644 index 5336ac5..0000000 --- a/frontend/src/stories/components/Scaffold/LinkToScaffold.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import LinkToScaffold from "main/components/Scaffold/LinkToScaffold"; - -const meta: Meta = { - title: "components/Scaffold/LinkToScaffold", - component: LinkToScaffold, - tags: ["autodocs"], - args: { - course: { id: 1, courseName: "CMPSC 156" }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/stories/components/Scaffold/LinkToSettings.stories.tsx b/frontend/src/stories/components/Scaffold/LinkToSettings.stories.tsx deleted file mode 100644 index 12d3b84..0000000 --- a/frontend/src/stories/components/Scaffold/LinkToSettings.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import LinkToSettings from "main/components/Scaffold/LinkToSettings"; - -const meta: Meta = { - title: "components/Scaffold/LinkToSettings", - component: LinkToSettings, - tags: ["autodocs"], - args: { - course: { id: 1, courseName: "CMPSC 156" }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/stories/components/Scaffold/QuestionSearch.stories.tsx b/frontend/src/stories/components/Scaffold/QuestionSearch.stories.tsx deleted file mode 100644 index 88f937f..0000000 --- a/frontend/src/stories/components/Scaffold/QuestionSearch.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import QuestionSearch from "main/components/Scaffold/QuestionSearch"; -import type { Question } from "main/types/conceptGraph"; - -const sampleQuestions: Question[] = [ - { - id: "1", - assessment_id: "a1", - pl_question_uuid: "u1", - title: "Loops 101", - }, - { - id: "2", - assessment_id: "a1", - pl_question_uuid: "u2", - title: "Recursion basics", - }, - { - id: "3", - assessment_id: "a1", - pl_question_uuid: "u3", - title: "Scaffold graph fundamentals", - }, -]; - -const meta: Meta = { - title: "components/Scaffold/QuestionSearch", - component: QuestionSearch, - tags: ["autodocs"], - args: { - questions: sampleQuestions, - selectedQuestionId: "", - onSelect: () => {}, - disabled: false, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Disabled: Story = { - args: { - disabled: true, - }, -}; diff --git a/frontend/src/stories/components/Scaffold/ResetButton.stories.tsx b/frontend/src/stories/components/Scaffold/ResetButton.stories.tsx deleted file mode 100644 index 9fee1cf..0000000 --- a/frontend/src/stories/components/Scaffold/ResetButton.stories.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import ResetButton from "main/components/Scaffold/ResetButton"; - -const meta: Meta = { - title: "components/Scaffold/ResetButton", - component: ResetButton, - tags: ["autodocs"], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - onReset: () => {}, - }, -}; diff --git a/frontend/src/stories/components/Scaffold/ScaffoldBrand.stories.tsx b/frontend/src/stories/components/Scaffold/ScaffoldBrand.stories.tsx deleted file mode 100644 index fcf4d1c..0000000 --- a/frontend/src/stories/components/Scaffold/ScaffoldBrand.stories.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import ScaffoldBrand from "main/components/Scaffold/ScaffoldBrand"; - -const meta: Meta = { - title: "components/Scaffold/ScaffoldBrand", - component: ScaffoldBrand, - tags: ["autodocs"], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/stories/components/Scaffold/ScaffoldConceptGraph.stories.tsx b/frontend/src/stories/components/Scaffold/ScaffoldConceptGraph.stories.tsx deleted file mode 100644 index bfd934a..0000000 --- a/frontend/src/stories/components/Scaffold/ScaffoldConceptGraph.stories.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import ScaffoldConceptGraph from "main/components/Scaffold/ScaffoldConceptGraph"; - -const meta: Meta = { - title: "components/Scaffold/ScaffoldConceptGraph", - component: ScaffoldConceptGraph, - parameters: { - layout: "fullscreen", - }, - decorators: [ - (Story) => ( -
- -
- ), - ], - tags: ["autodocs"], -}; - -export default meta; -type Story = StoryObj; - -const sampleMajorConcepts = [ - { - id: 1, - labelHtml: "Recursion", - color: "#fe9a71", - subconcepts: [ - { id: 2, parentId: 1, labelHtml: "Base case" }, - { id: 3, parentId: 1, labelHtml: "State change" }, - ], - }, - { - id: 4, - labelHtml: "Loops", - color: "#93ebff", - subconcepts: [{ id: 5, parentId: 4, labelHtml: "For loops" }], - }, - { - id: 6, - labelHtml: "Scaffold", - color: "#c99ffe", - subconcepts: [{ id: 7, parentId: 6, labelHtml: "Storybook coverage" }], - }, -]; - -const samplePositions = { - "1": { x: 100, y: 100 }, - "4": { x: 420, y: 100 }, - "6": { x: 260, y: 340 }, -}; - -const samplePrereqEdgeData = [ - { id: 20, sourceId: 4, targetId: 1, color: null }, - { id: 21, sourceId: 1, targetId: 6, color: null }, -]; - -export const Default: Story = { - args: { - majorConcepts: sampleMajorConcepts, - positions: samplePositions, - conceptContent: {}, - prereqEdgeData: samplePrereqEdgeData, - highlightedIds: new Set(), - highlightedSubconcepts: new Map>(), - onConceptClick: () => {}, - starredIds: new Set(), - onStarClick: () => {}, - onReset: () => {}, - masteredSubconcepts: new Set(), - onSubconceptMastered: () => {}, - }, -}; diff --git a/frontend/src/stories/components/Scaffold/ScaffoldTopBar.stories.tsx b/frontend/src/stories/components/Scaffold/ScaffoldTopBar.stories.tsx deleted file mode 100644 index 9735c62..0000000 --- a/frontend/src/stories/components/Scaffold/ScaffoldTopBar.stories.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import ScaffoldTopBar from "main/components/Scaffold/ScaffoldTopBar"; -import { StaffToolsProvider } from "main/utils/staffTools"; -import { currentUserFixtures } from "fixtures/currentUserFixtures"; -import type { Assessment, Course, Question } from "main/types/conceptGraph"; -import type { CourseAccess } from "main/components/Courses/CourseMenu"; - -const sampleAssessments: Assessment[] = [ - { id: "1", pl_assessment_id: "pl1", name: "HW1" }, - { id: "2", pl_assessment_id: "pl2", name: "HW2" }, - { id: "3", pl_assessment_id: "pl3", name: "Scaffold Final" }, -]; - -const sampleQuestions: Question[] = [ - { - id: "q1", - assessment_id: "1", - pl_question_uuid: "u1", - title: "Loops 101", - }, - { - id: "q2", - assessment_id: "1", - pl_question_uuid: "u2", - title: "Recursion basics", - }, -]; - -const sampleCourse: Course = { id: 1, courseName: "CMPSC 156" }; - -const sampleCourseInfo: CourseAccess = { - id: 1, - courseName: "CMPSC 156", - term: "S26", - school: { key: "UCSB", displayName: "UCSB" }, - instructorEmail: "phtcon@ucsb.edu", - studentAccess: false, - staffAccess: false, - instructorAccess: true, - adminAccess: false, -}; - -const meta: Meta = { - title: "components/Scaffold/ScaffoldTopBar", - component: ScaffoldTopBar, - tags: ["autodocs"], - args: { - course: sampleCourse, - courseInfo: sampleCourseInfo, - courseId: 1, - assessments: sampleAssessments, - selectedAssessmentId: "", - onSelectAssessment: () => {}, - questions: [], - selectedQuestionId: "", - onSelectQuestion: () => {}, - numStarredConcepts: 3, - numTotalConcepts: 12, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const AssessmentSelected: Story = { - args: { - selectedAssessmentId: "1", - questions: sampleQuestions, - }, -}; - -// Shows the "Unlock Assessments" button, which only renders for an -// admin/instructor who also has the "Enable Editing" toggle on. -// StaffToolsProvider derives canUseStaffTools from the current-user query -// (preset here the same way AdminDeveloperPage's story presets systemInfo) -// and reads enableEditing from sessionStorage, matching how the toggle -// persists in the real app. -export const AsInstructorInEditingMode: Story = { - decorators: [ - (Story) => { - sessionStorage.setItem( - "staffTools", - JSON.stringify({ enableEditing: true }), - ); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - queryClient.setQueryData(["current user"], currentUserFixtures.adminUser); - return ( - - - - - - ); - }, - ], -}; diff --git a/frontend/src/stories/components/Scaffold/StarStatus.stories.tsx b/frontend/src/stories/components/Scaffold/StarStatus.stories.tsx deleted file mode 100644 index a1b16db..0000000 --- a/frontend/src/stories/components/Scaffold/StarStatus.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import StarStatus from "main/components/Scaffold/StarStatus"; - -const meta: Meta = { - title: "components/Scaffold/StarStatus", - component: StarStatus, - tags: ["autodocs"], -}; - -export default meta; -type Story = StoryObj; - -export const NoneStarred: Story = { - args: { - numStarredConcepts: 0, - numTotalConcepts: 12, - }, -}; - -export const SomeStarred: Story = { - args: { - numStarredConcepts: 5, - numTotalConcepts: 12, - }, -}; - -export const AllStarred: Story = { - args: { - numStarredConcepts: 12, - numTotalConcepts: 12, - }, -}; diff --git a/frontend/src/stories/components/Scaffold/UnlockAssessmentsModal.stories.tsx b/frontend/src/stories/components/Scaffold/UnlockAssessmentsModal.stories.tsx deleted file mode 100644 index b99d0b9..0000000 --- a/frontend/src/stories/components/Scaffold/UnlockAssessmentsModal.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { http, HttpResponse } from "msw"; -import UnlockAssessmentsModal from "main/components/Scaffold/UnlockAssessmentsModal"; -import type { AssessmentManagementDTO } from "main/types/conceptGraph"; - -const sampleAssessments: AssessmentManagementDTO[] = [ - { id: "1", name: "HW1", locked: true }, - { id: "2", name: "HW2", locked: false }, - { id: "3", name: "Scaffold Final", locked: true }, -]; - -const meta: Meta = { - title: "components/Scaffold/UnlockAssessmentsModal", - component: UnlockAssessmentsModal, - tags: ["autodocs"], - args: { - show: true, - onHide: () => {}, - courseId: 1, - }, - parameters: { - msw: [ - http.get("/api/assessments/all", () => - HttpResponse.json(sampleAssessments), - ), - http.put("/api/assessments/lock", () => - HttpResponse.json(sampleAssessments[0]), - ), - ], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const NoAssessments: Story = { - parameters: { - msw: [http.get("/api/assessments/all", () => HttpResponse.json([]))], - }, -}; diff --git a/frontend/src/stories/components/Settings/CourseOptionsForm.stories.jsx b/frontend/src/stories/components/Settings/CourseOptionsForm.stories.jsx deleted file mode 100644 index 094262b..0000000 --- a/frontend/src/stories/components/Settings/CourseOptionsForm.stories.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { http, HttpResponse } from "msw"; -import CourseOptionsForm from "main/components/Settings/CourseOptionsForm"; - -const optionsState = { - ENABLE_CANVAS: false, - TRANSLATE_SECTIONS: true, -}; - -export default { - title: "components/Settings/CourseOptionsForm", - component: CourseOptionsForm, -}; - -const Template = (args) => { - return ; -}; - -export const CanEdit = Template.bind({}); - -CanEdit.args = { - courseId: 1, - canEdit: true, -}; - -CanEdit.parameters = { - msw: [ - // Make sure that the GET request to fetch course options for courseId=1 returns the expected options - http.get("/api/course/options?courseId=1", () => { - return HttpResponse.json(optionsState); - }), - // Make sure that the POST request to update course options for courseId=1, option=ENABLE_CANVAS, enabled=true returns the expected response - http.post("/api/course/options", ({ request }) => { - const url = new URL(request.url); - window.alert(`Invoked post with URL: ${url}`); - const enabled = url.searchParams.get("enabled") === "true"; - const option = url.searchParams.get("option"); - optionsState[option] = url.searchParams.get("enabled") === "true"; - return HttpResponse.json( - { - [option]: enabled, - }, - { - status: 200, - }, - ); - }), - ], -}; - -export const CannotEdit = Template.bind({}); - -CannotEdit.args = { - courseId: 1, - canEdit: false, -}; - -CannotEdit.parameters = { - msw: [ - http.get("/api/course/options/", () => { - return HttpResponse.json(optionsState); - }), - ], -}; diff --git a/frontend/src/stories/pages/Help/AboutScaffold.stories.jsx b/frontend/src/stories/pages/Help/AboutCitelines.stories.jsx similarity index 100% rename from frontend/src/stories/pages/Help/AboutScaffold.stories.jsx rename to frontend/src/stories/pages/Help/AboutCitelines.stories.jsx diff --git a/frontend/src/tests/api/legacyClient.test.ts b/frontend/src/tests/api/legacyClient.test.ts deleted file mode 100644 index 9e17849..0000000 --- a/frontend/src/tests/api/legacyClient.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; -import { - fetchAssessments, - fetchQuestions, - fetchQuestionConcepts, - fetchLegacyUserState, - saveLegacyUserState, - logLegacyUserActivity, -} from "main/api/legacyClient"; - -function mockFetchOnce( - body: unknown, - init: { status?: number; ok?: boolean } = {}, -) { - const { status = 200, ok = status >= 200 && status < 300 } = init; - const response = { - ok, - status, - json: () => Promise.resolve(body), - } as Response; - (globalThis.fetch as ReturnType).mockResolvedValueOnce( - response, - ); - return response; -} - -describe("api/legacyClient", () => { - beforeEach(() => { - globalThis.fetch = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("fetchAssessments", () => { - test("fetches from the legacy assessments endpoint and returns json", async () => { - const assessments = [{ id: "1", pl_assessment_id: "pl1", name: "HW1" }]; - mockFetchOnce(assessments); - - const result = await fetchAssessments(); - - expect(globalThis.fetch).toHaveBeenCalledWith("/api/legacy/assessments"); - expect(result).toEqual(assessments); - }); - }); - - describe("fetchQuestions", () => { - test("fetches from the legacy questions endpoint for the given assessment", async () => { - const questions = [ - { - id: "1", - assessment_id: "1", - pl_question_uuid: "uuid-1", - title: "Q1", - }, - ]; - mockFetchOnce(questions); - - const result = await fetchQuestions("1"); - - expect(globalThis.fetch).toHaveBeenCalledWith( - "/api/legacy/assessments/1/questions", - ); - expect(result).toEqual(questions); - }); - }); - - describe("fetchQuestionConcepts", () => { - test("fetches from the legacy concepts endpoint for the given question", async () => { - const concepts = [ - { - id: "1", - question_id: "1", - concept_id: "c1", - subconcept_label: null, - }, - ]; - mockFetchOnce(concepts); - - const result = await fetchQuestionConcepts("1"); - - expect(globalThis.fetch).toHaveBeenCalledWith( - "/api/legacy/questions/1/concepts", - ); - expect(result).toEqual(concepts); - }); - }); - - describe("fetchLegacyUserState", () => { - test("returns the parsed user state on success", async () => { - const userState = { - starred_ids: ["1"], - detail_cards: [], - mastered_subconcepts: [], - }; - mockFetchOnce(userState); - - const result = await fetchLegacyUserState(1); - - expect(globalThis.fetch).toHaveBeenCalledWith("/api/legacy/user-state/1"); - expect(result).toEqual(userState); - }); - - test("returns null when the response is a 404", async () => { - mockFetchOnce(null, { status: 404, ok: false }); - - const result = await fetchLegacyUserState(1); - - expect(result).toBeNull(); - }); - - test("throws when the response is not ok and not a 404", async () => { - mockFetchOnce(null, { status: 500, ok: false }); - - await expect(fetchLegacyUserState(1)).rejects.toThrow( - "Failed to fetch user state for userid 1", - ); - }); - }); - - describe("saveLegacyUserState", () => { - const body = { - userid: 1, - starred_ids: ["1"], - detail_cards: [], - mastered_subconcepts: [], - }; - - test("posts the body as json on success", async () => { - mockFetchOnce(null, { status: 200 }); - - await saveLegacyUserState(body); - - expect(globalThis.fetch).toHaveBeenCalledWith("/api/legacy/user-state", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - }); - - test("throws when the response is not ok", async () => { - mockFetchOnce(null, { status: 500, ok: false }); - - await expect(saveLegacyUserState(body)).rejects.toThrow( - "Failed to save user state for userid 1", - ); - }); - }); - - describe("logLegacyUserActivity", () => { - const body = { - userid: 1, - event_type: "click", - payload: { foo: "bar" }, - }; - - test("posts the body as json on success", async () => { - mockFetchOnce(null, { status: 200 }); - - await logLegacyUserActivity(body); - - expect(globalThis.fetch).toHaveBeenCalledWith( - "/api/legacy/user-activity", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }, - ); - }); - - test("throws when the response is not ok", async () => { - mockFetchOnce(null, { status: 500, ok: false }); - - await expect(logLegacyUserActivity(body)).rejects.toThrow( - "Failed to log user activity for userid 1", - ); - }); - }); -}); diff --git a/frontend/src/tests/components/Concept/ConceptDeleteModal.test.jsx b/frontend/src/tests/components/Concept/ConceptDeleteModal.test.jsx deleted file mode 100644 index 0783423..0000000 --- a/frontend/src/tests/components/Concept/ConceptDeleteModal.test.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import { vi } from "vitest"; -import ConceptDeleteModal from "main/components/Concept/ConceptDeleteModal"; -import { fireEvent, render, waitFor, screen } from "@testing-library/react"; - -const mockSubmit = vi.fn(); -const showModal = vi.fn(); -const toggleShowModal = vi.fn(); - -describe("ConceptDeleteModal tests", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test("ConceptDeleteModal renders correctly and submits", async () => { - render( -
- -
, - ); - - expect(screen.getByTestId("ConceptDeleteModal")).toHaveClass( - "modal-dialog-centered", - ); - expect( - screen.getByText( - "Are you sure you want to delete this concept? Deleting a top-level concept also deletes all of its subconcepts.", - ), - ).toBeInTheDocument(); - - fireEvent.click( - screen.getByRole("button", { - name: "Delete Concept", - }), - ); - await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(1)); - }); - - test("can click close", async () => { - render( -
- -
, - ); - - fireEvent.click(await screen.findByRole("button", { name: "Close" })); - await waitFor(() => expect(toggleShowModal).toHaveBeenCalledWith(false)); - }); -}); diff --git a/frontend/src/tests/components/Concept/ConceptModal.test.jsx b/frontend/src/tests/components/Concept/ConceptModal.test.jsx deleted file mode 100644 index 99803b8..0000000 --- a/frontend/src/tests/components/Concept/ConceptModal.test.jsx +++ /dev/null @@ -1,153 +0,0 @@ -import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { vi } from "vitest"; -import ConceptModal from "main/components/Concept/ConceptModal"; -import conceptsFixtures from "fixtures/conceptsFixtures"; - -vi.mock("react-simplemde-editor", () => ({ - default: ({ value = "", onChange }) => ( -