From 1ec4ff97f80882f30ed1c152eddcf4d8fa63b0fd Mon Sep 17 00:00:00 2001 From: kah-seng Date: Fri, 14 Feb 2025 16:59:57 +0800 Subject: [PATCH 01/61] Added exam mode switches for admin panel and create course dropdown --- .tool-versions | 1 + package.json | 3 ++- src/commons/application/types/SessionTypes.ts | 2 ++ src/commons/dropdown/DropdownCreateCourse.tsx | 13 +++++++++++++ src/commons/mocks/UserMocks.ts | 2 ++ src/pages/academy/adminPanel/AdminPanel.tsx | 2 ++ .../adminPanel/subcomponents/CourseConfigPanel.tsx | 11 +++++++++++ 7 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000000..1a3e61bfce --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs v20.18.1 diff --git a/package.json b/package.json index 82fadd2c5e..760dba8ea5 100644 --- a/package.json +++ b/package.json @@ -177,5 +177,6 @@ "last 1 firefox version", "last 1 safari version" ] - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index 20d4bb4402..db3418f761 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -39,6 +39,7 @@ export type SessionState = { readonly enableAchievements?: boolean; readonly enableSourcecast?: boolean; readonly enableStories?: boolean; + readonly enableExamMode?: boolean; readonly sourceChapter?: Chapter; readonly sourceVariant?: Variant; readonly moduleHelpText?: string; @@ -105,6 +106,7 @@ export type CourseConfiguration = { enableAchievements: boolean; enableSourcecast: boolean; enableStories: boolean; + enableExamMode: boolean; sourceChapter: Chapter; sourceVariant: Variant; moduleHelpText: string; diff --git a/src/commons/dropdown/DropdownCreateCourse.tsx b/src/commons/dropdown/DropdownCreateCourse.tsx index 2d37ce7eb9..c8f889d25a 100644 --- a/src/commons/dropdown/DropdownCreateCourse.tsx +++ b/src/commons/dropdown/DropdownCreateCourse.tsx @@ -40,6 +40,7 @@ const DropdownCreateCourse: React.FC = props => { enableAchievements: true, enableSourcecast: true, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: '' @@ -234,6 +235,18 @@ const DropdownCreateCourse: React.FC = props => { }) } /> + + + setCourseConfig({ + ...courseConfig, + enableExamMode: (e.target as HTMLInputElement).checked + }) + } + />
diff --git a/src/commons/mocks/UserMocks.ts b/src/commons/mocks/UserMocks.ts index 523d68fc05..2198ffdaec 100644 --- a/src/commons/mocks/UserMocks.ts +++ b/src/commons/mocks/UserMocks.ts @@ -373,6 +373,7 @@ export const mockCourseConfigurations: CourseConfiguration[] = [ enableAchievements: true, enableSourcecast: true, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: '', @@ -386,6 +387,7 @@ export const mockCourseConfigurations: CourseConfiguration[] = [ enableAchievements: false, enableSourcecast: false, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_2, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help Text!', diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index d60edb1647..50b42bcd5f 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -29,6 +29,7 @@ const defaultCourseConfig: UpdateCourseConfiguration = { enableAchievements: true, enableSourcecast: true, enableStories: false, + enableExamMode: false, moduleHelpText: '' }; @@ -62,6 +63,7 @@ const AdminPanel: React.FC = () => { enableAchievements: session.enableAchievements, enableSourcecast: session.enableSourcecast, enableStories: session.enableStories, + enableExamMode: session.enableExamMode, moduleHelpText: session.moduleHelpText }); }, [ diff --git a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx index 023c659038..c64ea3c413 100644 --- a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx +++ b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx @@ -38,6 +38,7 @@ const CourseConfigPanel: React.FC = props => { enableAchievements, enableSourcecast, enableStories, + enableExamMode, moduleHelpText } = props.courseConfiguration; @@ -186,6 +187,16 @@ const CourseConfigPanel: React.FC = props => { }) } /> + + props.setCourseConfiguration({ + ...props.courseConfiguration, + enableExamMode: (e.target as HTMLInputElement).checked + }) + } + />
From 1aba138abe88100dc06f9cd446ec355087494999 Mon Sep 17 00:00:00 2001 From: kah-seng Date: Fri, 14 Feb 2025 17:07:06 +0800 Subject: [PATCH 02/61] Fixed exam mode switch not updating on launch --- src/pages/academy/adminPanel/AdminPanel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index 50b42bcd5f..30d493054b 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -73,6 +73,7 @@ const AdminPanel: React.FC = () => { session.enableGame, session.enableSourcecast, session.enableStories, + session.enableExamMode, session.moduleHelpText, session.viewable ]); From 5111ee548a035db3c970b30dda85211cfd6fa477 Mon Sep 17 00:00:00 2001 From: kah-seng Date: Fri, 14 Feb 2025 17:48:06 +0800 Subject: [PATCH 03/61] Hide Google Drive, GitHub, share and sessions button when exam mode enabled --- .../ControlBarGoogleDriveButtons.tsx | 47 ++++++++++--------- .../github/ControlBarGitHubButtons.tsx | 45 ++++++++++-------- src/pages/playground/Playground.tsx | 7 +-- 3 files changed, 55 insertions(+), 44 deletions(-) diff --git a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx index ff6246eeca..627da269c1 100644 --- a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx +++ b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { PersistenceFile, PersistenceState } from '../../features/persistence/PersistenceTypes'; import ControlButton from '../ControlButton'; -import { useResponsive } from '../utils/Hooks'; +import { useResponsive, useSession } from '../utils/Hooks'; const stateToIntent: { [state in PersistenceState]: Intent } = { INACTIVE: Intent.NONE, @@ -25,6 +25,7 @@ type Props = { }; export const ControlBarGoogleDriveButtons: React.FC = props => { + const { enableExamMode } = useSession(); const { isMobileBreakpoint } = useResponsive(); const state: PersistenceState = props.currentFile ? props.isDirty @@ -64,25 +65,29 @@ export const ControlBarGoogleDriveButtons: React.FC = props => { : undefined; return ( - - - - {openButton} - {saveButton} - {saveAsButton} - {logoutButton} - - - } - onOpening={props.onPopoverOpening} - popoverClassName={Classes.POPOVER_DISMISS} - disabled={props.isFolderModeEnabled} - > - {mainButton} - - + enableExamMode ? ( + <> + ) : ( + + + + {openButton} + {saveButton} + {saveAsButton} + {logoutButton} + + + } + onOpening={props.onPopoverOpening} + popoverClassName={Classes.POPOVER_DISMISS} + disabled={props.isFolderModeEnabled} + > + {mainButton} + + + ) ); }; diff --git a/src/commons/controlBar/github/ControlBarGitHubButtons.tsx b/src/commons/controlBar/github/ControlBarGitHubButtons.tsx index 5fb888dbaa..9f125d4322 100644 --- a/src/commons/controlBar/github/ControlBarGitHubButtons.tsx +++ b/src/commons/controlBar/github/ControlBarGitHubButtons.tsx @@ -2,7 +2,7 @@ import { ButtonGroup, Classes, Intent, Popover, Tooltip } from '@blueprintjs/cor import { IconNames } from '@blueprintjs/icons'; import { Octokit } from '@octokit/rest'; import React from 'react'; -import { useResponsive } from 'src/commons/utils/Hooks'; +import { useResponsive, useSession } from 'src/commons/utils/Hooks'; import { GitHubSaveInfo } from '../../../features/github/GitHubTypes'; import ControlButton from '../../ControlButton'; @@ -26,6 +26,7 @@ type Props = { * @param props Component properties */ export const ControlBarGitHubButtons: React.FC = props => { + const { enableExamMode } = useSession(); const { isMobileBreakpoint } = useResponsive(); const filePath = props.githubSaveInfo.filePath || ''; @@ -89,24 +90,28 @@ export const ControlBarGitHubButtons: React.FC = props => { : undefined; return ( - - - - {openButton} - {saveButton} - {saveAsButton} - {loginButton} - - - } - popoverClassName={Classes.POPOVER_DISMISS} - disabled={props.isFolderModeEnabled} - > - {mainButton} - - + enableExamMode ? ( + <> + ) : ( + + + + {openButton} + {saveButton} + {saveAsButton} + {loginButton} + + + } + popoverClassName={Classes.POPOVER_DISMISS} + disabled={props.isFolderModeEnabled} + > + {mainButton} + + + ) ); }; diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 58aeabeebd..94d81d0a96 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -25,7 +25,7 @@ import makeHtmlDisplayTabFrom from 'src/commons/sideContent/content/SideContentH import makeUploadTabFrom from 'src/commons/sideContent/content/SideContentUpload'; import { changeSideContentHeight } from 'src/commons/sideContent/SideContentActions'; import { useSideContent } from 'src/commons/sideContent/SideContentHelper'; -import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; +import { useResponsive, useSession, useTypedSelector } from 'src/commons/utils/Hooks'; import { showFullJSWarningOnUrlLoad, showFulTSWarningOnUrlLoad, @@ -197,6 +197,7 @@ export async function handleHash( const Playground: React.FC = props => { const { isSicpEditor } = props; const workspaceLocation: WorkspaceLocation = isSicpEditor ? 'sicp' : 'playground'; + const { enableExamMode } = useSession(); const { isMobileBreakpoint } = useResponsive(); const [deviceSecret, setDeviceSecret] = useState(); @@ -972,9 +973,9 @@ const Playground: React.FC = props => { controlBarProps: { editorButtons: [ autorunButtons, - languageConfig.chapter === Chapter.FULL_JS ? null : shareButton, + languageConfig.chapter === Chapter.FULL_JS || enableExamMode ? null : shareButton, chapterSelectButton, - isSicpEditor ? null : sessionButtons, + isSicpEditor || enableExamMode ? null : sessionButtons, languageConfig.supports.multiFile ? toggleFolderModeButton : null, persistenceButtons, githubButtons, From c8d8f18d668ca09508e8994fe2ed476432a784e0 Mon Sep 17 00:00:00 2001 From: Izumi Kyouka Date: Sun, 16 Feb 2025 22:50:40 +0800 Subject: [PATCH 04/61] added auto url navigation to course under exam mode; fixed UI logic causing spinner to show --- src/commons/dropdown/DropdownCourses.tsx | 6 ++++-- src/commons/sagas/BackendSaga.ts | 15 +++++++++++++++ src/pages/academy/Academy.tsx | 9 +++++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/commons/dropdown/DropdownCourses.tsx b/src/commons/dropdown/DropdownCourses.tsx index 6bc10e5f6c..e22691ec23 100644 --- a/src/commons/dropdown/DropdownCourses.tsx +++ b/src/commons/dropdown/DropdownCourses.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router'; import { Role } from '../application/ApplicationTypes'; import { UserCourse } from '../application/types/SessionTypes'; +import { useTypedSelector } from '../utils/Hooks'; type Props = { isOpen: boolean; @@ -15,11 +16,12 @@ type Props = { const DropdownCourses: React.FC = ({ isOpen, onClose, courses, courseId }) => { const navigate = useNavigate(); + const isUnderExamMode = useTypedSelector(state => state.session.enableExamMode); const options = courses.map(course => ({ value: course.courseId, label: course.courseName.concat(!course.viewable ? ' - disabled' : ''), - disabled: !course.viewable && course.role !== Role.Admin + disabled: !course.viewable && course.role !== Role.Admin })); const onChangeHandler = (e: React.ChangeEvent) => { @@ -42,7 +44,7 @@ const DropdownCourses: React.FC = ({ isOpen, onClose, courses, courseId } options={options} fill onChange={onChangeHandler} - disabled={courses.length <= 1} + disabled={courses.length <= 1 || isUnderExamMode} /> diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index b9c57609b8..91cc59d10a 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -822,6 +822,21 @@ const newBackendSagaTwo = combineSagaHandlers(sagaActions, { assessmentConfigurations: AssessmentConfiguration[] | null; } = yield call(getLatestCourseRegistrationAndConfiguration, tokens); + if (courseConfiguration?.enableExamMode) { + const { + user + }: { + user: User | null; + courseRegistration: CourseRegistration | null; + courseConfiguration: CourseConfiguration | null; + assessmentConfigurations: AssessmentConfiguration[] | null; + } = yield call(getUser, tokens); + + if (user) { + yield put(actions.setUser(user)); + } + } + if (!courseRegistration || !courseConfiguration || !assessmentConfigurations) { yield call(showWarningMessage, `Failed to load course!`); return yield routerNavigate('/welcome'); diff --git a/src/pages/academy/Academy.tsx b/src/pages/academy/Academy.tsx index ea503ed10b..15603dc002 100644 --- a/src/pages/academy/Academy.tsx +++ b/src/pages/academy/Academy.tsx @@ -5,7 +5,7 @@ import { useDispatch } from 'react-redux'; import { Navigate, Outlet, useNavigate, useParams } from 'react-router'; import ResearchAgreementPrompt from 'src/commons/researchAgreementPrompt/ResearchAgreementPrompt'; import Constants from 'src/commons/utils/Constants'; -import { useSession } from 'src/commons/utils/Hooks'; +import { useSession, useTypedSelector } from 'src/commons/utils/Hooks'; import classes from 'src/styles/Academy.module.scss'; import SessionActions from '../../commons/application/actions/SessionActions'; @@ -37,6 +37,7 @@ const CourseSelectingAcademy: React.FC = () => { const { courseId } = useSession(); const { courseId: routeCourseIdStr } = useParams<{ courseId?: string }>(); const routeCourseId = routeCourseIdStr != null ? parseInt(routeCourseIdStr, 10) : undefined; + const isUnderExamMode = useTypedSelector(state => state.session.enableExamMode); React.useEffect(() => { // Regex to handle case where routeCourseIdStr is not a number @@ -47,7 +48,11 @@ const CourseSelectingAcademy: React.FC = () => { if (routeCourseId !== undefined && !Number.isNaN(routeCourseId) && courseId !== routeCourseId) { dispatch(SessionActions.updateLatestViewedCourse(routeCourseId)); } - }, [courseId, dispatch, routeCourseId, navigate, routeCourseIdStr]); + + if (isUnderExamMode) { + navigate(`/courses/${courseId}`); + } + }, [courseId, dispatch, routeCourseId, navigate, routeCourseIdStr, isUnderExamMode]); return Number.isNaN(routeCourseId) ? ( From 804fdad606784482a4755066022a311cc65c3082 Mon Sep 17 00:00:00 2001 From: Izumi Kyouka Date: Sun, 16 Feb 2025 23:09:37 +0800 Subject: [PATCH 05/61] fixed some formatting with yarn run format --- .../ControlBarGoogleDriveButtons.tsx | 48 +++++++++---------- .../github/ControlBarGitHubButtons.tsx | 46 +++++++++--------- src/commons/dropdown/DropdownCourses.tsx | 2 +- src/commons/sagas/BackendSaga.ts | 2 +- 4 files changed, 47 insertions(+), 51 deletions(-) diff --git a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx index 627da269c1..31f5c8a742 100644 --- a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx +++ b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx @@ -64,30 +64,28 @@ export const ControlBarGoogleDriveButtons: React.FC = props => { ? 'Currently unsupported in Folder mode' : undefined; - return ( - enableExamMode ? ( - <> - ) : ( - - - - {openButton} - {saveButton} - {saveAsButton} - {logoutButton} - - - } - onOpening={props.onPopoverOpening} - popoverClassName={Classes.POPOVER_DISMISS} - disabled={props.isFolderModeEnabled} - > - {mainButton} - - - ) + return enableExamMode ? ( + <> + ) : ( + + + + {openButton} + {saveButton} + {saveAsButton} + {logoutButton} + + + } + onOpening={props.onPopoverOpening} + popoverClassName={Classes.POPOVER_DISMISS} + disabled={props.isFolderModeEnabled} + > + {mainButton} + + ); }; diff --git a/src/commons/controlBar/github/ControlBarGitHubButtons.tsx b/src/commons/controlBar/github/ControlBarGitHubButtons.tsx index 9f125d4322..83d774055f 100644 --- a/src/commons/controlBar/github/ControlBarGitHubButtons.tsx +++ b/src/commons/controlBar/github/ControlBarGitHubButtons.tsx @@ -89,29 +89,27 @@ export const ControlBarGitHubButtons: React.FC = props => { ? 'Currently unsupported in Folder mode' : undefined; - return ( - enableExamMode ? ( - <> - ) : ( - - - - {openButton} - {saveButton} - {saveAsButton} - {loginButton} - - - } - popoverClassName={Classes.POPOVER_DISMISS} - disabled={props.isFolderModeEnabled} - > - {mainButton} - - - ) + return enableExamMode ? ( + <> + ) : ( + + + + {openButton} + {saveButton} + {saveAsButton} + {loginButton} + + + } + popoverClassName={Classes.POPOVER_DISMISS} + disabled={props.isFolderModeEnabled} + > + {mainButton} + + ); }; diff --git a/src/commons/dropdown/DropdownCourses.tsx b/src/commons/dropdown/DropdownCourses.tsx index e22691ec23..1de6f7637c 100644 --- a/src/commons/dropdown/DropdownCourses.tsx +++ b/src/commons/dropdown/DropdownCourses.tsx @@ -21,7 +21,7 @@ const DropdownCourses: React.FC = ({ isOpen, onClose, courses, courseId } const options = courses.map(course => ({ value: course.courseId, label: course.courseName.concat(!course.viewable ? ' - disabled' : ''), - disabled: !course.viewable && course.role !== Role.Admin + disabled: !course.viewable && course.role !== Role.Admin })); const onChangeHandler = (e: React.ChangeEvent) => { diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index 91cc59d10a..da5f8d16c1 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -831,7 +831,7 @@ const newBackendSagaTwo = combineSagaHandlers(sagaActions, { courseConfiguration: CourseConfiguration | null; assessmentConfigurations: AssessmentConfiguration[] | null; } = yield call(getUser, tokens); - + if (user) { yield put(actions.setUser(user)); } From 7f09b057eeac2b19b531aee04239e8b93d34763a Mon Sep 17 00:00:00 2001 From: Izumi Kyouka Date: Sun, 16 Feb 2025 23:23:08 +0800 Subject: [PATCH 06/61] added enableExamMode field in backend tests; eslint fix and formatting --- public/externalLibs/sound/soundToneMatrix.js | 6 +++--- src/commons/sagas/__tests__/BackendSaga.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/public/externalLibs/sound/soundToneMatrix.js b/public/externalLibs/sound/soundToneMatrix.js index 8638a90378..d0246757bf 100644 --- a/public/externalLibs/sound/soundToneMatrix.js +++ b/public/externalLibs/sound/soundToneMatrix.js @@ -36,7 +36,7 @@ var timeout_matrix; // for coloring the matrix accordingly while it's being played var timeout_color; -var timeout_objects = new Array(); +var timeout_objects = []; // vector_to_list returns a list that contains the elements of the argument vector // in the given order. @@ -54,7 +54,7 @@ function vector_to_list(vector) { function x_y_to_row_column(x, y) { var row = Math.floor((y - margin_length) / (square_side_length + distance_between_squares)); var column = Math.floor((x - margin_length) / (square_side_length + distance_between_squares)); - return Array(row, column); + return [row, column]; } // given the row number of a square, return the leftmost coordinate @@ -365,5 +365,5 @@ function clear_all_timeout() { clearTimeout(timeout_objects[i]); } - timeout_objects = new Array(); + timeout_objects = []; } diff --git a/src/commons/sagas/__tests__/BackendSaga.ts b/src/commons/sagas/__tests__/BackendSaga.ts index e6ea9f1320..3f514f80eb 100644 --- a/src/commons/sagas/__tests__/BackendSaga.ts +++ b/src/commons/sagas/__tests__/BackendSaga.ts @@ -133,6 +133,7 @@ const mockCourseConfiguration1: CourseConfiguration = { enableAchievements: true, enableSourcecast: true, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help text', @@ -164,6 +165,7 @@ const mockCourseConfiguration2: CourseConfiguration = { enableAchievements: true, enableSourcecast: true, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_4, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help text', @@ -930,6 +932,7 @@ describe('Test UPDATE_COURSE_CONFIG action', () => { enableAchievements: false, enableSourcecast: false, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_4, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help', @@ -1028,6 +1031,7 @@ describe('Test CREATE_COURSE action', () => { enableAchievements: true, enableSourcecast: true, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help Text' From 38b208c10bcd911d888caf97b32e2b0c006cb1ee Mon Sep 17 00:00:00 2001 From: kah-seng Date: Mon, 17 Feb 2025 15:13:54 +0800 Subject: [PATCH 07/61] Hide remote execution when exam mode enabled, shifted exam mode checks to Playground.tsx for GitHub and Google Drive buttons --- .../controlBar/ControlBarGoogleDriveButtons.tsx | 7 ++----- .../controlBar/github/ControlBarGitHubButtons.tsx | 9 +++------ src/pages/playground/Playground.tsx | 15 ++++++++------- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx index 31f5c8a742..ff6246eeca 100644 --- a/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx +++ b/src/commons/controlBar/ControlBarGoogleDriveButtons.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { PersistenceFile, PersistenceState } from '../../features/persistence/PersistenceTypes'; import ControlButton from '../ControlButton'; -import { useResponsive, useSession } from '../utils/Hooks'; +import { useResponsive } from '../utils/Hooks'; const stateToIntent: { [state in PersistenceState]: Intent } = { INACTIVE: Intent.NONE, @@ -25,7 +25,6 @@ type Props = { }; export const ControlBarGoogleDriveButtons: React.FC = props => { - const { enableExamMode } = useSession(); const { isMobileBreakpoint } = useResponsive(); const state: PersistenceState = props.currentFile ? props.isDirty @@ -64,9 +63,7 @@ export const ControlBarGoogleDriveButtons: React.FC = props => { ? 'Currently unsupported in Folder mode' : undefined; - return enableExamMode ? ( - <> - ) : ( + return ( = props => { - const { enableExamMode } = useSession(); const { isMobileBreakpoint } = useResponsive(); const filePath = props.githubSaveInfo.filePath || ''; @@ -89,9 +88,7 @@ export const ControlBarGitHubButtons: React.FC = props => { ? 'Currently unsupported in Folder mode' : undefined; - return enableExamMode ? ( - <> - ) : ( + return ( = props => { ); -}; +}; \ No newline at end of file diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index 94d81d0a96..47467f3f06 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -25,7 +25,7 @@ import makeHtmlDisplayTabFrom from 'src/commons/sideContent/content/SideContentH import makeUploadTabFrom from 'src/commons/sideContent/content/SideContentUpload'; import { changeSideContentHeight } from 'src/commons/sideContent/SideContentActions'; import { useSideContent } from 'src/commons/sideContent/SideContentHelper'; -import { useResponsive, useSession, useTypedSelector } from 'src/commons/utils/Hooks'; +import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; import { showFullJSWarningOnUrlLoad, showFulTSWarningOnUrlLoad, @@ -197,7 +197,6 @@ export async function handleHash( const Playground: React.FC = props => { const { isSicpEditor } = props; const workspaceLocation: WorkspaceLocation = isSicpEditor ? 'sicp' : 'playground'; - const { enableExamMode } = useSession(); const { isMobileBreakpoint } = useResponsive(); const [deviceSecret, setDeviceSecret] = useState(); @@ -234,7 +233,8 @@ const Playground: React.FC = props => { sourceChapter: courseSourceChapter, sourceVariant: courseSourceVariant, googleUser: persistenceUser, - githubOctokitObject + githubOctokitObject, + enableExamMode } = useTypedSelector(state => state.session); const dispatch = useDispatch(); @@ -750,7 +750,7 @@ const Playground: React.FC = props => { } } - if (!isSicpEditor && !Constants.playgroundOnly) { + if (!isSicpEditor && !Constants.playgroundOnly && !enableExamMode) { tabs.push(remoteExecutionTab); } @@ -766,7 +766,8 @@ const Playground: React.FC = props => { shouldShowDataVisualizer, shouldShowCseMachine, shouldShowSubstVisualizer, - remoteExecutionTab + remoteExecutionTab, + enableExamMode ]); // Remove Intro and Remote Execution tabs for mobile @@ -977,8 +978,8 @@ const Playground: React.FC = props => { chapterSelectButton, isSicpEditor || enableExamMode ? null : sessionButtons, languageConfig.supports.multiFile ? toggleFolderModeButton : null, - persistenceButtons, - githubButtons, + enableExamMode ? null : persistenceButtons, + enableExamMode ? null : githubButtons, usingSubst || usingCse || isCseVariant(languageConfig.variant) ? stepperStepLimit : isSourceLanguage(languageConfig.chapter) From f17ff5963f9cfc5633e06232fa6ce23de2b2131a Mon Sep 17 00:00:00 2001 From: kah-seng Date: Fri, 21 Feb 2025 21:59:47 +0800 Subject: [PATCH 08/61] yarn run format and rename for standardisation --- src/commons/controlBar/github/ControlBarGitHubButtons.tsx | 2 +- src/commons/dropdown/DropdownCourses.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commons/controlBar/github/ControlBarGitHubButtons.tsx b/src/commons/controlBar/github/ControlBarGitHubButtons.tsx index 99b5ed6d41..5fb888dbaa 100644 --- a/src/commons/controlBar/github/ControlBarGitHubButtons.tsx +++ b/src/commons/controlBar/github/ControlBarGitHubButtons.tsx @@ -109,4 +109,4 @@ export const ControlBarGitHubButtons: React.FC = props => { ); -}; \ No newline at end of file +}; diff --git a/src/commons/dropdown/DropdownCourses.tsx b/src/commons/dropdown/DropdownCourses.tsx index 1de6f7637c..c6d82056b9 100644 --- a/src/commons/dropdown/DropdownCourses.tsx +++ b/src/commons/dropdown/DropdownCourses.tsx @@ -16,7 +16,7 @@ type Props = { const DropdownCourses: React.FC = ({ isOpen, onClose, courses, courseId }) => { const navigate = useNavigate(); - const isUnderExamMode = useTypedSelector(state => state.session.enableExamMode); + const enableExamMode = useTypedSelector(state => state.session.enableExamMode); const options = courses.map(course => ({ value: course.courseId, @@ -44,7 +44,7 @@ const DropdownCourses: React.FC = ({ isOpen, onClose, courses, courseId } options={options} fill onChange={onChangeHandler} - disabled={courses.length <= 1 || isUnderExamMode} + disabled={courses.length <= 1 || enableExamMode} /> From bad7d503370e9147a5a07e64359a8eb194f81c19 Mon Sep 17 00:00:00 2001 From: kah-seng Date: Tue, 4 Mar 2025 22:23:43 +0800 Subject: [PATCH 09/61] Formatting --- .tool-versions | 2 +- package.json | 3 +-- public/externalLibs/sound/soundToneMatrix.js | 6 +++--- src/pages/academy/Academy.tsx | 6 +++--- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.tool-versions b/.tool-versions index 1a3e61bfce..e84e893012 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs v20.18.1 +nodejs v20.18.1 \ No newline at end of file diff --git a/package.json b/package.json index 760dba8ea5..82fadd2c5e 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + } } diff --git a/public/externalLibs/sound/soundToneMatrix.js b/public/externalLibs/sound/soundToneMatrix.js index d0246757bf..8638a90378 100644 --- a/public/externalLibs/sound/soundToneMatrix.js +++ b/public/externalLibs/sound/soundToneMatrix.js @@ -36,7 +36,7 @@ var timeout_matrix; // for coloring the matrix accordingly while it's being played var timeout_color; -var timeout_objects = []; +var timeout_objects = new Array(); // vector_to_list returns a list that contains the elements of the argument vector // in the given order. @@ -54,7 +54,7 @@ function vector_to_list(vector) { function x_y_to_row_column(x, y) { var row = Math.floor((y - margin_length) / (square_side_length + distance_between_squares)); var column = Math.floor((x - margin_length) / (square_side_length + distance_between_squares)); - return [row, column]; + return Array(row, column); } // given the row number of a square, return the leftmost coordinate @@ -365,5 +365,5 @@ function clear_all_timeout() { clearTimeout(timeout_objects[i]); } - timeout_objects = []; + timeout_objects = new Array(); } diff --git a/src/pages/academy/Academy.tsx b/src/pages/academy/Academy.tsx index 15603dc002..e141847a76 100644 --- a/src/pages/academy/Academy.tsx +++ b/src/pages/academy/Academy.tsx @@ -37,7 +37,7 @@ const CourseSelectingAcademy: React.FC = () => { const { courseId } = useSession(); const { courseId: routeCourseIdStr } = useParams<{ courseId?: string }>(); const routeCourseId = routeCourseIdStr != null ? parseInt(routeCourseIdStr, 10) : undefined; - const isUnderExamMode = useTypedSelector(state => state.session.enableExamMode); + const enableExamMode = useTypedSelector(state => state.session.enableExamMode); React.useEffect(() => { // Regex to handle case where routeCourseIdStr is not a number @@ -49,10 +49,10 @@ const CourseSelectingAcademy: React.FC = () => { dispatch(SessionActions.updateLatestViewedCourse(routeCourseId)); } - if (isUnderExamMode) { + if (enableExamMode) { navigate(`/courses/${courseId}`); } - }, [courseId, dispatch, routeCourseId, navigate, routeCourseIdStr, isUnderExamMode]); + }, [courseId, dispatch, routeCourseId, navigate, routeCourseIdStr, enableExamMode]); return Number.isNaN(routeCourseId) ? ( From 2d837c1c278cb52818e8b46de76ad967b79869e3 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 6 Mar 2025 11:08:29 +0800 Subject: [PATCH 10/61] Remove .tool-versions --- .tool-versions | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index e84e893012..0000000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -nodejs v20.18.1 \ No newline at end of file From 0c7526b8a045fd8967e16f69b25608c8ef10782f Mon Sep 17 00:00:00 2001 From: kah-seng Date: Thu, 6 Mar 2025 20:41:39 +0800 Subject: [PATCH 11/61] Disable create course when under exam mode, hide exam mode toggle when not an official course, remove exam mode toggle when creating new course --- src/commons/application/types/SessionTypes.ts | 2 ++ src/commons/dropdown/Dropdown.tsx | 9 ++++--- src/commons/dropdown/DropdownCreateCourse.tsx | 12 --------- src/commons/mocks/UserMocks.ts | 6 +++-- src/pages/academy/adminPanel/AdminPanel.tsx | 4 ++- .../subcomponents/CourseConfigPanel.tsx | 25 +++++++++++-------- 6 files changed, 28 insertions(+), 30 deletions(-) diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index db3418f761..55c011d605 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -44,6 +44,7 @@ export type SessionState = { readonly sourceVariant?: Variant; readonly moduleHelpText?: string; readonly assetsPrefix?: string; + readonly isOfficialCourse?: boolean; readonly assessmentConfigurations?: AssessmentConfiguration[]; readonly userCourseRegistrations?: AdminPanelCourseRegistration[]; @@ -111,6 +112,7 @@ export type CourseConfiguration = { sourceVariant: Variant; moduleHelpText: string; assetsPrefix: string; + isOfficialCourse: boolean; }; export type AdminPanelCourseRegistration = { diff --git a/src/commons/dropdown/Dropdown.tsx b/src/commons/dropdown/Dropdown.tsx index 5d0f5a2f42..150f491fed 100644 --- a/src/commons/dropdown/Dropdown.tsx +++ b/src/commons/dropdown/Dropdown.tsx @@ -24,7 +24,7 @@ const Dropdown: React.FC = () => { const { t } = useTranslation('commons', { keyPrefix: 'dropdown' }); - const { isLoggedIn, name, courses, courseId } = useSession(); + const { isLoggedIn, name, courses, courseId, enableExamMode } = useSession(); const dispatch = useDispatch(); const handleLogOut = () => dispatch(logOut()); @@ -49,9 +49,10 @@ const Dropdown: React.FC = () => { ) : null; - const createCourse = isLoggedIn ? ( - - ) : null; + const createCourse = + isLoggedIn && !enableExamMode ? ( + + ) : null; const logout = isLoggedIn ? ( diff --git a/src/commons/dropdown/DropdownCreateCourse.tsx b/src/commons/dropdown/DropdownCreateCourse.tsx index c8f889d25a..38835bb2bc 100644 --- a/src/commons/dropdown/DropdownCreateCourse.tsx +++ b/src/commons/dropdown/DropdownCreateCourse.tsx @@ -235,18 +235,6 @@ const DropdownCreateCourse: React.FC = props => { }) } /> - - - setCourseConfig({ - ...courseConfig, - enableExamMode: (e.target as HTMLInputElement).checked - }) - } - />
diff --git a/src/commons/mocks/UserMocks.ts b/src/commons/mocks/UserMocks.ts index 2198ffdaec..dd338f4845 100644 --- a/src/commons/mocks/UserMocks.ts +++ b/src/commons/mocks/UserMocks.ts @@ -377,7 +377,8 @@ export const mockCourseConfigurations: CourseConfiguration[] = [ sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: '', - assetsPrefix: '' + assetsPrefix: '', + isOfficialCourse: false }, { courseName: `CS2040S Data Structures and Algorithms (AY20/21 Sem 2)`, @@ -391,7 +392,8 @@ export const mockCourseConfigurations: CourseConfiguration[] = [ sourceChapter: Chapter.SOURCE_2, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help Text!', - assetsPrefix: '' + assetsPrefix: '', + isOfficialCourse: false } ]; diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index 30d493054b..418e7e6f8a 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -64,7 +64,8 @@ const AdminPanel: React.FC = () => { enableSourcecast: session.enableSourcecast, enableStories: session.enableStories, enableExamMode: session.enableExamMode, - moduleHelpText: session.moduleHelpText + moduleHelpText: session.moduleHelpText, + isOfficialCourse: session.isOfficialCourse }); }, [ session.courseName, @@ -75,6 +76,7 @@ const AdminPanel: React.FC = () => { session.enableStories, session.enableExamMode, session.moduleHelpText, + session.isOfficialCourse, session.viewable ]); diff --git a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx index c64ea3c413..9d97b436d6 100644 --- a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx +++ b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx @@ -39,7 +39,8 @@ const CourseConfigPanel: React.FC = props => { enableSourcecast, enableStories, enableExamMode, - moduleHelpText + moduleHelpText, + isOfficialCourse } = props.courseConfiguration; const writePanel = ( @@ -187,16 +188,18 @@ const CourseConfigPanel: React.FC = props => { }) } /> - - props.setCourseConfiguration({ - ...props.courseConfiguration, - enableExamMode: (e.target as HTMLInputElement).checked - }) - } - /> + {isOfficialCourse && ( + + props.setCourseConfiguration({ + ...props.courseConfiguration, + enableExamMode: (e.target as HTMLInputElement).checked + }) + } + /> + )}
From fc1a803b68e433242e30ba22853abdeea5739dbf Mon Sep 17 00:00:00 2001 From: kah-seng Date: Thu, 6 Mar 2025 21:00:27 +0800 Subject: [PATCH 12/61] Added isOfficialCourse field in backend tests --- src/commons/sagas/__tests__/BackendSaga.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commons/sagas/__tests__/BackendSaga.ts b/src/commons/sagas/__tests__/BackendSaga.ts index 11e571b2f8..4d502349af 100644 --- a/src/commons/sagas/__tests__/BackendSaga.ts +++ b/src/commons/sagas/__tests__/BackendSaga.ts @@ -137,7 +137,8 @@ const mockCourseConfiguration1: CourseConfiguration = { sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help text', - assetsPrefix: '' + assetsPrefix: '', + isOfficialCourse: false }; const mockCourseRegistration2: CourseRegistration = { @@ -169,7 +170,8 @@ const mockCourseConfiguration2: CourseConfiguration = { sourceChapter: Chapter.SOURCE_4, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help text', - assetsPrefix: '' + assetsPrefix: '', + isOfficialCourse: false }; const mockAssessmentConfigurations: AssessmentConfiguration[] = [ @@ -936,7 +938,8 @@ describe('Test UPDATE_COURSE_CONFIG action', () => { sourceChapter: Chapter.SOURCE_4, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help', - assetsPrefix: '' + assetsPrefix: '', + isOfficialCourse: false }; test('when course config is changed', () => { @@ -1034,7 +1037,8 @@ describe('Test CREATE_COURSE action', () => { enableExamMode: false, sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, - moduleHelpText: 'Help Text' + moduleHelpText: 'Help Text', + isOfficialCourse: false }; const user = mockUser; const courseConfiguration = mockCourseConfiguration1; From 6a20fc73cbd31d69b54614d206a5a1a841e01e8d Mon Sep 17 00:00:00 2001 From: kah-seng Date: Thu, 6 Mar 2025 22:24:53 +0800 Subject: [PATCH 13/61] Change useTypedSelector to useSession --- src/pages/academy/Academy.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/academy/Academy.tsx b/src/pages/academy/Academy.tsx index e141847a76..e10cb249ef 100644 --- a/src/pages/academy/Academy.tsx +++ b/src/pages/academy/Academy.tsx @@ -5,7 +5,7 @@ import { useDispatch } from 'react-redux'; import { Navigate, Outlet, useNavigate, useParams } from 'react-router'; import ResearchAgreementPrompt from 'src/commons/researchAgreementPrompt/ResearchAgreementPrompt'; import Constants from 'src/commons/utils/Constants'; -import { useSession, useTypedSelector } from 'src/commons/utils/Hooks'; +import { useSession } from 'src/commons/utils/Hooks'; import classes from 'src/styles/Academy.module.scss'; import SessionActions from '../../commons/application/actions/SessionActions'; @@ -34,10 +34,9 @@ const Academy: React.FC = () => { const CourseSelectingAcademy: React.FC = () => { const dispatch = useDispatch(); const navigate = useNavigate(); - const { courseId } = useSession(); + const { courseId, enableExamMode } = useSession(); const { courseId: routeCourseIdStr } = useParams<{ courseId?: string }>(); const routeCourseId = routeCourseIdStr != null ? parseInt(routeCourseIdStr, 10) : undefined; - const enableExamMode = useTypedSelector(state => state.session.enableExamMode); React.useEffect(() => { // Regex to handle case where routeCourseIdStr is not a number From 736e6242efe2061107dd09d5b534f3003f198914 Mon Sep 17 00:00:00 2001 From: Izumi Kyouka Date: Wed, 12 Mar 2025 03:14:11 +0800 Subject: [PATCH 14/61] added resume code function in admin panel --- src/commons/application/types/SessionTypes.ts | 2 ++ src/commons/mocks/UserMocks.ts | 2 ++ src/pages/academy/adminPanel/AdminPanel.tsx | 15 +++------------ .../subcomponents/CourseConfigPanel.tsx | 18 ++++++++++++++++++ 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index 55c011d605..3e339c547d 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -40,6 +40,7 @@ export type SessionState = { readonly enableSourcecast?: boolean; readonly enableStories?: boolean; readonly enableExamMode?: boolean; + readonly resumeCode?: string; readonly sourceChapter?: Chapter; readonly sourceVariant?: Variant; readonly moduleHelpText?: string; @@ -108,6 +109,7 @@ export type CourseConfiguration = { enableSourcecast: boolean; enableStories: boolean; enableExamMode: boolean; + resumeCode: string; sourceChapter: Chapter; sourceVariant: Variant; moduleHelpText: string; diff --git a/src/commons/mocks/UserMocks.ts b/src/commons/mocks/UserMocks.ts index dd338f4845..d9bab20a1b 100644 --- a/src/commons/mocks/UserMocks.ts +++ b/src/commons/mocks/UserMocks.ts @@ -374,6 +374,7 @@ export const mockCourseConfigurations: CourseConfiguration[] = [ enableSourcecast: true, enableStories: false, enableExamMode: false, + resumeCode: '', sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: '', @@ -389,6 +390,7 @@ export const mockCourseConfigurations: CourseConfiguration[] = [ enableSourcecast: false, enableStories: false, enableExamMode: false, + resumeCode: '', sourceChapter: Chapter.SOURCE_2, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help Text!', diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index 418e7e6f8a..a48546d9ea 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -30,6 +30,7 @@ const defaultCourseConfig: UpdateCourseConfiguration = { enableSourcecast: true, enableStories: false, enableExamMode: false, + resumeCode: '', moduleHelpText: '' }; @@ -64,21 +65,11 @@ const AdminPanel: React.FC = () => { enableSourcecast: session.enableSourcecast, enableStories: session.enableStories, enableExamMode: session.enableExamMode, + resumeCode: session.resumeCode, moduleHelpText: session.moduleHelpText, isOfficialCourse: session.isOfficialCourse }); - }, [ - session.courseName, - session.courseShortName, - session.enableAchievements, - session.enableGame, - session.enableSourcecast, - session.enableStories, - session.enableExamMode, - session.moduleHelpText, - session.isOfficialCourse, - session.viewable - ]); + }, [session.courseName, session.courseShortName, session.enableAchievements, session.enableGame, session.enableSourcecast, session.enableStories, session.enableExamMode, session.moduleHelpText, session.isOfficialCourse, session.viewable, session.resumeCode]); const tableRef = useRef(null); useEffect(() => { diff --git a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx index 9d97b436d6..95e7380bb4 100644 --- a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx +++ b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx @@ -39,6 +39,7 @@ const CourseConfigPanel: React.FC = props => { enableSourcecast, enableStories, enableExamMode, + resumeCode, moduleHelpText, isOfficialCourse } = props.courseConfiguration; @@ -200,6 +201,23 @@ const CourseConfigPanel: React.FC = props => { } /> )} + + + props.setCourseConfiguration({ + ...props.courseConfiguration, + resumeCode: (e.target as HTMLInputElement).value + }) + } + /> + From 1f42ded6c67c974758d1112ca309b459b51e6ad6 Mon Sep 17 00:00:00 2001 From: kah-seng Date: Thu, 13 Mar 2025 23:21:02 +0800 Subject: [PATCH 15/61] Added validate resume code saga --- .../application/actions/SessionActions.ts | 3 ++- src/commons/sagas/BackendSaga.ts | 14 ++++++++++++-- src/commons/sagas/RequestsSaga.ts | 18 ++++++++++++++++++ src/pages/academy/adminPanel/AdminPanel.tsx | 17 ++++++++++++++++- .../subcomponents/CourseConfigPanel.tsx | 2 ++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index 1fc5590ce5..abf93180bb 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -149,7 +149,8 @@ const SessionActions = createActions('session', { deleteUserCourseRegistration: (courseRegId: number) => ({ courseRegId }), updateCourseResearchAgreement: (agreedToResearch: boolean) => ({ agreedToResearch }), updateStoriesUserRole: (userId: number, role: StoriesRole) => ({ userId, role }), - deleteStoriesUserUserGroups: (userId: number) => ({ userId }) + deleteStoriesUserUserGroups: (userId: number) => ({ userId }), + validateResumeCode: (resumeCode: string) => ({ resumeCode }) }); // For compatibility with existing code (actions helper) diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index da5f8d16c1..ab51a4f318 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -99,7 +99,8 @@ import { unpublishGrading, unpublishGradingAll, updateAssessment, - uploadAssessment + uploadAssessment, + validateResumeCode } from './RequestsSaga'; import { safeTakeEvery as takeEvery } from './SafeEffects'; @@ -601,7 +602,16 @@ const newBackendSagaOne = combineSagaHandlers(sagaActions, { yield put(actions.updateGradingOverviews({ count: totalPossibleEntries, data: newOverviews })); }, submitGrading: sendGrade, - submitGradingAndContinue: sendGradeAndContinue + submitGradingAndContinue: sendGradeAndContinue, + validateResumeCode: function* (action) { + const tokens: Tokens = yield selectTokens(); + const { resumeCode } = action.payload; + + // TODO: Implement + console.log('Validating code ', resumeCode); + const resumeCodeIsValid = yield call(validateResumeCode, tokens, resumeCode); + console.log('Resume code is valid ', resumeCodeIsValid); + } }); function* sendGrade( diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 29f88df173..9a387cf1a9 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -1396,6 +1396,24 @@ export const removeUserCourseRegistration = async ( return resp; }; +/** + * POST /courses/{course_Id}/resume_code + */ +export const validateResumeCode = async ( + tokens: Tokens, + resumeCode: string +): Promise => { + const resp = await request(`${courseId()}/resume_code`, 'POST', { + ...tokens, + body: { + 'resume_code': resumeCode, + } + }); + + console.log('Response ', resp); + return resp != null && resp.ok; +}; + /** * GET /devices */ diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index a48546d9ea..78117880dd 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -69,7 +69,19 @@ const AdminPanel: React.FC = () => { moduleHelpText: session.moduleHelpText, isOfficialCourse: session.isOfficialCourse }); - }, [session.courseName, session.courseShortName, session.enableAchievements, session.enableGame, session.enableSourcecast, session.enableStories, session.enableExamMode, session.moduleHelpText, session.isOfficialCourse, session.viewable, session.resumeCode]); + }, [ + session.courseName, + session.courseShortName, + session.enableAchievements, + session.enableGame, + session.enableSourcecast, + session.enableStories, + session.enableExamMode, + session.moduleHelpText, + session.isOfficialCourse, + session.viewable, + session.resumeCode + ]); const tableRef = useRef(null); useEffect(() => { @@ -96,6 +108,9 @@ const AdminPanel: React.FC = () => { // Handler to submit changes to Course Configration and Assessment Configuration to the backend. // Changes made to users are handled separately. const submitHandler = useCallback(() => { + // TODO: Implement + dispatch(SessionActions.validateResumeCode('123456789')); + if (hasChangesCourseConfig) { dispatch(SessionActions.updateCourseConfig(courseConfiguration)); setHasChangesCourseConfig(false); diff --git a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx index 95e7380bb4..8c9e6b10d7 100644 --- a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx +++ b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx @@ -201,6 +201,7 @@ const CourseConfigPanel: React.FC = props => { } /> )} + {enableExamMode && ( = props => { } /> + )} From 6cd6727685eb6028e9eef574d4f0aca4fbbca716 Mon Sep 17 00:00:00 2001 From: kah-seng Date: Fri, 14 Mar 2025 14:29:19 +0800 Subject: [PATCH 16/61] Added basic dev tools detection, pausing of source academy and resume code validation --- src/commons/application/Application.tsx | 64 ++++++++++++++++--- .../application/actions/SessionActions.ts | 6 +- src/commons/sagas/BackendSaga.ts | 6 +- src/commons/sagas/RequestsSaga.ts | 10 +-- src/pages/academy/adminPanel/AdminPanel.tsx | 3 - .../subcomponents/CourseConfigPanel.tsx | 34 +++++----- 6 files changed, 81 insertions(+), 42 deletions(-) diff --git a/src/commons/application/Application.tsx b/src/commons/application/Application.tsx index 31f2b666de..bf77b6d1b4 100644 --- a/src/commons/application/Application.tsx +++ b/src/commons/application/Application.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useDispatch } from 'react-redux'; import { Outlet } from 'react-router-dom'; import Messages, { @@ -8,6 +8,7 @@ import Messages, { } from 'src/features/vscode/messages'; import NavigationBar from '../navigationBar/NavigationBar'; +import { PauseAcademyOverlay } from '../pauseAcademyOverlay/PauseAcademyOverlay'; import Constants from '../utils/Constants'; import { useLocalStorageState, useSession } from '../utils/Hooks'; import WorkspaceActions from '../workspace/WorkspaceActions'; @@ -17,7 +18,7 @@ import VscodeActions from './actions/VscodeActions'; const Application: React.FC = () => { const dispatch = useDispatch(); - const { isLoggedIn } = useSession(); + const { isLoggedIn, enableExamMode } = useSession(); // Used in the mobile/PWA experience (e.g. separate handling of orientation changes on Andriod & iOS due to unique browser behaviours) const isMobile = /iPhone|iPad|Android/.test(navigator.userAgent); @@ -29,6 +30,10 @@ const Application: React.FC = () => { defaultWorkspaceSettings ); + // Used for dev tools detection + const [pauseAcademy, setPauseAcademy] = useState(false); + const [pauseAcademyReason, setPauseAcademyReason] = useState(''); + // Effect to fetch the latest user info and course configurations from the backend on refresh, // if the user was previously logged in React.useEffect(() => { @@ -133,15 +138,54 @@ const Application: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Effect for dev tools blocking/detection when exam mode enabled + React.useEffect(() => { + const detectDevTools = () => { + const startTimestamp = Date.now(); + debugger; + if (Date.now() - startTimestamp > 200) { + setPauseAcademy(true); + setPauseAcademyReason('Dev tools detected'); + } + }; + + if (enableExamMode) { + document.addEventListener('contextmenu', event => event.preventDefault()); + document.addEventListener('keydown', event => { + if ( + event.key == 'F12' || + ((event.key == 'I' || event.key == 'J') && event.ctrlKey && event.shiftKey) + ) { + event.preventDefault(); + } + }); + setInterval(detectDevTools, 500); + } + }, [enableExamMode]); + + const resumeCodeSubmitHandler = (resumeCode: string) => { + if (resumeCode == '') { + alert('Resume code cannot be empty'); + } else { + dispatch(SessionActions.validateResumeCode(resumeCode, setPauseAcademy)); + } + }; + return ( - -
- -
- -
-
-
+ <> + {pauseAcademy ? ( + + ) : ( + +
+ +
+ +
+
+
+ )} + ); }; diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index abf93180bb..405f7d8940 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -1,3 +1,4 @@ +import { Dispatch, SetStateAction } from 'react'; import { createActions } from 'src/commons/redux/utils'; import { paginationToBackendParams, @@ -150,7 +151,10 @@ const SessionActions = createActions('session', { updateCourseResearchAgreement: (agreedToResearch: boolean) => ({ agreedToResearch }), updateStoriesUserRole: (userId: number, role: StoriesRole) => ({ userId, role }), deleteStoriesUserUserGroups: (userId: number) => ({ userId }), - validateResumeCode: (resumeCode: string) => ({ resumeCode }) + validateResumeCode: (resumeCode: string, setPauseAcademy: Dispatch>) => ({ + resumeCode, + setPauseAcademy + }) }); // For compatibility with existing code (actions helper) diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index ab51a4f318..819e6bc013 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -605,12 +605,10 @@ const newBackendSagaOne = combineSagaHandlers(sagaActions, { submitGradingAndContinue: sendGradeAndContinue, validateResumeCode: function* (action) { const tokens: Tokens = yield selectTokens(); - const { resumeCode } = action.payload; + const { resumeCode, setPauseAcademy } = action.payload; - // TODO: Implement - console.log('Validating code ', resumeCode); const resumeCodeIsValid = yield call(validateResumeCode, tokens, resumeCode); - console.log('Resume code is valid ', resumeCodeIsValid); + setPauseAcademy(!resumeCodeIsValid); } }); diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 9a387cf1a9..889085a7a6 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -1399,18 +1399,14 @@ export const removeUserCourseRegistration = async ( /** * POST /courses/{course_Id}/resume_code */ -export const validateResumeCode = async ( - tokens: Tokens, - resumeCode: string -): Promise => { +export const validateResumeCode = async (tokens: Tokens, resumeCode: string): Promise => { const resp = await request(`${courseId()}/resume_code`, 'POST', { ...tokens, - body: { - 'resume_code': resumeCode, + body: { + resume_code: resumeCode } }); - console.log('Response ', resp); return resp != null && resp.ok; }; diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index 78117880dd..6e50265eb3 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -108,9 +108,6 @@ const AdminPanel: React.FC = () => { // Handler to submit changes to Course Configration and Assessment Configuration to the backend. // Changes made to users are handled separately. const submitHandler = useCallback(() => { - // TODO: Implement - dispatch(SessionActions.validateResumeCode('123456789')); - if (hasChangesCourseConfig) { dispatch(SessionActions.updateCourseConfig(courseConfiguration)); setHasChangesCourseConfig(false); diff --git a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx index 8c9e6b10d7..1611c37d50 100644 --- a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx +++ b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx @@ -202,23 +202,23 @@ const CourseConfigPanel: React.FC = props => { /> )} {enableExamMode && ( - - - props.setCourseConfiguration({ - ...props.courseConfiguration, - resumeCode: (e.target as HTMLInputElement).value - }) - } - /> - + + + props.setCourseConfiguration({ + ...props.courseConfiguration, + resumeCode: (e.target as HTMLInputElement).value + }) + } + /> + )} From b72a22bc6d77e0ac103265d3edceeae3576d1575 Mon Sep 17 00:00:00 2001 From: kah-seng Date: Fri, 14 Mar 2025 14:41:46 +0800 Subject: [PATCH 17/61] Added resume code input validation for admin panel --- src/pages/academy/adminPanel/AdminPanel.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index 6e50265eb3..507450563a 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -109,8 +109,12 @@ const AdminPanel: React.FC = () => { // Changes made to users are handled separately. const submitHandler = useCallback(() => { if (hasChangesCourseConfig) { - dispatch(SessionActions.updateCourseConfig(courseConfiguration)); - setHasChangesCourseConfig(false); + if (courseConfiguration.enableExamMode && courseConfiguration.resumeCode == '') { + alert('Resume code cannot be empty when exam mode is enabled'); + } else { + dispatch(SessionActions.updateCourseConfig(courseConfiguration)); + setHasChangesCourseConfig(false); + } } const tableState = tableRef.current?.getData() ?? []; const currentConfigs = session.assessmentConfigurations ?? []; From 9f5e3a20ed4702917af930c50c7ad3188ccc9858 Mon Sep 17 00:00:00 2001 From: kah-seng Date: Fri, 14 Mar 2025 16:02:26 +0800 Subject: [PATCH 18/61] Restore pause overlay --- .../PauseAcademyOverlay.tsx | 34 +++++++++++++++++++ src/styles/PauseAcademyOverlay.module.scss | 28 +++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/commons/pauseAcademyOverlay/PauseAcademyOverlay.tsx create mode 100644 src/styles/PauseAcademyOverlay.module.scss diff --git a/src/commons/pauseAcademyOverlay/PauseAcademyOverlay.tsx b/src/commons/pauseAcademyOverlay/PauseAcademyOverlay.tsx new file mode 100644 index 0000000000..4809751e5c --- /dev/null +++ b/src/commons/pauseAcademyOverlay/PauseAcademyOverlay.tsx @@ -0,0 +1,34 @@ +import { Button, FormGroup, InputGroup } from '@blueprintjs/core'; +import { useState } from 'react'; + +import classes from '../../styles/PauseAcademyOverlay.module.scss'; + +type PauseAcademyOverlayProps = { + reason?: string; + onSubmit: (resumeCode: string) => void; +}; + +export const PauseAcademyOverlay: React.FC = props => { + const [resumeCode, setResumeCode] = useState(''); + + return ( +
+

Source Academy Paused

+ {props.reason &&

Reason: {props.reason}

} +

Please inform any of the invigilators

+ + setResumeCode((e.target as HTMLInputElement).value)} + /> + +
+ ); +}; diff --git a/src/styles/PauseAcademyOverlay.module.scss b/src/styles/PauseAcademyOverlay.module.scss new file mode 100644 index 0000000000..82889553f0 --- /dev/null +++ b/src/styles/PauseAcademyOverlay.module.scss @@ -0,0 +1,28 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 100vw; + background-color: gray; + opacity: 80%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.title-label { + font-size: 5vh; + font-weight: bold; + text-decoration: underline; +} + +.reason-label { + font-size: 3vh; + font-weight: bold; +} + +.info-label { + font-size: 3vh; +} From feacd381cbaa70869a8fb94f030e3916385cc517 Mon Sep 17 00:00:00 2001 From: kah-seng Date: Fri, 14 Mar 2025 16:30:07 +0800 Subject: [PATCH 19/61] Fixed resume code input validation --- src/commons/application/Application.tsx | 2 +- src/pages/academy/adminPanel/AdminPanel.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commons/application/Application.tsx b/src/commons/application/Application.tsx index bf77b6d1b4..698ef8a327 100644 --- a/src/commons/application/Application.tsx +++ b/src/commons/application/Application.tsx @@ -164,7 +164,7 @@ const Application: React.FC = () => { }, [enableExamMode]); const resumeCodeSubmitHandler = (resumeCode: string) => { - if (resumeCode == '') { + if (!resumeCode || resumeCode.length === 0) { alert('Resume code cannot be empty'); } else { dispatch(SessionActions.validateResumeCode(resumeCode, setPauseAcademy)); diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index 507450563a..b74753562a 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -109,7 +109,7 @@ const AdminPanel: React.FC = () => { // Changes made to users are handled separately. const submitHandler = useCallback(() => { if (hasChangesCourseConfig) { - if (courseConfiguration.enableExamMode && courseConfiguration.resumeCode == '') { + if (courseConfiguration.enableExamMode && (!courseConfiguration.resumeCode || courseConfiguration.resumeCode.length === 0)) { alert('Resume code cannot be empty when exam mode is enabled'); } else { dispatch(SessionActions.updateCourseConfig(courseConfiguration)); From cc13931c01528c80cf7e63b30d246064ae59aa51 Mon Sep 17 00:00:00 2001 From: kah-seng Date: Fri, 14 Mar 2025 16:37:24 +0800 Subject: [PATCH 20/61] Added backend saga tests --- src/commons/application/Application.tsx | 16 +++++++--------- src/commons/sagas/__tests__/BackendSaga.ts | 12 ++++++++---- src/pages/academy/adminPanel/AdminPanel.tsx | 5 ++++- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/commons/application/Application.tsx b/src/commons/application/Application.tsx index 698ef8a327..b48421dd16 100644 --- a/src/commons/application/Application.tsx +++ b/src/commons/application/Application.tsx @@ -172,20 +172,18 @@ const Application: React.FC = () => { }; return ( - <> + {pauseAcademy ? ( ) : ( - -
- -
- -
+
+ +
+
- +
)} - + ); }; diff --git a/src/commons/sagas/__tests__/BackendSaga.ts b/src/commons/sagas/__tests__/BackendSaga.ts index 4d502349af..b94d5e0c06 100644 --- a/src/commons/sagas/__tests__/BackendSaga.ts +++ b/src/commons/sagas/__tests__/BackendSaga.ts @@ -138,7 +138,8 @@ const mockCourseConfiguration1: CourseConfiguration = { sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help text', assetsPrefix: '', - isOfficialCourse: false + isOfficialCourse: false, + resumeCode: '' }; const mockCourseRegistration2: CourseRegistration = { @@ -171,7 +172,8 @@ const mockCourseConfiguration2: CourseConfiguration = { sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help text', assetsPrefix: '', - isOfficialCourse: false + isOfficialCourse: false, + resumeCode: '' }; const mockAssessmentConfigurations: AssessmentConfiguration[] = [ @@ -939,7 +941,8 @@ describe('Test UPDATE_COURSE_CONFIG action', () => { sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help', assetsPrefix: '', - isOfficialCourse: false + isOfficialCourse: false, + resumeCode: '' }; test('when course config is changed', () => { @@ -1038,7 +1041,8 @@ describe('Test CREATE_COURSE action', () => { sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help Text', - isOfficialCourse: false + isOfficialCourse: false, + resumeCode: '' }; const user = mockUser; const courseConfiguration = mockCourseConfiguration1; diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index b74753562a..8b02bb1270 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -109,7 +109,10 @@ const AdminPanel: React.FC = () => { // Changes made to users are handled separately. const submitHandler = useCallback(() => { if (hasChangesCourseConfig) { - if (courseConfiguration.enableExamMode && (!courseConfiguration.resumeCode || courseConfiguration.resumeCode.length === 0)) { + if ( + courseConfiguration.enableExamMode && + (!courseConfiguration.resumeCode || courseConfiguration.resumeCode.length === 0) + ) { alert('Resume code cannot be empty when exam mode is enabled'); } else { dispatch(SessionActions.updateCourseConfig(courseConfiguration)); From 23415c9350cdf6c8e24f3af0cd9d5c3de80f22ff Mon Sep 17 00:00:00 2001 From: Izumi Kyouka Date: Wed, 19 Mar 2025 01:07:31 +0800 Subject: [PATCH 21/61] added disable-devtool library; configured PauseAcademyOverlay to work with the module --- package.json | 1 + src/commons/application/Application.tsx | 22 ++++++++++++------- src/commons/application/ApplicationTypes.ts | 5 +++-- .../application/actions/SessionActions.ts | 3 ++- src/commons/application/types/SessionTypes.ts | 2 ++ src/commons/mocks/UserMocks.ts | 18 +++++++++++++++ src/commons/sagas/BackendSaga.ts | 7 +++++- src/commons/sagas/RequestsSaga.ts | 8 +++++++ src/styles/PauseAcademyOverlay.module.scss | 2 +- yarn.lock | 8 +++++++ 10 files changed, 63 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index c3684f33f0..73357a5757 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "classnames": "^2.3.2", "conductor": "https://github.com/source-academy/conductor.git#0.2.1", "dayjs": "^1.11.13", + "disable-devtool": "^0.3.8", "dompurify": "^3.2.4", "flexboxgrid": "^6.3.1", "flexboxgrid-helpers": "^1.1.3", diff --git a/src/commons/application/Application.tsx b/src/commons/application/Application.tsx index b48421dd16..e312dda184 100644 --- a/src/commons/application/Application.tsx +++ b/src/commons/application/Application.tsx @@ -1,3 +1,4 @@ +import disableDevtool from 'disable-devtool'; import React, { useState } from 'react'; import { useDispatch } from 'react-redux'; import { Outlet } from 'react-router-dom'; @@ -18,7 +19,7 @@ import VscodeActions from './actions/VscodeActions'; const Application: React.FC = () => { const dispatch = useDispatch(); - const { isLoggedIn, enableExamMode } = useSession(); + const { isLoggedIn, isPaused, enableExamMode } = useSession(); // Used in the mobile/PWA experience (e.g. separate handling of orientation changes on Andriod & iOS due to unique browser behaviours) const isMobile = /iPhone|iPad|Android/.test(navigator.userAgent); @@ -140,16 +141,22 @@ const Application: React.FC = () => { // Effect for dev tools blocking/detection when exam mode enabled React.useEffect(() => { - const detectDevTools = () => { - const startTimestamp = Date.now(); - debugger; - if (Date.now() - startTimestamp > 200) { + if (isPaused !== undefined && isPaused) { + setPauseAcademy(true); + setPauseAcademyReason('Developer tools was previously used.'); + } + + const options = { + ondevtoolopen: () => { setPauseAcademy(true); - setPauseAcademyReason('Dev tools detected'); - } + setPauseAcademyReason("Developer tools has been used"); + dispatch(SessionActions.pauseUser()); + }, + clearIntervalWhenDevOpenTrigger: true, }; if (enableExamMode) { + disableDevtool(options); document.addEventListener('contextmenu', event => event.preventDefault()); document.addEventListener('keydown', event => { if ( @@ -159,7 +166,6 @@ const Application: React.FC = () => { event.preventDefault(); } }); - setInterval(detectDevTools, 500); } }, [enableExamMode]); diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index fbc2728d20..6fe847e638 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -21,7 +21,7 @@ import { import { RouterState } from './types/CommonsTypes'; import { ExternalLibraryName } from './types/ExternalTypes'; import { SessionState } from './types/SessionTypes'; -import { VscodeState as VscodeState } from './types/VscodeTypes'; +import { VscodeState } from './types/VscodeTypes'; export type OverallState = { readonly router: RouterState; @@ -554,7 +554,8 @@ export const defaultSession: SessionState = { students: undefined, teamFormationOverviews: undefined, gradings: {}, - notifications: [] + notifications: [], + isPaused: undefined }; export const defaultStories: StoriesState = { diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index 405f7d8940..a39e0b0eba 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -154,7 +154,8 @@ const SessionActions = createActions('session', { validateResumeCode: (resumeCode: string, setPauseAcademy: Dispatch>) => ({ resumeCode, setPauseAcademy - }) + }), + pauseUser: () => {} }); // For compatibility with existing code (actions helper) diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index 3e339c547d..4abcd221a0 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -21,6 +21,7 @@ export type SessionState = { readonly userId?: number; readonly name?: string; readonly courses: UserCourse[]; + readonly isPaused?: boolean; // Course Registration readonly courseRegId?: number; @@ -87,6 +88,7 @@ export type User = { name: string; username: string; courses: UserCourse[]; + isPaused: boolean; }; export type CourseRegistration = { diff --git a/src/commons/mocks/UserMocks.ts b/src/commons/mocks/UserMocks.ts index d9bab20a1b..9dddf1e49b 100644 --- a/src/commons/mocks/UserMocks.ts +++ b/src/commons/mocks/UserMocks.ts @@ -45,6 +45,7 @@ export const mockUser: User = { userId: 123, name: 'DevAdmin', username: 'DevAdmin', + isPaused: false, courses: [ { courseId: 1, @@ -75,6 +76,7 @@ export const mockStudents: User[] = [ userId: 101, name: 'Papito Sakolomoto', username: 'Papito Sakolomoto', + isPaused: false, courses: [ { courseId: 1, @@ -89,6 +91,7 @@ export const mockStudents: User[] = [ userId: 102, name: 'Carina Heng Xin Ting', username: 'Carina Heng Xin Ting', + isPaused: false, courses: [ { courseId: 2, @@ -103,6 +106,7 @@ export const mockStudents: User[] = [ userId: 103, name: 'Valentino Gusion', username: 'Valentino Gusion', + isPaused: false, courses: [ { courseId: 3, @@ -117,6 +121,7 @@ export const mockStudents: User[] = [ userId: 104, name: 'Ixia Arlot Rambutan', username: 'Ixia Arlot Rambutan', + isPaused: false, courses: [ { courseId: 4, @@ -131,6 +136,7 @@ export const mockStudents: User[] = [ userId: 105, name: 'Ariel Shockatia Ligament', username: 'Ariel Shockatia Ligament', + isPaused: false, courses: [ { courseId: 5, @@ -145,6 +151,7 @@ export const mockStudents: User[] = [ userId: 106, name: 'Lolita Sim', username: 'Lolita Sim', + isPaused: false, courses: [ { courseId: 5, @@ -159,6 +166,7 @@ export const mockStudents: User[] = [ userId: 107, name: 'Lim Jun Ming', username: 'Lim Jun Ming', + isPaused: false, courses: [ { courseId: 5, @@ -173,6 +181,7 @@ export const mockStudents: User[] = [ userId: 108, name: 'Tobias Gray', username: 'Tobias Gray', + isPaused: false, courses: [ { courseId: 5, @@ -187,6 +196,7 @@ export const mockStudents: User[] = [ userId: 109, name: 'Lenard Toh See Ming', username: 'Lenard Toh See Ming', + isPaused: false, courses: [ { courseId: 5, @@ -201,6 +211,7 @@ export const mockStudents: User[] = [ userId: 110, name: 'Richard Gray', username: 'Richard Gray', + isPaused: false, courses: [ { courseId: 5, @@ -215,6 +226,7 @@ export const mockStudents: User[] = [ userId: 111, name: 'Benedict Lim', username: 'Benedict Lim', + isPaused: false, courses: [ { courseId: 5, @@ -229,6 +241,7 @@ export const mockStudents: User[] = [ userId: 112, name: 'Harshvathini Tharman', username: 'Harshvathini Tharman', + isPaused: false, courses: [ { courseId: 5, @@ -243,6 +256,7 @@ export const mockStudents: User[] = [ userId: 113, name: 'James Cook', username: 'James Cook', + isPaused: false, courses: [ { courseId: 5, @@ -257,6 +271,7 @@ export const mockStudents: User[] = [ userId: 114, name: 'Mike Chang', username: 'Mike Chang', + isPaused: false, courses: [ { courseId: 5, @@ -271,6 +286,7 @@ export const mockStudents: User[] = [ userId: 115, name: 'Giyu Tomioka', username: 'Giyu Tomioka', + isPaused: false, courses: [ { courseId: 5, @@ -285,6 +301,7 @@ export const mockStudents: User[] = [ userId: 116, name: 'Oliver Sandy', username: 'Oliver Sandy', + isPaused: false, courses: [ { courseId: 5, @@ -299,6 +316,7 @@ export const mockStudents: User[] = [ userId: 117, name: 'Muthu Valakrishnan', username: 'Muthu Valakrishnan', + isPaused: false, courses: [ { courseId: 5, diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index 819e6bc013..1279cf2942 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -73,6 +73,7 @@ import { getUser, getUserCourseRegistrations, handleResponseError, + pauseUser, postAcknowledgeNotifications, postAnswer, postAssessment, @@ -100,7 +101,7 @@ import { unpublishGradingAll, updateAssessment, uploadAssessment, - validateResumeCode + validateResumeCode, } from './RequestsSaga'; import { safeTakeEvery as takeEvery } from './SafeEffects'; @@ -609,6 +610,10 @@ const newBackendSagaOne = combineSagaHandlers(sagaActions, { const resumeCodeIsValid = yield call(validateResumeCode, tokens, resumeCode); setPauseAcademy(!resumeCodeIsValid); + }, + pauseUser: function* () { + const tokens: Tokens = yield selectTokens(); + yield call(pauseUser, tokens); } }); diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 889085a7a6..43caf4c02f 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -1410,6 +1410,14 @@ export const validateResumeCode = async (tokens: Tokens, resumeCode: string): Pr return resp != null && resp.ok; }; +export const pauseUser = async (tokens: Tokens): Promise => { + const resp = await request(`${courseId()}/user/pause`, 'PUT', { + ...tokens, + }); + + return resp != null && resp.ok; +} + /** * GET /devices */ diff --git a/src/styles/PauseAcademyOverlay.module.scss b/src/styles/PauseAcademyOverlay.module.scss index 82889553f0..3090bc3b9c 100644 --- a/src/styles/PauseAcademyOverlay.module.scss +++ b/src/styles/PauseAcademyOverlay.module.scss @@ -5,7 +5,7 @@ height: 100vh; width: 100vw; background-color: gray; - opacity: 80%; + opacity: 90%; display: flex; flex-direction: column; align-items: center; diff --git a/yarn.lock b/yarn.lock index baa41cc656..3454628792 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7862,6 +7862,13 @@ __metadata: languageName: node linkType: hard +"disable-devtool@npm:^0.3.8": + version: 0.3.8 + resolution: "disable-devtool@npm:0.3.8" + checksum: 10c0/7d09bffb4f0b4cbd0d667d8c7536c6ab3e2b196d1924d24663af31f011a136c0eefd01e0f9e035470d725bf11a9f214928e7b8222be35a33983180bbb450b506 + languageName: node + linkType: hard + "dlv@npm:^1.1.3": version: 1.1.3 resolution: "dlv@npm:1.1.3" @@ -9533,6 +9540,7 @@ __metadata: coveralls: "npm:^3.1.1" cross-env: "npm:^7.0.3" dayjs: "npm:^1.11.13" + disable-devtool: "npm:^0.3.8" dompurify: "npm:^3.2.4" eslint: "npm:^9.9.0" eslint-plugin-react: "npm:^7.35.0" From 0c6833190632b47b50257649e7f0bb5db04a61a7 Mon Sep 17 00:00:00 2001 From: Izumi Kyouka Date: Thu, 20 Mar 2025 21:51:12 +0800 Subject: [PATCH 22/61] fixed repeated request to pause user by using useRef --- src/commons/application/Application.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/commons/application/Application.tsx b/src/commons/application/Application.tsx index e312dda184..90fbbf280e 100644 --- a/src/commons/application/Application.tsx +++ b/src/commons/application/Application.tsx @@ -34,6 +34,7 @@ const Application: React.FC = () => { // Used for dev tools detection const [pauseAcademy, setPauseAcademy] = useState(false); const [pauseAcademyReason, setPauseAcademyReason] = useState(''); + const hasSentPauseUserRequest = React.useRef(false); // Effect to fetch the latest user info and course configurations from the backend on refresh, // if the user was previously logged in @@ -150,9 +151,14 @@ const Application: React.FC = () => { ondevtoolopen: () => { setPauseAcademy(true); setPauseAcademyReason("Developer tools has been used"); - dispatch(SessionActions.pauseUser()); + if (hasSentPauseUserRequest.current === false) { + dispatch(SessionActions.pauseUser()); + hasSentPauseUserRequest.current = true; + } }, - clearIntervalWhenDevOpenTrigger: true, + ondevtoolclose: () => { + hasSentPauseUserRequest.current = false; + } }; if (enableExamMode) { @@ -167,7 +173,7 @@ const Application: React.FC = () => { } }); } - }, [enableExamMode]); + }, [dispatch, enableExamMode, isPaused, hasSentPauseUserRequest]); const resumeCodeSubmitHandler = (resumeCode: string) => { if (!resumeCode || resumeCode.length === 0) { From ad49ef5fcb53a7a40ed79271c07a94c06dfc9614 Mon Sep 17 00:00:00 2001 From: kah-seng Date: Mon, 24 Mar 2025 22:20:09 +0800 Subject: [PATCH 23/61] Added documentation tab and merge block_dev_tools_library branch --- src/commons/application/Application.tsx | 2 +- .../AssessmentWorkspace.tsx | 10 ++++ src/commons/sagas/BackendSaga.ts | 2 +- src/commons/sagas/RequestsSaga.ts | 4 +- .../content/SideContentDocumentation.tsx | 50 +++++++++++++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 src/commons/sideContent/content/SideContentDocumentation.tsx diff --git a/src/commons/application/Application.tsx b/src/commons/application/Application.tsx index 90fbbf280e..8cdabbc1e8 100644 --- a/src/commons/application/Application.tsx +++ b/src/commons/application/Application.tsx @@ -150,7 +150,7 @@ const Application: React.FC = () => { const options = { ondevtoolopen: () => { setPauseAcademy(true); - setPauseAcademyReason("Developer tools has been used"); + setPauseAcademyReason('Developer tools has been used'); if (hasSentPauseUserRequest.current === false) { dispatch(SessionActions.pauseUser()); hasSentPauseUserRequest.current = true; diff --git a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx index cc3723a158..d15dceaa74 100644 --- a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx +++ b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx @@ -64,6 +64,7 @@ import MobileWorkspace, { MobileWorkspaceProps } from '../mobileWorkspace/Mobile import SideContentAutograder from '../sideContent/content/SideContentAutograder'; import SideContentContestLeaderboard from '../sideContent/content/SideContentContestLeaderboard'; import SideContentContestVotingContainer from '../sideContent/content/SideContentContestVotingContainer'; +import SideContentDocumentation from '../sideContent/content/SideContentDocumentation'; import SideContentToneMatrix from '../sideContent/content/SideContentToneMatrix'; import { SideContentProps } from '../sideContent/SideContent'; import { changeSideContentHeight } from '../sideContent/SideContentActions'; @@ -412,6 +413,7 @@ const AssessmentWorkspace: React.FC = props => { const isTeamAssessment = assessmentOverview !== undefined ? assessmentOverview.maxTeamSize > 1 : false; const isContestVoting = question?.type === QuestionTypes.voting; + const isPrivate = assessmentOverview !== undefined ? assessmentOverview.private : false; const handleContestEntryClick = (_submissionId: number, answer: string) => { // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. handleEditorValueChange(0, answer); @@ -557,6 +559,14 @@ const AssessmentWorkspace: React.FC = props => { }); } + if (isPrivate) { + tabs.push({ + label: `Documentation`, + iconName: IconNames.BOOK, + body: + }); + } + const onChangeTabs = ( newTabId: SideContentType, prevTabId: SideContentType, diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index 1279cf2942..4b0b49ca14 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -101,7 +101,7 @@ import { unpublishGradingAll, updateAssessment, uploadAssessment, - validateResumeCode, + validateResumeCode } from './RequestsSaga'; import { safeTakeEvery as takeEvery } from './SafeEffects'; diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 43caf4c02f..9465b5050f 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -1412,11 +1412,11 @@ export const validateResumeCode = async (tokens: Tokens, resumeCode: string): Pr export const pauseUser = async (tokens: Tokens): Promise => { const resp = await request(`${courseId()}/user/pause`, 'PUT', { - ...tokens, + ...tokens }); return resp != null && resp.ok; -} +}; /** * GET /devices diff --git a/src/commons/sideContent/content/SideContentDocumentation.tsx b/src/commons/sideContent/content/SideContentDocumentation.tsx new file mode 100644 index 0000000000..f8eddea116 --- /dev/null +++ b/src/commons/sideContent/content/SideContentDocumentation.tsx @@ -0,0 +1,50 @@ +import { Button } from '@blueprintjs/core'; +import { useState } from 'react'; + +const SideContentDocumentation: React.FC = () => { + const pages = [ + { + name: 'Modules', + src: 'https://source-academy.github.io/modules/documentation/modules/curve.html' + }, + { + name: 'Docs', + src: 'https://docs.sourceacademy.org/' + }, + { + name: 'SICP JS', + src: 'https://sourceacademy.org/sicpjs/' + } + ]; + + const [activePage, setActivePage] = useState(pages[0]); + + const changeActivePage = (index: number) => { + setActivePage(pages[index]); + }; + + return ( +
+
+ {pages.map((page, index) => ( +
+