From 317d0051fe46d0697e786ec1c4a9ac7b48c4515f Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 14 Mar 2024 18:47:39 +0800 Subject: [PATCH 1/8] Update .env example file --- .env.example | 1 - 1 file changed, 1 deletion(-) diff --git a/.env.example b/.env.example index 75bf6f14d6..e6c7943d7e 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,6 @@ REACT_APP_DEPLOYMENT_NAME=Source Academy REACT_APP_BACKEND_URL=http://localhost:4000 REACT_APP_USE_BACKEND=TRUE REACT_APP_PLAYGROUND_ONLY=FALSE -REACT_APP_ENABLE_GITHUB_ASSESSMENTS=TRUE REACT_APP_SHOW_RESEARCH_PROMPT=FALSE REACT_APP_URL_SHORTENER_SIGNATURE= From 2a7f9f2a7daf7ec394b8e0331eb884c947885340 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 14 Mar 2024 18:47:57 +0800 Subject: [PATCH 2/8] Update workflows, README --- .github/workflows/build-development.yml | 1 - README.md | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/build-development.yml b/.github/workflows/build-development.yml index 18fbbf21de..05ddb1ed45 100644 --- a/.github/workflows/build-development.yml +++ b/.github/workflows/build-development.yml @@ -38,7 +38,6 @@ jobs: REACT_APP_GOOGLE_API_KEY: ${{ secrets.REACT_APP_GOOGLE_API_KEY }} REACT_APP_GOOGLE_APP_ID: ${{ secrets.REACT_APP_GOOGLE_APP_ID }} REACT_APP_PLAYGROUND_ONLY: 'TRUE' - REACT_APP_ENABLE_GITHUB_ASSESSMENTS: 'TRUE' REACT_APP_VERSION: ${{ format('{0}-{1}', github.sha, steps.get-time.outputs.time) }} REACT_APP_ENVIRONMENT: 'pages' REACT_APP_MODULE_BACKEND_URL: https://source-academy.github.io/modules diff --git a/README.md b/README.md index 226cd9a10f..352cb66f6a 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,6 @@ See [here](https://github.com/source-academy/frontend/wiki/Google-Drive-Persiste 1. `REACT_APP_DEPLOYMENT_NAME`: The name of the Source Academy deployment. This will be shown in the `/welcome` route. Defaults to 'Source Academy'. 1. `REACT_APP_PLAYGROUND_ONLY`: Whether to build the "playground-only" version, which disables the Academy components, so only the Playground is available. This is what we deploy onto [GitHub Pages](https://source-academy.github.io). -1. `REACT_APP_ENABLE_GITHUB_ASSESSMENTS`: Whether to enable the GitHub Assessments feature. Off by default. 1. `REACT_APP_SHOW_RESEARCH_PROMPT`: Whether to show the educational research consent prompt to users. This is mainly for instructors using their own deployment of Source Academy @ NUS to disable this prompt. ## Projects From 6bf1f1be5f979ef22f46c76013984a6e59861160 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 14 Mar 2024 18:59:26 +0800 Subject: [PATCH 3/8] Remove most GitHub Assessments-related files * Remove components * Remove pages * Remove routes * Remove snapshots * Remove tests * Miscellaneous changes to other files --- src/commons/application/ApplicationTypes.ts | 4 +- src/commons/application/types/SessionTypes.ts | 2 - .../github/ControlBarDisplayMCQButton.tsx | 22 - .../github/ControlBarGitHubLoginButton.tsx | 30 - .../github/ControlBarTaskAddButton.tsx | 26 - .../github/ControlBarTaskDeleteButton.tsx | 41 - .../GitHubMissionCreateDialog.tsx | 77 -- .../GitHubMissionDataUtils.ts | 512 -------- .../GitHubMissionMobileLoginButton.tsx | 32 - .../GitHubMissionSaveDialog.tsx | 82 -- .../githubAssessments/GitHubMissionTypes.ts | 46 - .../__tests__/GitHubMissionCreateDialog.tsx | 85 -- .../__tests__/GitHubMissionDataUtils.ts | 903 ------------- .../__tests__/GitHubMissionSaveDialog.tsx | 87 -- src/commons/navigationBar/NavigationBar.tsx | 14 - .../GitHubAssessmentsNavigationBar.tsx | 114 -- .../GitHubAssessmentsNavigationBar.tsx | 21 - .../SideContentMissionEditor.tsx | 49 - src/commons/utils/Constants.ts | 2 - .../GitHubAssessmentDefaultValues.ts | 100 -- .../GitHubAssessmentListing.tsx | 163 --- .../GitHubAssessmentWorkspace.tsx | 1164 ----------------- .../githubAssessments/GitHubClassroom.tsx | 322 ----- .../GitHubClassroomWelcome.tsx | 32 - .../__tests__/GitHubClassroom.tsx | 122 -- .../__snapshots__/GitHubClassroom.tsx.snap | 69 - src/routes/routerConfig.tsx | 19 +- 27 files changed, 7 insertions(+), 4133 deletions(-) delete mode 100644 src/commons/controlBar/github/ControlBarDisplayMCQButton.tsx delete mode 100644 src/commons/controlBar/github/ControlBarGitHubLoginButton.tsx delete mode 100644 src/commons/controlBar/github/ControlBarTaskAddButton.tsx delete mode 100644 src/commons/controlBar/github/ControlBarTaskDeleteButton.tsx delete mode 100644 src/commons/githubAssessments/GitHubMissionCreateDialog.tsx delete mode 100644 src/commons/githubAssessments/GitHubMissionDataUtils.ts delete mode 100644 src/commons/githubAssessments/GitHubMissionMobileLoginButton.tsx delete mode 100644 src/commons/githubAssessments/GitHubMissionSaveDialog.tsx delete mode 100644 src/commons/githubAssessments/GitHubMissionTypes.ts delete mode 100644 src/commons/githubAssessments/__tests__/GitHubMissionCreateDialog.tsx delete mode 100644 src/commons/githubAssessments/__tests__/GitHubMissionDataUtils.ts delete mode 100644 src/commons/githubAssessments/__tests__/GitHubMissionSaveDialog.tsx delete mode 100644 src/commons/navigationBar/subcomponents/GitHubAssessmentsNavigationBar.tsx delete mode 100644 src/commons/navigationBar/subcomponents/__tests__/GitHubAssessmentsNavigationBar.tsx delete mode 100644 src/commons/sideContent/content/githubAssessments/SideContentMissionEditor.tsx delete mode 100644 src/pages/githubAssessments/GitHubAssessmentDefaultValues.ts delete mode 100644 src/pages/githubAssessments/GitHubAssessmentListing.tsx delete mode 100644 src/pages/githubAssessments/GitHubAssessmentWorkspace.tsx delete mode 100644 src/pages/githubAssessments/GitHubClassroom.tsx delete mode 100644 src/pages/githubAssessments/GitHubClassroomWelcome.tsx delete mode 100644 src/pages/githubAssessments/__tests__/GitHubClassroom.tsx delete mode 100644 src/pages/githubAssessments/__tests__/__snapshots__/GitHubClassroom.tsx.snap diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index a239f0bcba..4cc24a7a3a 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -356,9 +356,7 @@ export const createDefaultWorkspace = (workspaceLocation: WorkspaceLocation): Wo filePath: ['playground', 'sicp'].includes(workspaceLocation) ? getDefaultFilePath(workspaceLocation) : undefined, - value: ['playground', 'sourcecast', 'githubAssessments'].includes(workspaceLocation) - ? defaultEditorValue - : '', + value: ['playground', 'sourcecast'].includes(workspaceLocation) ? defaultEditorValue : '', highlightedLines: [], breakpoints: [] } diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index 27c9af3e63..ac5640e725 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -8,7 +8,6 @@ import { AssessmentConfiguration, AssessmentOverview } from '../../assessment/AssessmentTypes'; -import { MissionRepoData } from '../../githubAssessments/GitHubMissionTypes'; import { Notification } from '../../notificationBadge/NotificationBadgeTypes'; import { GameState, Role, Story } from '../ApplicationTypes'; @@ -118,7 +117,6 @@ export type SessionState = { readonly gradings: Map; readonly notifications: Notification[]; readonly googleUser?: string; - readonly githubAssessment?: MissionRepoData; readonly githubOctokitObject: { octokit: Octokit | undefined }; readonly githubAccessToken?: string; readonly remoteExecutionDevices?: Device[]; diff --git a/src/commons/controlBar/github/ControlBarDisplayMCQButton.tsx b/src/commons/controlBar/github/ControlBarDisplayMCQButton.tsx deleted file mode 100644 index 0bb0fd8a72..0000000000 --- a/src/commons/controlBar/github/ControlBarDisplayMCQButton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { IconNames } from '@blueprintjs/icons'; - -import ControlButton from '../../ControlButton'; - -type ControlBarDisplayMCQButtonProps = DispatchProps & StateProps; - -type DispatchProps = { - displayMCQInEditor: () => void; - displayTextInEditor: () => void; -}; - -type StateProps = { - mcqDisplayed: boolean; - key: string; -}; - -export const ControlBarDisplayMCQButton: React.FC = props => { - const label = props.mcqDisplayed ? 'Show MCQ Text' : 'Hide MCQ Text'; - const behaviour = props.mcqDisplayed ? props.displayTextInEditor : props.displayMCQInEditor; - - return ; -}; diff --git a/src/commons/controlBar/github/ControlBarGitHubLoginButton.tsx b/src/commons/controlBar/github/ControlBarGitHubLoginButton.tsx deleted file mode 100644 index eb3949a349..0000000000 --- a/src/commons/controlBar/github/ControlBarGitHubLoginButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { ButtonGroup } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; -import React from 'react'; -import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; - -import ControlButton from '../../ControlButton'; - -export type ControlBarGitHubLoginButtonProps = { - onClickLogIn: () => void; - onClickLogOut: () => void; -}; - -/** - * GitHub buttons to be used for the GitHub-hosted mission interface. - * - * @param props Component properties - */ -export const ControlBarGitHubLoginButton: React.FC = props => { - const { isMobileBreakpoint } = useResponsive(); - const isLoggedIn = - useTypedSelector(store => store.session.githubOctokitObject).octokit !== undefined; - - const loginButton = isLoggedIn ? ( - - ) : ( - - ); - - return {loginButton}; -}; diff --git a/src/commons/controlBar/github/ControlBarTaskAddButton.tsx b/src/commons/controlBar/github/ControlBarTaskAddButton.tsx deleted file mode 100644 index edb49a79a6..0000000000 --- a/src/commons/controlBar/github/ControlBarTaskAddButton.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { IconNames } from '@blueprintjs/icons'; - -import ControlButton from '../../ControlButton'; -import { maximumTasksPerMission } from '../../githubAssessments/GitHubMissionDataUtils'; -import { showWarningMessage } from '../../utils/notifications/NotificationsHelper'; - -export type ControlBarTaskAddButtonProps = { - addNewQuestion: () => void; - numberOfTasks: number; - key: string; -}; - -export const ControlBarTaskAddButton: React.FC = props => { - function onClickAdd() { - if (props.numberOfTasks === maximumTasksPerMission) { - showWarningMessage( - 'Cannot have more than ' + maximumTasksPerMission + ' Tasks in a Mission!' - ); - return; - } - - props.addNewQuestion(); - } - - return ; -}; diff --git a/src/commons/controlBar/github/ControlBarTaskDeleteButton.tsx b/src/commons/controlBar/github/ControlBarTaskDeleteButton.tsx deleted file mode 100644 index e385314a70..0000000000 --- a/src/commons/controlBar/github/ControlBarTaskDeleteButton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { IconNames } from '@blueprintjs/icons'; - -import ControlButton from '../../ControlButton'; -import { showSimpleConfirmDialog } from '../../utils/DialogHelper'; -import { showWarningMessage } from '../../utils/notifications/NotificationsHelper'; - -export type ControlBarTaskDeleteButtonProps = { - deleteCurrentQuestion: () => void; - numberOfTasks: number; - key: string; -}; - -export const ControlBarTaskDeleteButton: React.FC = props => { - async function onClickDelete() { - if (props.numberOfTasks <= 1) { - showWarningMessage('Cannot delete the only remaining task!'); - return; - } - - const confirmDelete = await showSimpleConfirmDialog({ - contents: ( -
-

Warning: you are about to delete a task.

-

This action cannot be undone.

-

Please click 'Confirm' to continue, or 'Cancel' to go back.

-
- ), - negativeLabel: 'Cancel', - positiveIntent: 'primary', - positiveLabel: 'Confirm' - }); - - if (confirmDelete) { - props.deleteCurrentQuestion(); - } - } - - return ( - - ); -}; diff --git a/src/commons/githubAssessments/GitHubMissionCreateDialog.tsx b/src/commons/githubAssessments/GitHubMissionCreateDialog.tsx deleted file mode 100644 index 6388f43797..0000000000 --- a/src/commons/githubAssessments/GitHubMissionCreateDialog.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { AnchorButton, Button, Classes, Dialog, InputGroup, Intent } from '@blueprintjs/core'; -import classNames from 'classnames'; -import React, { useState } from 'react'; -import classes from 'src/styles/GithubAssessments.module.scss'; - -import { showWarningMessage } from '../utils/notifications/NotificationsHelper'; - -export type GitHubMissionCreateDialogResolution = { - confirmSave: boolean; - repoName: string; -}; - -export type GitHubMissionCreateDialogProps = { - filesToCreate: string[]; - userLogin: string; - resolveDialog: (arg: GitHubMissionCreateDialogResolution) => void; -}; - -export const GitHubMissionCreateDialog: React.FC = props => { - const [repositoryName, setrepositoryName] = useState('sa-new-mission-repository'); - - return ( - -
-

Please confirm your save

-
-
-
-

This will create a repository owned by {props.userLogin} with the title:

- -
-
-

This repository will be created with following files:

- {props.filesToCreate.map(filepath => ( -
  • {filepath}
  • - ))} -
    -
    - -
    -
    - - - Confirm - -
    -
    -
    - ); - - function handleTitleChange(event: any) { - setrepositoryName(event.target.value); - } - - function handleClose() { - props.resolveDialog({ - confirmSave: false, - repoName: '' - }); - } - - function handleConfirm() { - if (repositoryName === '') { - showWarningMessage('Cannot create repository without title!', 2000); - return; - } - - props.resolveDialog({ - confirmSave: true, - repoName: repositoryName - }); - } -}; diff --git a/src/commons/githubAssessments/GitHubMissionDataUtils.ts b/src/commons/githubAssessments/GitHubMissionDataUtils.ts deleted file mode 100644 index c6285e68bf..0000000000 --- a/src/commons/githubAssessments/GitHubMissionDataUtils.ts +++ /dev/null @@ -1,512 +0,0 @@ -import { Octokit } from '@octokit/rest'; -import { - GetResponseDataTypeFromEndpointMethod, - GetResponseTypeFromEndpointMethod -} from '@octokit/types'; -import { Chapter } from 'js-slang/dist/types'; -import { isEqual } from 'lodash'; - -import { IMCQQuestion, Testcase } from '../assessment/AssessmentTypes'; -import { showWarningMessage } from '../utils/notifications/NotificationsHelper'; -import { MissionData, MissionMetadata, MissionRepoData, TaskData } from './GitHubMissionTypes'; - -export const maximumTasksPerMission = 20; - -const jsonStringify = (object: any) => JSON.stringify(object, null, 4); -const convertTestsToSaveableJson = (tests: Testcase[]) => { - const saveableTests = tests.map((test: Testcase) => { - return { - answer: test.answer, - program: test.program, - score: test.score ? test.score : 0, - type: test.type ? test.type : 'public' - }; - }); - return jsonStringify(saveableTests); -}; -const identity = (content: any) => content; - -// 1) fileName: the name of the file corresponding to the named property -// 2) isDefaultValue: function should return true if the input value is the default value of the property -// 3) fromStringConverter: a function to be applied to raw text data to convert it into the property -// 4) toStringConverter: a function to be applied to the property to convert it to raw text data -const taskDataPropertyTable = { - taskDescription: { - fileName: 'Problem.md', - isDefaultValue: (value: string) => value === '', - fromStringConverter: identity, - toStringConverter: identity - }, - starterCode: { - fileName: 'StarterCode.js', - isDefaultValue: (value: string) => value === '', - fromStringConverter: identity, - toStringConverter: identity - }, - savedCode: { - fileName: 'SavedCode.js', - isDefaultValue: (value: string) => value === '', - fromStringConverter: identity, - toStringConverter: identity - }, - testPrepend: { - fileName: 'TestPrepend.js', - isDefaultValue: (value: string) => value === '', - fromStringConverter: identity, - toStringConverter: identity - }, - testPostpend: { - fileName: 'TestPostpend.js', - isDefaultValue: (value: string) => value === '', - fromStringConverter: identity, - toStringConverter: identity - }, - testCases: { - fileName: 'TestCases.json', - isDefaultValue: (value: Testcase[]) => value.length === 0, - fromStringConverter: JSON.parse, - toStringConverter: convertTestsToSaveableJson - } -}; - -/** - * Retrieves mission information - such as the briefings, questions, metadata etc. from a GitHub Repository. - * - * @param missionRepoData Repository information where the mission is stored - * @param octokit The Octokit instance for the authenticated user - */ -export async function getMissionData(missionRepoData: MissionRepoData, octokit: Octokit) { - const briefingStringPromise = getContentAsString( - missionRepoData.repoOwner, - missionRepoData.repoName, - 'README.md', - octokit - ); - - const metadataStringPromise = getContentAsString( - missionRepoData.repoOwner, - missionRepoData.repoName, - '.metadata', - octokit - ); - - const tasksDataPromise = getTasksData( - missionRepoData.repoOwner, - missionRepoData.repoName, - octokit - ); - - const [briefingString, metadataString, tasksData] = await Promise.all([ - briefingStringPromise, - metadataStringPromise, - tasksDataPromise - ]); - const missionMetadata = convertMetadataStringToMissionMetadata(metadataString); - - const newMissionData: MissionData = { - missionRepoData: missionRepoData, - missionBriefing: briefingString, - missionMetadata: missionMetadata, - tasksData: tasksData - }; - - return newMissionData; -} - -/** - * Retrieves information regarding each task in the Mission from the GitHub repository. - * - * @param repoOwner The owner of the mission repository - * @param repoName The name of the mission repository - * @param octokit The Octokit instance for the authenticated user - */ -async function getTasksData(repoOwner: string, repoName: string, octokit: Octokit) { - const questions: TaskData[] = []; - - if (octokit === undefined) { - return questions; - } - - // Get files in root - type GetContentResponse = GetResponseTypeFromEndpointMethod; - const rootFolderContents: GetContentResponse = await octokit.repos.getContent({ - owner: repoOwner, - repo: repoName, - path: '' - }); - - type GetContentData = GetResponseDataTypeFromEndpointMethod; - const files: GetContentData = rootFolderContents.data; - - if (!Array.isArray(files)) { - return questions; - } - - const promises = []; - - for (let i = 1; i <= maximumTasksPerMission; i++) { - const questionFolderName = 'Q' + i; - - // We make the assumption that there are no gaps in question numbering - // If the question does not exist, we may break - if (files.find(file => file.name === questionFolderName) === undefined) { - break; - } - - promises.push( - octokit.repos - .getContent({ - owner: repoOwner, - repo: repoName, - path: questionFolderName - }) - .then((folderContents: GetContentResponse) => { - if (!Array.isArray(folderContents.data)) { - return; - } - - const folderContentsAsArray = folderContents.data as any[]; - const folderContentFileNames = folderContentsAsArray.map( - (file: any) => file.name as string - ); - - const properties = Object.keys(taskDataPropertyTable); - - const filteredProperties = properties.filter((property: string) => - folderContentFileNames.includes(taskDataPropertyTable[property].fileName) - ); - - const promises = filteredProperties.map(async (property: string) => { - const fileName = taskDataPropertyTable[property].fileName; - - const stringContent = await getContentAsString( - repoOwner, - repoName, - questionFolderName + '/' + fileName, - octokit - ); - - return taskDataPropertyTable[property].fromStringConverter(stringContent); - }); - - return Promise.all(promises).then((stringContents: string[]) => { - const taskData: TaskData = { - questionNumber: i, - taskDescription: '', - starterCode: '', - savedCode: '', - testPrepend: '', - testPostpend: '', - testCases: [] - }; - - for (let i = 0; i < stringContents.length; i++) { - taskData[filteredProperties[i]] = stringContents[i]; - } - - if (taskData.savedCode === '') { - taskData.savedCode = taskData.starterCode; - } - - questions.push(taskData); - }); - }) - .catch(err => { - showWarningMessage('Error occurred while trying to retrieve file content', 1000); - console.error(err); - }) - ); - } - await Promise.all(promises); - questions.sort((a, b) => a.questionNumber - b.questionNumber); - - return questions; -} - -/** - * Retrieves content from a single file on GitHub and returns it in string form. - * - * @param repoOwner The owner of the mission repository - * @param repoName The name of the mission repository - * @param filepath The path to the file to be retrieved - * @param octokit The Octokit instance for the authenticated user - */ -export async function getContentAsString( - repoOwner: string, - repoName: string, - filepath: string, - octokit: Octokit -) { - let contentString = ''; - - if (octokit === undefined) { - return contentString; - } - - try { - type GetContentResponse = GetResponseTypeFromEndpointMethod; - const fileInfo: GetContentResponse = await octokit.repos.getContent({ - owner: repoOwner, - repo: repoName, - path: filepath - }); - - contentString = Buffer.from((fileInfo.data as any).content, 'base64').toString(); - } catch (err) { - showWarningMessage('Error occurred while trying to retrieve file content', 1000); - console.error(err); - } - - return contentString; -} - -/** - * Converts the contents of the '.metadata' file into a MissionMetadata object. - * - * @param metadataString The file contents of the '.metadata' file of a mission repository - */ -function convertMetadataStringToMissionMetadata(metadataString: string) { - try { - return JSON.parse(metadataString) as MissionMetadata; - } catch (err) { - console.error(err); - return { - sourceVersion: Chapter.SOURCE_4 - } as MissionMetadata; - } -} - -function convertMissionMetadataToMetadataString(missionMetadata: MissionMetadata) { - return jsonStringify(missionMetadata); -} - -/** - * Discovers files to be changed when saving to an existing GitHub repository - * Return value is an array in the format [filenameToContentMap, foldersToDelete] - * filenameToContentMap is an object whose key-value pairs are filenames and their new contents - * foldersToDelete is an array containing the names of folders - * @param missionMetadata The current MissionMetadata - * @param cachedMissionMetadata The cached MissionMetadata - * @param briefingContent The current briefing - * @param cachedBriefingContent The cached briefing - * @param taskList The current taskList - * @param cachedTaskList The cached taskList - * @param isTeacherMode If this is true, any changes to the saved code will be made to starter code instead - */ -export function discoverFilesToBeChangedWithMissionRepoData( - missionMetadata: MissionMetadata, - cachedMissionMetadata: MissionMetadata, - briefingContent: string, - cachedBriefingContent: string, - taskList: TaskData[], - cachedTaskList: TaskData[], - isTeacherMode: boolean -): [any, string[]] { - const filenameToContentMap = {}; - const foldersToDelete: string[] = []; - - if (missionMetadata !== cachedMissionMetadata) { - filenameToContentMap['.metadata'] = convertMissionMetadataToMetadataString(missionMetadata); - } - - if (briefingContent !== cachedBriefingContent) { - filenameToContentMap['README.md'] = briefingContent; - } - - let i = 0; - while (i < taskList.length) { - const taskNumber = i + 1; - const questionFolderName = 'Q' + taskNumber; - - if (taskNumber > cachedTaskList.length) { - // Look for files to create - filenameToContentMap[questionFolderName + '/StarterCode.js'] = taskList[i].savedCode; - filenameToContentMap[questionFolderName + '/Problem.md'] = taskList[i].taskDescription; - - const propertiesToCheck = ['testCases', 'testPrepend', 'testPostpend']; - - for (const propertyName of propertiesToCheck) { - const currentValue = taskList[i][propertyName]; - const isDefaultValue = taskDataPropertyTable[propertyName].isDefaultValue(currentValue); - - if (!isDefaultValue) { - const onRepoFileName = - questionFolderName + '/' + taskDataPropertyTable[propertyName].fileName; - const stringContent = taskDataPropertyTable[propertyName].toStringConverter( - taskList[i][propertyName] - ); - - filenameToContentMap[onRepoFileName] = stringContent; - } - } - } else { - // Look for files to edit - const propertiesToCheck = Object.keys(taskDataPropertyTable); - - for (const propertyName of propertiesToCheck) { - const currentValue = taskList[i][propertyName]; - const cachedValue = cachedTaskList[i][propertyName]; - - if (!isEqual(currentValue, cachedValue)) { - const onRepoFileName = - questionFolderName + '/' + taskDataPropertyTable[propertyName].fileName; - const stringContent = taskDataPropertyTable[propertyName].toStringConverter( - taskList[i][propertyName] - ); - - filenameToContentMap[onRepoFileName] = stringContent; - } - } - - if ( - isTeacherMode && - filenameToContentMap[questionFolderName + '/' + taskDataPropertyTable['savedCode'].fileName] - ) { - // replace changes to savedCode with changes to starterCode - const savedCodeValue = - filenameToContentMap[ - questionFolderName + '/' + taskDataPropertyTable['savedCode'].fileName - ]; - delete filenameToContentMap[ - questionFolderName + '/' + taskDataPropertyTable['savedCode'].fileName - ]; - filenameToContentMap[ - questionFolderName + '/' + taskDataPropertyTable['starterCode'].fileName - ] = savedCodeValue; - } - } - i++; - } - - while (i < cachedTaskList.length) { - const taskNumber = i + 1; - foldersToDelete.push('Q' + taskNumber); - i++; - } - - return [filenameToContentMap, foldersToDelete]; -} - -/** - * Discovers files to be changed when saving to a new GitHub repository - * @param missionMetadata The current MissionMetadata - * @param briefingContent The current briefing - * @param taskList The current taskList - */ -export function discoverFilesToBeCreatedWithoutMissionRepoData( - missionMetadata: MissionMetadata, - briefingContent: string, - taskList: TaskData[] -) { - const filenameToContentMap = {}; - filenameToContentMap['.metadata'] = convertMissionMetadataToMetadataString(missionMetadata); - filenameToContentMap['README.md'] = briefingContent; - - const propertiesToCheck = ['testCases', 'testPrepend', 'testPostpend']; - - for (let i = 0; i < taskList.length; i++) { - const taskNumber = i + 1; - const questionFolderName = 'Q' + taskNumber; - - filenameToContentMap[questionFolderName + '/' + taskDataPropertyTable['starterCode'].fileName] = - taskList[i].savedCode; - filenameToContentMap[ - questionFolderName + '/' + taskDataPropertyTable['taskDescription'].fileName - ] = taskList[i].taskDescription; - - propertiesToCheck.forEach((propertyName: string) => { - const currentValue = taskList[i][propertyName]; - const isDefaultValue = taskDataPropertyTable[propertyName].isDefaultValue(currentValue); - - if (!isDefaultValue) { - const onRepoFileName = - questionFolderName + '/' + taskDataPropertyTable[propertyName].fileName; - const stringContent = taskDataPropertyTable[propertyName].toStringConverter( - taskList[i][propertyName] - ); - - filenameToContentMap[onRepoFileName] = stringContent; - } - }); - } - - return filenameToContentMap; -} - -/** - * Checks if the textual contents of a GitHub-hosted file is for an MCQ question, and converts it if so - * returns an array of 2 values, a boolean and an IMCQQuestion - * The boolean specifies whether the input corresponded to an MCQQuestion - * The IMCQQuestion is only meaningful if the boolean is true, and contains the converted information - * @param possibleMCQText The text to be checked and converted - */ -export function convertToMCQQuestionIfMCQText(possibleMCQText: string): [boolean, IMCQQuestion] { - let isMCQText = false; - const mcqQuestion = { - answer: 0, - choices: [], - solution: -1, - type: 'mcq', - content: '', - grade: 0, - id: 0, - library: { chapter: Chapter.SOURCE_4, external: { name: 'NONE', symbols: [] }, globals: [] }, - maxGrade: 0, - xp: 0, - maxXp: 0 - } as IMCQQuestion; - - const trimmedText = possibleMCQText.trim(); - - if (trimmedText.substring(0, 3).toLowerCase() === 'mcq') { - isMCQText = true; - } - - if (isMCQText) { - const onlyQuestionInformation = trimmedText.substring(3, trimmedText.length); - try { - const intermediateObject = JSON.parse(onlyQuestionInformation); - - const studentAnswer = intermediateObject.answer; - const intermediateChoices = intermediateObject.choices as any[]; - const choices = intermediateChoices.map((question: { option: string; hint: string }) => { - return { - content: question.option, - hint: question.hint - }; - }); - const solution = intermediateObject.solution; - - mcqQuestion.answer = studentAnswer; - mcqQuestion.choices = choices; - mcqQuestion.solution = solution; - } catch (err) { - isMCQText = false; - } - } - - return [isMCQText, mcqQuestion]; -} - -/** - * Converts an IMCQQuestion object into textual contents to be saved to a GitHub repository - * @param mcq The IMCQQuestion object - */ -export function convertIMCQQuestionToMCQText(mcq: IMCQQuestion) { - const studentAnswer = mcq.answer; - const choices = mcq.choices.map((choice: { content: string; hint: string | null }) => { - return { - option: choice.content, - hint: choice.hint - }; - }); - const solution = mcq.solution; - - const json = { - choices: choices, - answer: studentAnswer, - solution: solution - }; - - return 'MCQ\n' + jsonStringify(json); -} diff --git a/src/commons/githubAssessments/GitHubMissionMobileLoginButton.tsx b/src/commons/githubAssessments/GitHubMissionMobileLoginButton.tsx deleted file mode 100644 index 8df825a015..0000000000 --- a/src/commons/githubAssessments/GitHubMissionMobileLoginButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ButtonGroup } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; -import React from 'react'; - -import ControlButton from '../ControlButton'; -import { useResponsive, useTypedSelector } from '../utils/Hooks'; - -export type ControlBarGitHubMobileLoginButtonProps = { - onClickLogIn: () => void; - onClickLogOut: () => void; -}; - -/** - * GitHub buttons to be used for the GitHub-hosted mission interface. - * - * @param props Component properties - */ -export const ControlBarGitHubMobileLoginButton: React.FC< - ControlBarGitHubMobileLoginButtonProps -> = props => { - const { isMobileBreakpoint } = useResponsive(); - const isLoggedIn = - useTypedSelector(store => store.session.githubOctokitObject).octokit !== undefined; - - const loginButton = isLoggedIn ? ( - - ) : ( - - ); - - return {loginButton}; -}; diff --git a/src/commons/githubAssessments/GitHubMissionSaveDialog.tsx b/src/commons/githubAssessments/GitHubMissionSaveDialog.tsx deleted file mode 100644 index 09537c5dde..0000000000 --- a/src/commons/githubAssessments/GitHubMissionSaveDialog.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { AnchorButton, Button, Classes, Dialog, InputGroup, Intent } from '@blueprintjs/core'; -import classNames from 'classnames'; -import React, { useState } from 'react'; -import classes from 'src/styles/GithubAssessments.module.scss'; - -export type GitHubMissionSaveDialogResolution = { - confirmSave: boolean; - commitMessage: string; -}; - -export type GitHubMissionSaveDialogProps = { - repoName: string; - filesToChangeOrCreate: string[]; - filesToDelete: string[]; - resolveDialog: (arg: GitHubMissionSaveDialogResolution) => void; -}; - -/** - * A dialog that prompts the user to confirm their save. Displays the files that will change, as well as the prompt for the user to enter a commit message. - * - * @param props Component properties - */ -export const GitHubMissionSaveDialog: React.FC = props => { - const [commitMessage, setCommitMessage] = useState(''); - - return ( - -
    -

    Please confirm your save

    -
    -
    -
    - {props.filesToChangeOrCreate.length > 0 && ( -

    You are about to create or edit the following files:

    - )} - {props.filesToChangeOrCreate.map(filepath => ( -
  • {filepath}
  • - ))} - - {props.filesToDelete.length > 0 &&

    You are about to delete the following files:

    } - {props.filesToDelete.map(filepath => ( -
  • {filepath}
  • - ))} -
    -
    - -
    -
    - -
    -
    - - - Confirm - -
    -
    -
    - ); - - function handleClose() { - props.resolveDialog({ - confirmSave: false, - commitMessage: '' - }); - } - - function handleConfirm() { - props.resolveDialog({ - confirmSave: true, - commitMessage: commitMessage - }); - } - - function handleCommitMessageChange(event: any) { - setCommitMessage(event.target.value); - } -}; diff --git a/src/commons/githubAssessments/GitHubMissionTypes.ts b/src/commons/githubAssessments/GitHubMissionTypes.ts deleted file mode 100644 index 8bf8b0a284..0000000000 --- a/src/commons/githubAssessments/GitHubMissionTypes.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Chapter } from 'js-slang/dist/types'; - -import { Testcase } from '../assessment/AssessmentTypes'; - -/** - * Represents a single task for a mission hosted in a GitHub repository. - */ -export type TaskData = { - questionNumber: number; - taskDescription: string; - starterCode: string; - savedCode: string; - testPrepend: string; - testPostpend: string; - testCases: Testcase[]; -}; - -/** - * An code representation of a GitHub-hosted mission's '.metadata' file. - */ -export type MissionMetadata = { - sourceVersion: Chapter; -}; - -/** - * Represents information about a GitHub repository containing a SourceAcademy mission. - * - * Contains sufficient information for two purposes: - * 1. Retrieval of repository information - * 2. Establishing proper display order on the Mission Listing page - */ -export type MissionRepoData = { - repoOwner: string; - repoName: string; - dateOfCreation: Date; -}; - -/** - * Represents the information relating to a single Mission. - */ -export type MissionData = { - missionRepoData: MissionRepoData; - missionBriefing: string; - missionMetadata: MissionMetadata; - tasksData: TaskData[]; -}; diff --git a/src/commons/githubAssessments/__tests__/GitHubMissionCreateDialog.tsx b/src/commons/githubAssessments/__tests__/GitHubMissionCreateDialog.tsx deleted file mode 100644 index ea97af0463..0000000000 --- a/src/commons/githubAssessments/__tests__/GitHubMissionCreateDialog.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { - GitHubMissionCreateDialog, - GitHubMissionCreateDialogResolution -} from '../GitHubMissionCreateDialog'; - -test("Selecting close causes ResolveDialog to be called with confirmSave=true and repoName=''", async () => { - let outsideValue = { - confirmSave: false, - repoName: 'test' - } as GitHubMissionCreateDialogResolution; - function resolveDialog(insideValue: GitHubMissionCreateDialogResolution) { - outsideValue = insideValue; - } - - act(() => { - render( - - ); - }); - - await screen.findByText('Please confirm your save'); - - fireEvent.click(screen.getByText('Close')); - expect(outsideValue).toStrictEqual({ confirmSave: false, repoName: '' }); -}); - -test('Selecting Confirm without repository name does not result in dialog resolution', async () => { - let outsideValue = { - confirmSave: false, - repoName: 'test' - } as GitHubMissionCreateDialogResolution; - function resolveDialog(insideValue: GitHubMissionCreateDialogResolution) { - outsideValue = insideValue; - } - - act(() => { - render( - - ); - }); - - await screen.findByText('Please confirm your save'); - const textArea = screen.getByPlaceholderText('Enter Repository Title'); - userEvent.clear(textArea); - fireEvent.click(screen.getByText('Confirm')); - expect(outsideValue).toStrictEqual({ confirmSave: false, repoName: 'test' }); -}); - -test('Selecting Confirm with repository name causes ResolveDialog to be called with confirmSave=true and inputted repoName', async () => { - let outsideValue = { - confirmSave: false, - repoName: 'blank' - } as GitHubMissionCreateDialogResolution; - function resolveDialog(insideValue: GitHubMissionCreateDialogResolution) { - outsideValue = insideValue; - } - - act(() => { - render( - - ); - }); - - await screen.findByText('Please confirm your save'); - const textArea = screen.getByPlaceholderText('Enter Repository Title'); - await userEvent.clear(textArea); - await userEvent.type(textArea, 'repoName'); - fireEvent.click(screen.getByText('Confirm')); - expect(outsideValue).toStrictEqual({ confirmSave: true, repoName: 'repoName' }); -}); diff --git a/src/commons/githubAssessments/__tests__/GitHubMissionDataUtils.ts b/src/commons/githubAssessments/__tests__/GitHubMissionDataUtils.ts deleted file mode 100644 index 9ab6d3bfa5..0000000000 --- a/src/commons/githubAssessments/__tests__/GitHubMissionDataUtils.ts +++ /dev/null @@ -1,903 +0,0 @@ -import { Octokit } from '@octokit/rest'; -import { Chapter } from 'js-slang/dist/types'; - -import { IMCQQuestion } from '../../assessment/AssessmentTypes'; -import * as GitHubMissionDataUtils from '../GitHubMissionDataUtils'; -import { MissionRepoData } from '../GitHubMissionTypes'; - -test('getContentAsString correctly gets content and translates from Base64 to utf-8', async () => { - const octokit = new Octokit(); - const getContentMock = jest.spyOn(octokit.repos, 'getContent'); - - getContentMock.mockImplementationOnce(async () => { - const contentResponse = generateGetContentResponse(); - (contentResponse.data as any).content = Buffer.from('Hello World!', 'utf8').toString('base64'); - return contentResponse; - }); - - const content = await GitHubMissionDataUtils.getContentAsString( - 'dummy owner', - 'dummy repo', - 'dummy path', - octokit - ); - expect(content).toBe('Hello World!'); -}); - -test('getMissionData works properly', async () => { - const missionRepoData: MissionRepoData = { - repoOwner: 'Pain', - repoName: 'Peko', - dateOfCreation: new Date('December 17, 1995 03:24:00') - }; - - const octokit = new Octokit(); - const getContentMock = jest.spyOn(octokit.repos, 'getContent'); - getContentMock - .mockImplementationOnce(async () => { - // Briefing String - const contentResponse = generateGetContentResponse(); - (contentResponse.data as any).content = Buffer.from('Briefing Content', 'utf8').toString( - 'base64' - ); - return contentResponse; - }) - .mockImplementationOnce(async () => { - // Metadata String - const contentResponse = generateGetContentResponse(); - (contentResponse.data as any).content = Buffer.from( - `{ - "sourceVersion": 3 - }`, - 'utf-8' - ).toString('base64'); - return contentResponse; - }) - .mockImplementationOnce(async () => { - // Get files/folders at root - const contentResponse = generateGetContentResponse() as { - url: any; - status: any; - headers: any; - data: any; - }; - contentResponse.data = [generateGitHubSubDirectory('Q1'), generateGitHubSubDirectory('Q2')]; - return contentResponse; - }) - .mockImplementationOnce(async () => { - // Get folder contents for Q1 - const contentResponse = generateGetContentResponse() as { - url: any; - status: any; - headers: any; - data: any; - }; - contentResponse.data = [ - generateGitHubSubDirectory('Problem.md'), - generateGitHubSubDirectory('StarterCode.js') - ]; - return contentResponse; - }) - .mockImplementationOnce(async () => { - // Folder contents for Q2 - const contentResponse = generateGetContentResponse(); - contentResponse.data = [ - generateGitHubSubDirectory('Problem.md'), - generateGitHubSubDirectory('StarterCode.js'), - generateGitHubSubDirectory('SavedCode.js'), - generateGitHubSubDirectory('TestPrepend.js'), - generateGitHubSubDirectory('TestCases.json') - ]; - //(contentResponse.data as any).content = Buffer.from('SavedCode A', 'utf8').toString('base64'); - return contentResponse; - }) - .mockImplementationOnce(async () => { - // Q1/Problem.md - const contentResponse = generateGetContentResponse(); - (contentResponse.data as any).content = Buffer.from('Task A', 'utf8').toString('base64'); - return contentResponse; - }) - .mockImplementationOnce(async () => { - // Q1/StarterCode.js - const contentResponse = generateGetContentResponse(); - (contentResponse.data as any).content = Buffer.from('Code A', 'utf8').toString('base64'); - return contentResponse; - }) - .mockImplementationOnce(async () => { - // Q2/Problem.md - const contentResponse = generateGetContentResponse(); - (contentResponse.data as any).content = Buffer.from('Task B', 'utf8').toString('base64'); - return contentResponse; - }) - .mockImplementationOnce(async () => { - // Q2/StarterCode.js - const contentResponse = generateGetContentResponse(); - (contentResponse.data as any).content = Buffer.from('Code B', 'utf8').toString('base64'); - return contentResponse; - }) - .mockImplementationOnce(async () => { - // Q2/SavedCode.js - const contentResponse = generateGetContentResponse(); - (contentResponse.data as any).content = Buffer.from('Code C', 'utf8').toString('base64'); - return contentResponse; - }) - .mockImplementationOnce(async () => { - // Q2/TestPrepend.js - const contentResponse = generateGetContentResponse(); - (contentResponse.data as any).content = Buffer.from('Code D', 'utf8').toString('base64'); - return contentResponse; - }) - .mockImplementationOnce(async () => { - // Q2/TestCases.json - const contentResponse = generateGetContentResponse(); - (contentResponse.data as any).content = Buffer.from( - `[{ - "answer": "[[1, [2, [3, null]]], [4, [5, null]]]", - "program": "sort_pair_of_lists(pair(list(2, 1, 3), list(5, 4)));" - }]`, - 'utf8' - ).toString('base64'); - return contentResponse; - }); - - const missionData = await GitHubMissionDataUtils.getMissionData(missionRepoData, octokit); - - expect(missionData.missionRepoData.repoOwner).toBe('Pain'); - expect(missionData.missionRepoData.repoName).toBe('Peko'); - - expect(missionData.missionBriefing).toBe('Briefing Content'); - expect(missionData.missionMetadata.sourceVersion).toBe(3); - - expect(missionData.tasksData.length).toBe(2); - - expect(missionData.tasksData[0]).toEqual({ - questionNumber: 1, - taskDescription: 'Task A', - starterCode: 'Code A', - savedCode: 'Code A', - testPrepend: '', - testPostpend: '', - testCases: [] - }); - expect(missionData.tasksData[1]).toEqual({ - questionNumber: 2, - taskDescription: 'Task B', - starterCode: 'Code B', - savedCode: 'Code C', - testPrepend: 'Code D', - testPostpend: '', - testCases: [ - { - answer: '[[1, [2, [3, null]]], [4, [5, null]]]', - program: 'sort_pair_of_lists(pair(list(2, 1, 3), list(5, 4)));' - } - ] - }); -}); - -test('discoverFilesToBeChangedWithMissionRepoData discovers files to create', () => { - // missionMetadata and cachedMissionMetadata have the same values - // briefingContent and cachedBriefingContent have the same values - // taskList is longer than cachedTaskList - // taskList shares one task with cachedTaskList - - const missionMetadata = Object.assign(dummyMissionMetadata); - const cachedMissionMetadata = Object.assign(dummyMissionMetadata); - - const briefingContent = 'dummy'; - const cachedBriefingContent = 'dummy'; - - const taskList = [ - { - questionNumber: 1, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }, - { - questionNumber: 2, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - } - ]; - const cachedTaskList = [ - { - questionNumber: 1, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - } - ]; - - const isTeacherMode = false; - - const [filenameToContentMap, foldersToDelete] = - GitHubMissionDataUtils.discoverFilesToBeChangedWithMissionRepoData( - missionMetadata, - cachedMissionMetadata, - briefingContent, - cachedBriefingContent, - taskList, - cachedTaskList, - isTeacherMode - ); - - const existingKeys = new Set(); - - Object.keys(filenameToContentMap).forEach((key: string) => existingKeys.add(key)); - - const expectedKeys = new Set([ - 'Q2/StarterCode.js', - 'Q2/Problem.md', - 'Q2/TestCases.json', - 'Q2/TestPrepend.js', - 'Q2/TestPostpend.js' - ]); - - expect(existingKeys).toEqual(expectedKeys); - expect(foldersToDelete.length).toBe(0); -}); - -test('discoverFilesToBeChangedWithMissionRepoData discovers files to edit in Non-Teacher Mode', () => { - // missionMetadata and cachedMissionMetadata have different values - // briefingContent and cachedBriefingContent have different values - // taskList is the same length as cachedTaskList - // taskList shares one task with cachedTaskList - // Expect: metadata and readme, as well as different files from Q1, Q2 and Q3 to be changed - - const missionMetadata = Object.assign(dummyMissionMetadata); - const cachedMissionMetadata = Object.assign(defaultMissionMetadata); - - const briefingContent = 'new dummy'; - const cachedBriefingContent = 'dummy'; - - const taskList = [ - { - questionNumber: 1, - taskDescription: 'change', - starterCode: 'starter', - savedCode: 'change', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }, - { - questionNumber: 2, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'change', - testPostpend: 'change', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }, - { - questionNumber: 3, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - }, - { - answer: 'another', - program: 'testcase', - score: 0, - type: 'public' as const - } - ] - } - ]; - - const cachedTaskList = [ - { - questionNumber: 1, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }, - { - questionNumber: 2, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }, - { - questionNumber: 3, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - } - ]; - - const isTeacherMode = false; - - const [filenameToContentMap, foldersToDelete] = - GitHubMissionDataUtils.discoverFilesToBeChangedWithMissionRepoData( - missionMetadata, - cachedMissionMetadata, - briefingContent, - cachedBriefingContent, - taskList, - cachedTaskList, - isTeacherMode - ); - - const existingKeys = new Set(); - - Object.keys(filenameToContentMap).forEach((key: string) => existingKeys.add(key)); - - const expectedKeys = new Set([ - '.metadata', - 'README.md', - 'Q1/SavedCode.js', - 'Q1/Problem.md', - 'Q2/TestPrepend.js', - 'Q2/TestPostpend.js', - 'Q3/TestCases.json' - ]); - - expect(existingKeys).toEqual(expectedKeys); - expect(foldersToDelete.length).toBe(0); -}); - -test('discoverFilesToBeChangedWithMissionRepoData discovers files to edit in Teacher Mode', () => { - // Test inputs should be same as above, but with isTeacherMode set to true - // This will turn any edits to 'SavedCode.js' into edits to 'StarterCode.js' - const missionMetadata = Object.assign(dummyMissionMetadata); - const cachedMissionMetadata = Object.assign(defaultMissionMetadata); - - const briefingContent = 'new dummy'; - const cachedBriefingContent = 'dummy'; - - const taskList = [ - { - questionNumber: 1, - taskDescription: 'change', - starterCode: 'starter', - savedCode: 'change', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }, - { - questionNumber: 2, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'change', - testPostpend: 'change', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }, - { - questionNumber: 3, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - }, - { - answer: 'another', - program: 'testcase', - score: 0, - type: 'public' as const - } - ] - } - ]; - - const cachedTaskList = [ - { - questionNumber: 1, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }, - { - questionNumber: 2, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }, - { - questionNumber: 3, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - } - ]; - - const isTeacherMode = true; - - const [filenameToContentMap, foldersToDelete] = - GitHubMissionDataUtils.discoverFilesToBeChangedWithMissionRepoData( - missionMetadata, - cachedMissionMetadata, - briefingContent, - cachedBriefingContent, - taskList, - cachedTaskList, - isTeacherMode - ); - - const existingKeys = new Set(); - - Object.keys(filenameToContentMap).forEach((key: string) => existingKeys.add(key)); - - const expectedKeys = new Set([ - '.metadata', - 'README.md', - 'Q1/StarterCode.js', - 'Q1/Problem.md', - 'Q2/TestPrepend.js', - 'Q2/TestPostpend.js', - 'Q3/TestCases.json' - ]); - - expect(existingKeys).toEqual(expectedKeys); - expect(foldersToDelete.length).toBe(0); -}); - -test('discoverFilesToBeChangedWithMissionRepoData discovers files to delete', () => { - // missionMetadata and cachedMissionMetadata have same values - // briefingContent and cachedBriefingContent have same values - // taskList is the shorter as cachedTaskList - // taskList has a changed task from cachedTaskList - // isTeacherMode is true as deletion is only accessible in isTeacherMode - // Expect: files from Q1 to be changed, Q2 and Q3 to be deleted - - const missionMetadata = Object.assign(defaultMissionMetadata); - const cachedMissionMetadata = Object.assign(defaultMissionMetadata); - - const briefingContent = 'dummy'; - const cachedBriefingContent = 'dummy'; - - const taskList = [ - { - questionNumber: 1, - taskDescription: 'changed', - starterCode: 'starter', - savedCode: 'changed', - testPrepend: 'changed', - testPostpend: 'changed', - testCases: [ - { - answer: 'different', - program: '', - score: 0, - type: 'public' as const - } - ] - } - ]; - - const cachedTaskList = [ - { - questionNumber: 1, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }, - { - questionNumber: 2, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }, - { - questionNumber: 3, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - } - ]; - - const isTeacherMode = true; - - const [filenameToContentMap, foldersToDelete] = - GitHubMissionDataUtils.discoverFilesToBeChangedWithMissionRepoData( - missionMetadata, - cachedMissionMetadata, - briefingContent, - cachedBriefingContent, - taskList, - cachedTaskList, - isTeacherMode - ); - - const existingKeys = new Set(); - - Object.keys(filenameToContentMap).forEach((key: string) => existingKeys.add(key)); - - const expectedKeys = new Set([ - 'Q1/StarterCode.js', - 'Q1/Problem.md', - 'Q1/TestPrepend.js', - 'Q1/TestPostpend.js', - 'Q1/TestCases.json' - ]); - - expect(existingKeys).toEqual(expectedKeys); - - expect(foldersToDelete).toEqual(['Q2', 'Q3']); -}); - -test('discoverFilesToBeCreatedWithoutMissionRepoData works properly', () => { - const missionMetadata = Object.assign({}, dummyMissionMetadata); - const briefingContent = 'dummy briefing'; - const taskData = { - questionNumber: 0, - taskDescription: 'description', - starterCode: 'starter', - savedCode: 'saved', - testPrepend: 'prepend', - testPostpend: 'postpend', - testCases: [ - { - answer: '', - program: '', - score: 0, - type: 'public' as const - } - ] - }; - - const filenameToContentMap = - GitHubMissionDataUtils.discoverFilesToBeCreatedWithoutMissionRepoData( - missionMetadata, - briefingContent, - [taskData] - ); - - const existingKeys = new Set(); - - Object.keys(filenameToContentMap).forEach((key: string) => existingKeys.add(key)); - - const expectedKeys = new Set([ - 'Q1/StarterCode.js', - 'Q1/Problem.md', - '.metadata', - 'README.md', - 'Q1/TestCases.json', - 'Q1/TestPrepend.js', - 'Q1/TestPostpend.js' - ]); - - expect(existingKeys).toEqual(expectedKeys); -}); - -test('convertToMCQQuestionIfMCQText returns false if non-MCQ', () => { - const isMCQText = GitHubMissionDataUtils.convertToMCQQuestionIfMCQText('McQsdlkfjsd;f')[0]; - expect(isMCQText).toBe(false); -}); - -test('convertToMCQQuestionIfMCQText returns false if mcq text is malformed', () => { - const malformedMcqText = - 'MCQ\n' + - '{\n' + - ' "choices":\n' + - ' [\n' + - ' { "option": "Θ(1)", "hint":"one" },\n' + - ' { "option": "Θ(log _n_)", "hint":"two" },\n' + - ' { "option": "Θ(_n_)", "hint":"14345" },\n' + - ' { "option": "Θ(_n_ log _n_)", "hint":"yes" },\n' + - ' { "option": "Θ(_n_²)", "hint":"definitely wrong" },\n' + - ' { "option": "Θ(_n_³)", "hint":"maybe" }\n' + - ' ],\n' + - ' "answer": 4'; - - const isMCQText = GitHubMissionDataUtils.convertToMCQQuestionIfMCQText(malformedMcqText)[0]; - expect(isMCQText).toBe(false); -}); - -test('convertToMCQQuestionIfMCQText works properly', () => { - const mcqText = - 'MCQ\n' + - '{\n' + - ' "choices":\n' + - ' [\n' + - ' { "option": "Θ(1)", "hint":"one" },\n' + - ' { "option": "Θ(log _n_)", "hint":"two" },\n' + - ' { "option": "Θ(_n_)", "hint":"14345" },\n' + - ' { "option": "Θ(_n_ log _n_)", "hint":"yes" },\n' + - ' { "option": "Θ(_n_²)", "hint":"definitely wrong" },\n' + - ' { "option": "Θ(_n_³)", "hint":"maybe" }\n' + - ' ],\n' + - ' "answer": 4,\n' + - ' "solution": 3\n' + - '}'; - - const expectedAnswer = 4; - const expectedChoices = [ - { content: 'Θ(1)', hint: 'one' }, - { content: 'Θ(log _n_)', hint: 'two' }, - { content: 'Θ(_n_)', hint: '14345' }, - { content: 'Θ(_n_ log _n_)', hint: 'yes' }, - { content: 'Θ(_n_²)', hint: 'definitely wrong' }, - { content: 'Θ(_n_³)', hint: 'maybe' } - ]; - const expectedSolution = 3; - - const [isMCQText, mcqQuestion] = GitHubMissionDataUtils.convertToMCQQuestionIfMCQText(mcqText); - expect(isMCQText).toBe(true); - expect(mcqQuestion).toEqual({ - answer: expectedAnswer, - choices: expectedChoices, - solution: expectedSolution, - type: 'mcq', - content: '', - grade: 0, - id: 0, - library: { chapter: Chapter.SOURCE_4, external: { name: 'NONE', symbols: [] }, globals: [] }, - maxGrade: 0, - xp: 0, - maxXp: 0 - }); -}); - -test('convertIMCQQuestionToMCQText works properly', () => { - const studentAnswer = 4; - const correctAnswer = 2; - const possibleChoices = [ - { content: 'Θ(1)', hint: 'one' }, - { content: 'Θ(log _n_)', hint: 'two' }, - { content: 'Θ(_n_)', hint: '14345' }, - { content: 'Θ(_n_ log _n_)', hint: 'yes' }, - { content: 'Θ(_n_²)', hint: 'definitely wrong' }, - { content: 'Θ(_n_³)', hint: 'maybe' } - ]; - - const inputMCQObject = { - answer: studentAnswer, - choices: possibleChoices, - solution: correctAnswer, - type: 'mcq', - content: '', - grade: 0, - id: 0, - library: { chapter: Chapter.SOURCE_4, external: { name: 'NONE', symbols: [] }, globals: [] }, - maxGrade: 0, - xp: 0, - maxXp: 0 - } as IMCQQuestion; - - const expectedText = - 'MCQ\n' + - JSON.stringify( - { - choices: possibleChoices.map((choice: { content: string; hint: string }) => { - return { - option: choice.content, - hint: choice.hint - }; - }), - answer: studentAnswer, - solution: correctAnswer - }, - null, - 4 - ); - - expect(GitHubMissionDataUtils.convertIMCQQuestionToMCQText(inputMCQObject)).toEqual(expectedText); -}); - -function generateGitHubSubDirectory(name: string) { - return { - type: 'dummy', - size: 0, - name: name, - path: 'dummy', - sha: 'string', - url: 'string', - git_url: null, - html_url: null, - download_url: null, - _links: { - self: '', - git: null, - html: null - } - }; -} - -function generateGetContentResponse() { - return { - url: '', - status: 200 as const, - headers: {}, - data: { - type: 'file', - encoding: 'base64', - size: 0, - name: 'name', - path: 'path', - content: 'pain', - sha: '123', - url: 'www.eh', - git_url: null, - html_url: null, - download_url: null, - _links: { - self: '', - git: null, - html: null - } - } - } as any; -} - -const dummyMissionMetadata = { - sourceVersion: 1 -}; - -const defaultMissionMetadata = { - coverImage: '', - type: '', - id: '', - title: '', - sourceVersion: 1, - dueDate: new Date(8640000000000000), - reading: '', - webSummary: '' -}; diff --git a/src/commons/githubAssessments/__tests__/GitHubMissionSaveDialog.tsx b/src/commons/githubAssessments/__tests__/GitHubMissionSaveDialog.tsx deleted file mode 100644 index ea99ed2a3b..0000000000 --- a/src/commons/githubAssessments/__tests__/GitHubMissionSaveDialog.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { - GitHubMissionSaveDialog, - GitHubMissionSaveDialogResolution -} from '../GitHubMissionSaveDialog'; - -test('Selecting close causes resolveDialog to be called with false confirmSave and empty string commitMessage', async () => { - const repoName = 'dummy value'; - const changedFiles: string[] = []; - const filesToDelete: string[] = []; - let outsideValue = { confirmSave: true, commitMessage: 'a-commit-message' }; - function resolveDialog(insideValue: GitHubMissionSaveDialogResolution) { - outsideValue = insideValue; - } - - act(() => { - render( - - ); - }); - - await screen.findByText('Please confirm your save'); - - fireEvent.click(screen.getByText('Close')); - expect(outsideValue).toStrictEqual({ confirmSave: false, commitMessage: '' }); -}); - -test('Selecting save causes resolveDialog to be called with true confirmSave and empty string commitMessage', async () => { - const repoName = 'dummy value'; - const changedFiles: string[] = []; - const filesToDelete: string[] = []; - let outsideValue = { confirmSave: false, commitMessage: 'not-a-commit-message' }; - function resolveDialog(insideValue: GitHubMissionSaveDialogResolution) { - outsideValue = insideValue; - } - - act(() => { - render( - - ); - }); - - await screen.findByText('Please confirm your save'); - - fireEvent.click(screen.getByText('Confirm')); - expect(outsideValue).toStrictEqual({ confirmSave: true, commitMessage: '' }); -}); - -test('Selecting Confirm causes resolveDialog to be called with confirmSave = true and commitMessage = InputGroup value', async () => { - const repoName = 'dummy value'; - const changedFiles: string[] = ['Q1/StarterCode.js']; - const filesToDelete: string[] = ['Q2']; - let outsideValue = { confirmSave: false, commitMessage: 'not-a-commit-message' }; - function resolveDialog(insideValue: GitHubMissionSaveDialogResolution) { - outsideValue = insideValue; - } - - act(() => { - render( - - ); - }); - - await waitFor(() => expect(screen.getAllByText('Q1/StarterCode.js').length).toBe(1)); - await waitFor(() => expect(screen.getAllByText('Q2').length).toBe(1)); - await screen.findByText('Please confirm your save'); - await userEvent.type(screen.getByPlaceholderText('Enter Commit Message'), 'message'); - fireEvent.click(screen.getByText('Confirm')); - expect(outsideValue).toStrictEqual({ confirmSave: true, commitMessage: 'message' }); -}); diff --git a/src/commons/navigationBar/NavigationBar.tsx b/src/commons/navigationBar/NavigationBar.tsx index 78265d2d5e..df8c4ebdb9 100644 --- a/src/commons/navigationBar/NavigationBar.tsx +++ b/src/commons/navigationBar/NavigationBar.tsx @@ -153,12 +153,6 @@ const NavigationBar: React.FC = () => { text: 'Playground', disabled: !isEnrolledInACourse }, - { - to: '/githubassessments', - icon: IconNames.BRIEFCASE, - text: 'Classroom', - disabled: !Constants.enableGitHubAssessments - }, { to: '/sicpjs', icon: IconNames.BOOK, @@ -235,7 +229,6 @@ const NavigationBar: React.FC = () => { '/playground', '/sicpjs', '/contributors', - '/githubassessments', `/courses/${courseId}/sourcecast`, `/courses/${courseId}/achievements` ]; @@ -324,7 +317,6 @@ const NavigationBar: React.FC = () => { - @@ -348,12 +340,6 @@ const playgroundOnlyNavbarLeftInfo: NavbarEntryInfo[] = [ icon: IconNames.CODE, text: 'Playground' }, - { - to: '/githubassessments', - icon: IconNames.BRIEFCASE, - text: 'Classroom', - disabled: !Constants.enableGitHubAssessments - }, { to: '/sicpjs', icon: IconNames.BOOK, diff --git a/src/commons/navigationBar/subcomponents/GitHubAssessmentsNavigationBar.tsx b/src/commons/navigationBar/subcomponents/GitHubAssessmentsNavigationBar.tsx deleted file mode 100644 index 8ec75ad3b3..0000000000 --- a/src/commons/navigationBar/subcomponents/GitHubAssessmentsNavigationBar.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { - Alignment, - Button, - Classes, - Icon, - InputGroup, - Menu, - MenuItem, - Navbar, - NavbarGroup -} from '@blueprintjs/core'; -import { IconName, IconNames } from '@blueprintjs/icons'; -import { Popover2 } from '@blueprintjs/popover2'; -import { Octokit } from '@octokit/rest'; -import classNames from 'classnames'; -import React from 'react'; -import { NavLink } from 'react-router-dom'; - -import { GHAssessmentTypeOverview } from '../../../pages/githubAssessments/GitHubClassroom'; -import { ControlBarGitHubLoginButton } from '../../controlBar/github/ControlBarGitHubLoginButton'; -import { assessmentTypeLink } from '../../utils/ParamParseHelper'; - -type GitHubAssessmentsNavigationBarProps = DispatchProps & StateProps; - -type DispatchProps = { - changeCourseHandler: (e: any) => void; - handleGitHubLogIn: () => void; - handleGitHubLogOut: () => void; -}; - -type StateProps = { - octokit?: Octokit; - courses?: string[]; - selectedCourse: string; - types?: string[]; - assessmentTypeOverviews?: GHAssessmentTypeOverview[]; -}; - -/** - * The white navbar for the website. Should only be displayed when using GitHub-hosted missions. - */ -const GitHubAssessmentsNavigationBar: React.FC = props => { - const handleClick = (e: any) => { - props.changeCourseHandler(e); - }; - - return ( - - - {props.types?.map((type, idx) => { - return ( - - classNames('NavigationBar__link', Classes.BUTTON, Classes.MINIMAL, { - [Classes.ACTIVE]: isActive - }) - } - > - -
    {type}
    -
    - ); - })} -
    - - {props.octokit !== undefined && props.types && props.types.length > 0 && ( - - {props.courses?.map((course: string) => ( - - ))} - - } - placement={'bottom-end'} - > - - ), - [navigate] - ); - - const refreshButton = useMemo( - () => ( - - ), - [props.refreshAssessmentOverviews] - ); - - if (!props.assessmentOverviews) { - display = ( - <> - {createAssessmentButton} - } /> - - ); - } else if (props.assessmentOverviews.length === 0) { - display = ( - <> - {createAssessmentButton} - {refreshButton} - - - ); - } else { - // Create cards - const cards = props.assessmentOverviews.map(element => - convertAssessmentOverviewToCard(element, isMobileBreakpoint, navigate) - ); - display = ( - <> - {createAssessmentButton} - {refreshButton} - {cards} - - ); - } - - return ( -
    - {}} /> -
    - ); -}; - -/** - * Maps from a BrowsableMission object to a JSX card that can be displayed on the Mission Listing. - * - * @param missionRepo The BrowsableMission representation of a single mission repository - * @param isMobileBreakpoint Whether we are using mobile breakpoint - */ -function convertAssessmentOverviewToCard( - assessmentOverview: GHAssessmentOverview, - isMobileBreakpoint: boolean, - navigate: NavigateFunction -) { - const ratio = isMobileBreakpoint ? 5 : 3; - const ownerSlashName = - assessmentOverview.missionRepoData.repoOwner + - '/' + - assessmentOverview.missionRepoData.repoName; - const dueDate = assessmentOverview.dueDate.toDateString(); - - const hasDueDate = new Date(8640000000000000) > assessmentOverview.dueDate; - const isOverdue = new Date() > assessmentOverview.dueDate; - - const assessmentNotAccepted = assessmentOverview.link !== undefined; - let buttonText = 'Open'; - let handleClick = () => navigate(`/githubassessments/editor`, { state: assessmentOverview }); - - if (assessmentNotAccepted) { - buttonText = 'Accept'; - handleClick = () => window.open(assessmentOverview.link); - } else if (isOverdue) { - buttonText = 'Review Answers'; - } - - return ( -
    - -
    - Assessment -
    - -
    -
    - -

    {assessmentOverview.title}

    -
    {ownerSlashName}
    -
    -
    - -
    - -
    - -
    - - - {hasDueDate ? 'Due: ' + dueDate : 'No due date'} - -
    - -
    -
    -
    -
    -
    - ); -} - -export default GitHubAssessmentListing; diff --git a/src/pages/githubAssessments/GitHubAssessmentWorkspace.tsx b/src/pages/githubAssessments/GitHubAssessmentWorkspace.tsx deleted file mode 100644 index 2abb12be72..0000000000 --- a/src/pages/githubAssessments/GitHubAssessmentWorkspace.tsx +++ /dev/null @@ -1,1164 +0,0 @@ -import { - Button, - Card, - Classes, - Dialog, - NonIdealState, - Spinner, - SpinnerSize -} from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; -import { GetResponseTypeFromEndpointMethod } from '@octokit/types'; -import classNames from 'classnames'; -import { Chapter, Variant } from 'js-slang/dist/types'; -import { isEqual } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { useLocation, useNavigate } from 'react-router'; -import { SideContentProps } from 'src/commons/sideContent/SideContent'; -import { changeSideContentHeight } from 'src/commons/sideContent/SideContentActions'; -import { useSideContent } from 'src/commons/sideContent/SideContentHelper'; -import { useResponsive, useTypedSelector } from 'src/commons/utils/Hooks'; -import { - browseReplHistoryDown, - browseReplHistoryUp, - clearReplOutput, - evalEditor, - evalRepl, - evalTestcase, - navigateToDeclaration, - promptAutocomplete, - removeEditorTab, - runAllTestcases, - setEditorBreakpoint, - updateActiveEditorTabIndex, - updateEditorValue, - updateHasUnsavedChanges, - updateReplValue, - updateWorkspace -} from 'src/commons/workspace/WorkspaceActions'; -import classes from 'src/styles/GithubAssessments.module.scss'; - -import { ExternalLibraryName } from '../../commons/application/types/ExternalTypes'; -import { Testcase } from '../../commons/assessment/AssessmentTypes'; -import { ControlBarProps } from '../../commons/controlBar/ControlBar'; -import { ControlBarChapterSelect } from '../../commons/controlBar/ControlBarChapterSelect'; -import { ControlBarClearButton } from '../../commons/controlBar/ControlBarClearButton'; -import { ControlBarEvalButton } from '../../commons/controlBar/ControlBarEvalButton'; -import { ControlBarNextButton } from '../../commons/controlBar/ControlBarNextButton'; -import { ControlBarPreviousButton } from '../../commons/controlBar/ControlBarPreviousButton'; -import { ControlBarQuestionViewButton } from '../../commons/controlBar/ControlBarQuestionViewButton'; -import { ControlBarResetButton } from '../../commons/controlBar/ControlBarResetButton'; -import { ControlBarRunButton } from '../../commons/controlBar/ControlBarRunButton'; -import { ControlButtonSaveButton } from '../../commons/controlBar/ControlBarSaveButton'; -import { ControlBarDisplayMCQButton } from '../../commons/controlBar/github/ControlBarDisplayMCQButton'; -import { ControlBarTaskAddButton } from '../../commons/controlBar/github/ControlBarTaskAddButton'; -import { ControlBarTaskDeleteButton } from '../../commons/controlBar/github/ControlBarTaskDeleteButton'; -import { - convertEditorTabStateToProps, - NormalEditorContainerProps -} from '../../commons/editor/EditorContainer'; -import { Position } from '../../commons/editor/EditorTypes'; -import { - GitHubMissionCreateDialog, - GitHubMissionCreateDialogProps, - GitHubMissionCreateDialogResolution -} from '../../commons/githubAssessments/GitHubMissionCreateDialog'; -import { - convertIMCQQuestionToMCQText, - convertToMCQQuestionIfMCQText, - discoverFilesToBeChangedWithMissionRepoData, - discoverFilesToBeCreatedWithoutMissionRepoData, - getMissionData -} from '../../commons/githubAssessments/GitHubMissionDataUtils'; -import { - MissionData, - MissionMetadata, - MissionRepoData, - TaskData -} from '../../commons/githubAssessments/GitHubMissionTypes'; -import Markdown from '../../commons/Markdown'; -import { MobileSideContentProps } from '../../commons/mobileWorkspace/mobileSideContent/MobileSideContent'; -import MobileWorkspace, { - MobileWorkspaceProps -} from '../../commons/mobileWorkspace/MobileWorkspace'; -import SideContentMarkdownEditor from '../../commons/sideContent/content/githubAssessments/SideContentMarkdownEditor'; -import SideContentMissionEditor from '../../commons/sideContent/content/githubAssessments/SideContentMissionEditor'; -import SideContentTaskEditor from '../../commons/sideContent/content/githubAssessments/SideContentTaskEditor'; -import SideContentTestcaseEditor from '../../commons/sideContent/content/githubAssessments/SideContentTestcaseEditor'; -import { SideContentTab, SideContentType } from '../../commons/sideContent/SideContentTypes'; -import Constants from '../../commons/utils/Constants'; -import { promisifyDialog, showSimpleConfirmDialog } from '../../commons/utils/DialogHelper'; -import { showWarningMessage } from '../../commons/utils/notifications/NotificationsHelper'; -import Workspace, { WorkspaceProps } from '../../commons/workspace/Workspace'; -import { WorkspaceLocation, WorkspaceState } from '../../commons/workspace/WorkspaceTypes'; -import { - checkIfFileCanBeSavedAndGetSaveType, - getGitHubOctokitInstance, - performCreatingSave, - performFolderDeletion, - performOverwritingSave -} from '../../features/github/GitHubUtils'; -import { - defaultMCQQuestion, - defaultMissionBriefing, - defaultMissionMetadata, - defaultTask -} from './GitHubAssessmentDefaultValues'; -import { GHAssessmentOverview } from './GitHubClassroom'; - -const workspaceLocation: WorkspaceLocation = 'githubAssessment'; - -const GitHubAssessmentWorkspace: React.FC = () => { - const navigate = useNavigate(); - const dispatch = useDispatch(); - const location = useLocation(); - const octokit = getGitHubOctokitInstance(); - - if (octokit === undefined) { - navigate('/githubassessments/login'); - } - - // Handlers migrated over from deprecated withRouter implementation - const { - handleEditorEval, - handleEditorValueChange, - handleReplEval, - handleReplOutputClear, - handleUpdateHasUnsavedChanges, - handleUpdateWorkspace, - setActiveEditorTabIndex, - removeEditorTabByIndex - } = useMemo(() => { - return { - handleEditorEval: () => dispatch(evalEditor(workspaceLocation)), - handleEditorValueChange: (editorTabIndex: number, newEditorValue: string) => - dispatch(updateEditorValue(workspaceLocation, editorTabIndex, newEditorValue)), - handleReplEval: () => dispatch(evalRepl(workspaceLocation)), - handleReplOutputClear: () => dispatch(clearReplOutput(workspaceLocation)), - handleUpdateHasUnsavedChanges: (hasUnsavedChanges: boolean) => - dispatch(updateHasUnsavedChanges(workspaceLocation, hasUnsavedChanges)), - handleUpdateWorkspace: (options: Partial) => - dispatch(updateWorkspace(workspaceLocation, options)), - setActiveEditorTabIndex: (activeEditorTabIndex: number | null) => - dispatch(updateActiveEditorTabIndex(workspaceLocation, activeEditorTabIndex)), - removeEditorTabByIndex: (editorTabIndex: number) => - dispatch(removeEditorTab(workspaceLocation, editorTabIndex)) - }; - }, [dispatch]); - - /** - * State variables relating to information we are concerned with saving - */ - const [missionMetadata, setMissionMetadata] = useState(defaultMissionMetadata); - const [cachedMissionMetadata, setCachedMissionMetadata] = useState(defaultMissionMetadata); - const [hasUnsavedChangesToMetadata, setHasUnsavedChangesToMetadata] = useState(false); - - const [briefingContent, setBriefingContent] = useState(defaultMissionBriefing); - const [cachedBriefingContent, setCachedBriefingContent] = useState(defaultMissionBriefing); - const [hasUnsavedChangesToBriefing, setHasUnsavedChangesToBriefing] = useState(false); - - const [cachedTaskList, setCachedTaskList] = useState([]); - const [taskList, setTaskList] = useState([]); - const [hasUnsavedChangesToTasks, setHasUnsavedChangesToTasks] = useState(false); - - /** - * State variables relating to the rendering and function of the workspace - */ - const [summary, setSummary] = useState(''); - const [currentTaskNumber, setCurrentTaskNumber] = useState(0); - const [isTeacherMode, setIsTeacherMode] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [currentTaskIsMCQ, setCurrentTaskIsMCQ] = useState(false); - const [displayMCQInEditor, setDisplayMCQInEditor] = useState(true); - const [mcqQuestion, setMCQQuestion] = useState(defaultMCQQuestion); - const [missionRepoData, setMissionRepoData] = useState(undefined); - const assessmentOverview = location.state as GHAssessmentOverview; - - const [showBriefingOverlay, setShowBriefingOverlay] = useState(false); - const { selectedTab, setSelectedTab } = useSideContent( - workspaceLocation, - SideContentType.questionOverview - ); - const { isMobileBreakpoint } = useResponsive(); - - const { - isFolderModeEnabled, - activeEditorTabIndex, - editorTabs, - editorTestcases, - hasUnsavedChanges, - isRunning, - output, - replValue - } = useTypedSelector(state => state.workspaces.githubAssessment); - - /** - * Should be called to change the task number, rather than using setCurrentTaskNumber - */ - const changeStateDueToChangedTaskNumber = useCallback( - (newTaskNumber: number, currentTaskList: TaskData[]) => { - setCurrentTaskNumber(newTaskNumber); - const actualTaskIndex = newTaskNumber - 1; - - handleUpdateWorkspace({ - // TODO: Hardcoded to make use of the first editor tab. Rewrite after editor tabs are added. - editorTabs: [ - { - value: currentTaskList[actualTaskIndex].savedCode, - highlightedLines: [], - breakpoints: [] - } - ], - programPrependValue: currentTaskList[actualTaskIndex].testPrepend, - programPostpendValue: currentTaskList[actualTaskIndex].testPostpend, - editorTestcases: currentTaskList[actualTaskIndex].testCases - }); - handleReplOutputClear(); - - const [isMCQText, mcqQuestion] = convertToMCQQuestionIfMCQText( - currentTaskList[actualTaskIndex].savedCode - ); - - setCurrentTaskIsMCQ(isMCQText); - setMCQQuestion(mcqQuestion); - }, - [handleUpdateWorkspace, handleReplOutputClear] - ); - - const setBriefingContentWrapper = useCallback( - (newBriefingContent: string) => { - setBriefingContent(newBriefingContent); - setHasUnsavedChangesToBriefing(newBriefingContent !== cachedBriefingContent); - }, - [cachedBriefingContent] - ); - - const setMissionMetadataWrapper = useCallback( - (newMissionMetadata: MissionMetadata) => { - setMissionMetadata(newMissionMetadata); - setHasUnsavedChangesToMetadata(!isEqual(newMissionMetadata, cachedMissionMetadata)); - }, - [cachedMissionMetadata] - ); - - const setTaskListWrapper = useCallback( - (newTaskList: TaskData[]) => { - setTaskList(newTaskList); - setHasUnsavedChangesToTasks(!isEqual(newTaskList, cachedTaskList)); - }, - [cachedTaskList] - ); - - const setDisplayMCQInEditorWrapper = useCallback( - (shouldDisplayMCQ: boolean) => { - if (shouldDisplayMCQ) { - const [isMCQText, mcqQuestion] = convertToMCQQuestionIfMCQText( - taskList[currentTaskNumber - 1].savedCode - ); - setCurrentTaskIsMCQ(isMCQText); - setMCQQuestion(mcqQuestion); - } - - setDisplayMCQInEditor(shouldDisplayMCQ); - }, - [taskList, currentTaskNumber] - ); - - // Forces a re-render when saveable information changes, keeps environment state in-sync with component state - const computedHasUnsavedChanges = useMemo(() => { - return hasUnsavedChangesToMetadata || hasUnsavedChangesToBriefing || hasUnsavedChangesToTasks; - }, [hasUnsavedChangesToMetadata, hasUnsavedChangesToBriefing, hasUnsavedChangesToTasks]); - - useEffect(() => { - if (computedHasUnsavedChanges !== hasUnsavedChanges) { - handleUpdateHasUnsavedChanges(computedHasUnsavedChanges); - } - }, [computedHasUnsavedChanges, hasUnsavedChanges, handleUpdateHasUnsavedChanges]); - - /** - * Sets up the workspace for when the user is retrieving Mission information from a GitHub repository - */ - const setUpWithAssessmentOverview = useCallback(async () => { - if (octokit === undefined) return; - - const missionRepoData = assessmentOverview.missionRepoData; - - const missionDataPromise = getMissionData(missionRepoData, octokit); - const isTeacherModePromise = octokit.users.getAuthenticated().then((authenticatedUser: any) => { - const userLogin = authenticatedUser.data.login; - return userLogin === missionRepoData.repoOwner; - }); - - const promises = [missionDataPromise, isTeacherModePromise]; - - Promise.all(promises).then((promises: any[]) => { - setMissionRepoData(missionRepoData); - - setHasUnsavedChangesToTasks(false); - setHasUnsavedChangesToBriefing(false); - setHasUnsavedChangesToMetadata(false); - handleUpdateHasUnsavedChanges(false); - - const missionData: MissionData = promises[0]; - setSummary(missionData.missionBriefing); - - setMissionMetadata(missionData.missionMetadata); - setCachedMissionMetadata(missionData.missionMetadata); - - setBriefingContent(missionData.missionBriefing); - setCachedBriefingContent(missionData.missionBriefing); - - setTaskList(missionData.tasksData); - setCachedTaskList( - missionData.tasksData.map((taskData: TaskData) => Object.assign({}, taskData)) - ); - - changeStateDueToChangedTaskNumber(1, missionData.tasksData); - - const isTeacherMode: boolean = promises[1]; - setIsTeacherMode(isTeacherMode); - - setIsLoading(false); - }); - }, [ - assessmentOverview, - octokit, - changeStateDueToChangedTaskNumber, - handleUpdateHasUnsavedChanges - ]); - - /** - * Sets up the workspace for when the user is creating a new Mission - */ - const setUpWithoutAssessmentOverview = useCallback(() => { - setSummary(defaultMissionBriefing); - - setMissionMetadata(defaultMissionMetadata); - setCachedMissionMetadata(defaultMissionMetadata); - - setBriefingContent(defaultMissionBriefing); - setCachedBriefingContent(defaultMissionBriefing); - - setTaskList([defaultTask]); - setCachedTaskList([defaultTask]); - - setDisplayMCQInEditor(false); - changeStateDueToChangedTaskNumber(1, [defaultTask]); - - setHasUnsavedChangesToTasks(false); - setHasUnsavedChangesToBriefing(false); - setHasUnsavedChangesToMetadata(false); - handleUpdateHasUnsavedChanges(false); - - setIsTeacherMode(true); - setIsLoading(false); - }, [changeStateDueToChangedTaskNumber, handleUpdateHasUnsavedChanges]); - - useEffect(() => { - if (assessmentOverview === undefined || assessmentOverview === null) { - setUpWithoutAssessmentOverview(); - } else { - setUpWithAssessmentOverview(); - } - }, [assessmentOverview, setUpWithAssessmentOverview, setUpWithoutAssessmentOverview]); - - const briefingOverlay = ( - - - - - ); - - /** - * Handles the changes to the taskList - */ - const editCode = useCallback( - (taskNumber: number, newValue: string) => { - if (taskNumber > taskList.length) { - return; - } - const editedTaskList = taskList.map((taskData: TaskData) => Object.assign({}, taskData)); - editedTaskList[taskNumber - 1] = { - ...editedTaskList[taskNumber - 1], - savedCode: newValue - }; - - setTaskListWrapper(editedTaskList); - }, - [taskList, setTaskListWrapper] - ); - - const resetToTemplate = useCallback(() => { - const originalCode = taskList[currentTaskNumber - 1].starterCode; - // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. - handleEditorValueChange(0, originalCode); - editCode(currentTaskNumber, originalCode); - }, [currentTaskNumber, editCode, handleEditorValueChange, taskList]); - - const conductSave = useCallback( - async ( - changedFile: string, - newFileContent: string, - githubName: string | null, - githubEmail: string | null, - commitMessage: string - ) => { - const typedMissionRepoData = missionRepoData as MissionRepoData; - - const { saveType } = await checkIfFileCanBeSavedAndGetSaveType( - octokit, - typedMissionRepoData.repoOwner, - typedMissionRepoData.repoName, - changedFile - ); - - if (saveType === 'Overwrite') { - await performOverwritingSave( - octokit, - typedMissionRepoData.repoOwner, - typedMissionRepoData.repoName, - changedFile, - githubName, - githubEmail, - commitMessage, - newFileContent - ); - } - - if (saveType === 'Create') { - await performCreatingSave( - octokit, - typedMissionRepoData.repoOwner, - typedMissionRepoData.repoName, - changedFile, - githubName, - githubEmail, - commitMessage, - newFileContent - ); - } - }, - [missionRepoData, octokit] - ); - - const conductDelete = useCallback( - async ( - fileName: string, - githubName: string | null, - githubEmail: string | null, - commitMessage: string - ) => { - const typedMissionRepoData = missionRepoData as MissionRepoData; - - await performFolderDeletion( - octokit, - typedMissionRepoData.repoOwner, - typedMissionRepoData.repoName, - fileName, - githubName, - githubEmail, - commitMessage - ); - }, - [missionRepoData, octokit] - ); - - /** - * To be used when the information to be saved corresponds to an existing GitHub repository - */ - const saveWithMissionRepoData = useCallback(async () => { - if (octokit === undefined) { - showWarningMessage('Please sign in with GitHub!', 2000); - return; - } - - const [filenameToContentMap, foldersToDelete] = discoverFilesToBeChangedWithMissionRepoData( - missionMetadata, - cachedMissionMetadata, - briefingContent, - cachedBriefingContent, - taskList, - cachedTaskList, - isTeacherMode - ); - const changedFiles = Object.keys(filenameToContentMap); - - type GetAuthenticatedResponse = GetResponseTypeFromEndpointMethod< - typeof octokit.users.getAuthenticated - >; - const authUser: GetAuthenticatedResponse = await octokit.users.getAuthenticated(); - const githubName = authUser.data.name; - const githubEmail = authUser.data.email; - const commitMessage = ''; - - for (let i = 0; i < foldersToDelete.length; i++) { - await conductDelete(foldersToDelete[i], githubName, githubEmail, commitMessage); - } - - for (let i = 0; i < changedFiles.length; i++) { - const filename = changedFiles[i]; - const newFileContent = filenameToContentMap[filename]; - await conductSave(filename, newFileContent, githubName, githubEmail, commitMessage); - } - - setCachedTaskList(taskList); - setCachedBriefingContent(briefingContent); - setCachedMissionMetadata(missionMetadata); - - setHasUnsavedChangesToTasks(false); - setHasUnsavedChangesToBriefing(false); - setHasUnsavedChangesToMetadata(false); - }, [ - octokit, - isTeacherMode, - briefingContent, - missionMetadata, - taskList, - cachedTaskList, - cachedBriefingContent, - cachedMissionMetadata, - conductSave, - conductDelete - ]); - - /** - * To be used when the information to be saved to a new GitHub repository - */ - const saveWithoutMissionRepoData = useCallback(async () => { - if (octokit === undefined) { - showWarningMessage('Please sign in with GitHub!', 2000); - return; - } - - const filenameToContentMap = discoverFilesToBeCreatedWithoutMissionRepoData( - missionMetadata, - briefingContent, - taskList - ); - - const changedFiles = Object.keys(filenameToContentMap).sort(); - const authUser = await octokit.users.getAuthenticated(); - - const dialogResults = await promisifyDialog< - GitHubMissionCreateDialogProps, - GitHubMissionCreateDialogResolution - >(GitHubMissionCreateDialog, resolve => ({ - filesToCreate: changedFiles, - userLogin: authUser.data.login, - resolveDialog: dialogResults => resolve(dialogResults) - })); - - if (!dialogResults.confirmSave) { - return; - } - - const githubName = authUser.data.name; - const githubEmail = authUser.data.email; - const repoName = dialogResults.repoName; - const repoOwner = authUser.data.login; - - try { - await octokit.repos.createForAuthenticatedUser({ - name: repoName - }); - - for (let i = 0; i < changedFiles.length; i++) { - const fileToCreate = changedFiles[i]; - const fileContent = filenameToContentMap[fileToCreate]; - - await performCreatingSave( - octokit, - repoOwner, - repoName, - fileToCreate, - githubName, - githubEmail, - 'Repository created from Source Academy', - fileContent - ); - } - - setCachedTaskList(taskList); - setCachedBriefingContent(briefingContent); - setCachedMissionMetadata(missionMetadata); - - setHasUnsavedChangesToTasks(false); - setHasUnsavedChangesToBriefing(false); - setHasUnsavedChangesToMetadata(false); - - setMissionRepoData({ - repoOwner: repoOwner, - repoName: repoName, - dateOfCreation: new Date() - }); - } catch (err) { - console.error(err); - showWarningMessage('Something went wrong while creating the repository!', 2000); - } - }, [briefingContent, missionMetadata, octokit, taskList]); - - const onClickSave = useCallback(() => { - if (assessmentOverview !== undefined && new Date() > assessmentOverview.dueDate) { - showWarningMessage('It is past the due date for this assessment!'); - return; - } - - if (missionRepoData !== undefined) { - saveWithMissionRepoData(); - } else { - saveWithoutMissionRepoData(); - } - }, [assessmentOverview, missionRepoData, saveWithMissionRepoData, saveWithoutMissionRepoData]); - - const onClickReset = useCallback(async () => { - const confirmReset = await showSimpleConfirmDialog({ - contents: ( -
    - - -
    - ), - negativeLabel: 'Cancel', - positiveIntent: 'primary', - positiveLabel: 'Confirm' - }); - - if (confirmReset) { - resetToTemplate(); - } - }, [resetToTemplate]); - - /** - * Checks to ensure that the user wants to discard their current changes - */ - const shouldProceedToChangeTask = useCallback( - ( - currentTaskNumber: number, - taskList: TaskData[], - cachedTaskList: TaskData[], - missionRepoData: MissionRepoData - ) => { - if (missionRepoData === undefined) { - return true; - } - - const taskIndex = currentTaskNumber - 1; - if (!isEqual(taskList[taskIndex], cachedTaskList[taskIndex])) { - return window.confirm( - 'You have unsaved changes to the current question. Are you sure you want to continue?' - ); - } - - return true; - }, - [] - ); - - const onClickPrevious = useCallback(() => { - if ( - shouldProceedToChangeTask( - currentTaskNumber, - taskList, - cachedTaskList, - missionRepoData as MissionRepoData - ) - ) { - let activeTaskList = taskList; - if (missionRepoData !== undefined) { - activeTaskList = cachedTaskList.map((taskData: TaskData) => Object.assign({}, taskData)); - setTaskListWrapper(activeTaskList); - } - const newTaskNumber = currentTaskNumber - 1; - changeStateDueToChangedTaskNumber(newTaskNumber, activeTaskList); - } - }, [ - currentTaskNumber, - taskList, - cachedTaskList, - missionRepoData, - setTaskListWrapper, - shouldProceedToChangeTask, - changeStateDueToChangedTaskNumber - ]); - - const onClickNext = useCallback(() => { - if ( - shouldProceedToChangeTask( - currentTaskNumber, - taskList, - cachedTaskList, - missionRepoData as MissionRepoData - ) - ) { - let activeTaskList = taskList; - if (missionRepoData !== undefined) { - activeTaskList = cachedTaskList.map((taskData: TaskData) => Object.assign({}, taskData)); - setTaskListWrapper(activeTaskList); - } - const newTaskNumber = currentTaskNumber + 1; - changeStateDueToChangedTaskNumber(newTaskNumber, activeTaskList); - } - }, [ - currentTaskNumber, - taskList, - cachedTaskList, - missionRepoData, - setTaskListWrapper, - shouldProceedToChangeTask, - changeStateDueToChangedTaskNumber - ]); - - const onClickReturn = useCallback(() => { - navigate('/githubassessments/missions'); - }, [navigate]); - - /** - * Handles toggling of relevant SideContentTabs when mobile breakpoint it hit - */ - useEffect(() => { - if ( - !isMobileBreakpoint && - (selectedTab === SideContentType.mobileEditor || - selectedTab === SideContentType.mobileEditorRun) - ) { - setSelectedTab(SideContentType.questionOverview); - } - }, [isMobileBreakpoint, selectedTab, setSelectedTab]); - - const onEditorValueChange = useCallback( - (editorTabIndex: number, val: string) => { - // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. - handleEditorValueChange(0, val); - editCode(currentTaskNumber, val); - }, - [currentTaskNumber, editCode, handleEditorValueChange] - ); - - const onChangeTabs = ( - newTabId: SideContentType, - prevTabId: SideContentType, - event: React.MouseEvent - ) => { - if (newTabId === prevTabId) { - return; - } - setSelectedTab(newTabId); - }; - - /** - * handleEval used in both the Run button, and during 'shift-enter' in AceEditor - * - * However, AceEditor only binds commands on mount (https://github.com/securingsincity/react-ace/issues/684) - * Thus, we use a mutable ref to overcome the stale closure problem - */ - const activeTab = useRef(selectedTab); - activeTab.current = selectedTab; - const handleEval = () => { - handleEditorEval(); - - // Run testcases when the GitHub testcases tab is selected - if (activeTab.current === SideContentType.testcases) { - dispatch(runAllTestcases(workspaceLocation)); - } - }; - - const setTaskDescriptions = useCallback( - (newTaskDescriptions: string[]) => { - const newTaskList: TaskData[] = []; - - for (let i = 0; i < taskList.length; i++) { - const nextElement = Object.assign({}, taskList[i]) as TaskData; - nextElement.taskDescription = newTaskDescriptions[i]; - newTaskList.push(nextElement); - } - - setTaskListWrapper(newTaskList); - }, - [taskList, setTaskListWrapper] - ); - - const setTaskTestcases = useCallback( - (newTestcases: Testcase[]) => { - const editedTaskList = taskList.map((taskData: TaskData) => Object.assign({}, taskData)); - editedTaskList[currentTaskNumber - 1] = { - ...editedTaskList[currentTaskNumber - 1], - testCases: newTestcases - }; - - handleUpdateWorkspace({ - editorTestcases: editedTaskList[currentTaskNumber - 1].testCases - }); - - setTaskListWrapper(editedTaskList); - }, - [currentTaskNumber, taskList, handleUpdateWorkspace, setTaskListWrapper] - ); - - const setTestPrepend = useCallback( - (newTestPrepend: string) => { - const editedTaskList = taskList.map((taskData: TaskData) => Object.assign({}, taskData)); - editedTaskList[currentTaskNumber - 1] = { - ...editedTaskList[currentTaskNumber - 1], - testPrepend: newTestPrepend - }; - setTaskListWrapper(editedTaskList); - }, - [currentTaskNumber, taskList, setTaskListWrapper] - ); - - const setTestPostpend = useCallback( - (newTestPostpend: string) => { - const editedTaskList = taskList.map((taskData: TaskData) => Object.assign({}, taskData)); - editedTaskList[currentTaskNumber - 1] = { - ...editedTaskList[currentTaskNumber - 1], - testPostpend: newTestPostpend - }; - setTaskListWrapper(editedTaskList); - }, - [currentTaskNumber, taskList, setTaskListWrapper] - ); - - const sideContentProps = (): SideContentProps => { - const tabs: SideContentTab[] = [ - { - label: 'Task', - iconName: IconNames.NINJA, - body: ( - taskData.taskDescription)} - setTaskDescriptions={setTaskDescriptions} - /> - ), - id: SideContentType.questionOverview - }, - { - label: 'Briefing', - iconName: IconNames.BRIEFCASE, - body: ( - - ), - id: SideContentType.briefing - } - ]; - - const taskIndex = currentTaskNumber - 1; - const testPrepend = taskList[taskIndex] ? taskList[taskIndex].testPrepend : ''; - const testPostpend = taskList[taskIndex] ? taskList[taskIndex].testPostpend : ''; - tabs.push({ - label: 'Testcases', - iconName: IconNames.AIRPLANE, - body: ( - dispatch(evalTestcase(workspaceLocation, testcaseId))} - /> - ), - id: SideContentType.testcases - }); - - if (isTeacherMode) { - // Teachers have ability to edit mission metadata - tabs.push({ - label: 'Mission Metadata', - iconName: IconNames.BUILD, - body: ( - - ), - id: SideContentType.missionMetadata - }); - } - - return { - selectedTabId: selectedTab, - tabs: { - beforeDynamicTabs: tabs, - afterDynamicTabs: [] - }, - onChange: onChangeTabs, - workspaceLocation: workspaceLocation - }; - }; - - const addNewQuestion = () => { - const newTaskList = taskList - .slice(0, currentTaskNumber) - .concat([defaultTask]) - .concat(taskList.slice(currentTaskNumber, taskList.length)); - setTaskListWrapper(newTaskList); - - const newTaskNumber = currentTaskNumber + 1; - changeStateDueToChangedTaskNumber(newTaskNumber, newTaskList); - }; - - const deleteCurrentQuestion = () => { - const deleteAtIndex = currentTaskNumber - 1; - - const newTaskList = taskList - .slice(0, deleteAtIndex) - .concat(taskList.slice(currentTaskNumber, taskList.length)); - setTaskListWrapper(newTaskList); - - const newTaskNumber = currentTaskNumber === 1 ? currentTaskNumber : currentTaskNumber - 1; - changeStateDueToChangedTaskNumber(newTaskNumber, newTaskList); - }; - - const controlBarProps: () => ControlBarProps = () => { - const runButton = ( - - ); - - const saveButton = ( - - ); - - const resetButton = ; - - const chapterSelect = ( - {}} - isFolderModeEnabled={isFolderModeEnabled} - sourceChapter={missionMetadata.sourceVersion} - sourceVariant={Constants.defaultSourceVariant} - disabled={true} - key="chapter" - /> - ); - - const nextButton = ( - - ); - - const previousButton = ( - - ); - - const questionView = ( - - ); - - const editorButtons = !isMobileBreakpoint - ? [runButton, saveButton, resetButton, chapterSelect] - : [saveButton, resetButton]; - - if (isTeacherMode) { - const addTaskButton = ( - - ); - const deleteTaskButton = ( - - ); - const showMCQButton = ( - setDisplayMCQInEditorWrapper(true)} - displayTextInEditor={() => setDisplayMCQInEditorWrapper(false)} - mcqDisplayed={displayMCQInEditor} - key="display MCQ" - /> - ); - - editorButtons.push(addTaskButton, deleteTaskButton, showMCQButton); - } - - const flowButtons = [previousButton, questionView, nextButton]; - - return { - editorButtons: editorButtons, - flowButtons: flowButtons - }; - }; - - const mobileSideContentProps: () => MobileSideContentProps = () => { - const onChangeTabs = ( - newTabId: SideContentType, - prevTabId: SideContentType, - event: React.MouseEvent - ) => { - if (newTabId === prevTabId) { - return; - } - - // Do nothing when clicking the mobile 'Run' tab while on the testcases tab. - if ( - !(prevTabId === SideContentType.testcases && newTabId === SideContentType.mobileEditorRun) - ) { - setSelectedTab(newTabId); - } - }; - - const sideContent = sideContentProps(); - - return { - mobileControlBarProps: { - ...controlBarProps() - }, - ...sideContent, - onChange: onChangeTabs, - selectedTabId: selectedTab, - handleEditorEval: handleEval - }; - }; - - const replButtons = () => { - const clearButton = ( - - ); - - const evalButton = ( - - ); - - return [evalButton, clearButton]; - }; - - const handleMCQSubmit = useCallback( - (choiceId: number) => { - if (!currentTaskIsMCQ) { - return; - } - - const newMCQQuestion = Object.assign({}, mcqQuestion); - newMCQQuestion.answer = choiceId; - - setMCQQuestion(newMCQQuestion); - editCode(currentTaskNumber, convertIMCQQuestionToMCQText(newMCQQuestion)); - }, - [currentTaskIsMCQ, currentTaskNumber, editCode, mcqQuestion] - ); - - const mcqProps = useMemo(() => { - return currentTaskIsMCQ && displayMCQInEditor - ? { - mcq: mcqQuestion, - handleMCQSubmit: handleMCQSubmit - } - : undefined; - }, [currentTaskIsMCQ, displayMCQInEditor, mcqQuestion, handleMCQSubmit]); - - const editorContainerProps: NormalEditorContainerProps = { - editorVariant: 'normal', - isFolderModeEnabled, - activeEditorTabIndex, - setActiveEditorTabIndex, - removeEditorTabByIndex, - editorTabs: editorTabs.map(convertEditorTabStateToProps), - editorSessionId: '', - handleDeclarationNavigate: (cursorPosition: Position) => - dispatch(navigateToDeclaration(workspaceLocation, cursorPosition)), - handleEditorEval: handleEval, - handleEditorValueChange: onEditorValueChange, - handleUpdateHasUnsavedChanges: handleUpdateHasUnsavedChanges, - handleEditorUpdateBreakpoints: (editorTabIndex: number, newBreakpoints: string[]) => - dispatch(setEditorBreakpoint(workspaceLocation, editorTabIndex, newBreakpoints)), - handlePromptAutocomplete: (row: number, col: number, callback: any) => - dispatch(promptAutocomplete(workspaceLocation, row, col, callback)), - isEditorAutorun: false - }; - const replProps = { - handleBrowseHistoryDown: () => dispatch(browseReplHistoryDown(workspaceLocation)), - handleBrowseHistoryUp: () => dispatch(browseReplHistoryUp(workspaceLocation)), - handleReplEval: handleReplEval, - handleReplValueChange: (newValue: string) => - dispatch(updateReplValue(newValue, workspaceLocation)), - output: output, - replValue: replValue, - sourceChapter: missionMetadata.sourceVersion || Chapter.SOURCE_4, - sourceVariant: Variant.DEFAULT, - externalLibrary: ExternalLibraryName.NONE, - replButtons: replButtons() - }; - const sideBarProps = { - tabs: [] - }; - - const workspaceProps: WorkspaceProps = { - controlBarProps: controlBarProps(), - editorContainerProps: currentTaskIsMCQ && displayMCQInEditor ? undefined : editorContainerProps, - handleSideContentHeightChange: heightChange => - dispatch(changeSideContentHeight(heightChange, workspaceLocation)), - hasUnsavedChanges: hasUnsavedChanges, - mcqProps: mcqProps, - sideBarProps: sideBarProps, - sideContentProps: sideContentProps(), - replProps: replProps - }; - const mobileWorkspaceProps: MobileWorkspaceProps = { - editorContainerProps: currentTaskIsMCQ && displayMCQInEditor ? undefined : editorContainerProps, - replProps: replProps, - sideBarProps: sideBarProps, - hasUnsavedChanges: hasUnsavedChanges, - mcqProps: mcqProps, - mobileSideContentProps: mobileSideContentProps() - }; - - if (isLoading) { - return ( -
    - } /> -
    - ); - } else { - return ( -
    - {briefingOverlay} - {isMobileBreakpoint ? ( - - ) : ( - - )} -
    - ); - } -}; - -export default GitHubAssessmentWorkspace; diff --git a/src/pages/githubAssessments/GitHubClassroom.tsx b/src/pages/githubAssessments/GitHubClassroom.tsx deleted file mode 100644 index f65642512c..0000000000 --- a/src/pages/githubAssessments/GitHubClassroom.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { NonIdealState, Spinner } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; -import { Octokit } from '@octokit/rest'; -import { GetResponseDataTypeFromEndpointMethod } from '@octokit/types'; -import React, { useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; -import { loginGitHub, logoutGitHub } from 'src/commons/application/actions/SessionActions'; -import { useTypedSelector } from 'src/commons/utils/Hooks'; -import academyClasses from 'src/styles/Academy.module.scss'; - -import ContentDisplay from '../../commons/ContentDisplay'; -import { MissionRepoData } from '../../commons/githubAssessments/GitHubMissionTypes'; -import GitHubAssessmentsNavigationBar from '../../commons/navigationBar/subcomponents/GitHubAssessmentsNavigationBar'; -import { showWarningMessage } from '../../commons/utils/notifications/NotificationsHelper'; -import { assessmentTypeLink } from '../../commons/utils/ParamParseHelper'; -import GitHubAssessmentListing from './GitHubAssessmentListing'; -import GitHubAssessmentWorkspace from './GitHubAssessmentWorkspace'; -import GitHubClassroomWelcome from './GitHubClassroomWelcome'; - -const RelativeRoutes = { - GITHUB_LOGIN: 'login', - GITHUB_WELCOME: 'welcome', - GITHUB_EDITOR: 'editor', - getAssessmentTypeLink: assessmentTypeLink -}; - -type GitHubClassroomLocationState = - | { - courses: string[] | undefined; - assessmentTypeOverviews: GHAssessmentTypeOverview[] | undefined; - selectedCourse: string | undefined; - } - | undefined; - -/** - * A page that lists the missions available to the authenticated user. - * This page should only be reachable if using a GitHub-hosted deployment. - */ -const GitHubClassroom: React.FC = () => { - const location = useLocation(); - const locationState = location.state as GitHubClassroomLocationState; - - const octokit: Octokit | undefined = useTypedSelector( - store => store.session.githubOctokitObject - ).octokit; - const dispatch = useDispatch(); - const handleGitHubLogIn = () => dispatch(loginGitHub()); - const handleGitHubLogOut = () => dispatch(logoutGitHub()); - - const [courses, setCourses] = useState(locationState?.courses); - const [selectedCourse, setSelectedCourse] = useState(locationState?.selectedCourse || ''); - const [assessmentTypeOverviews, setAssessmentTypeOverviews] = useState< - GHAssessmentTypeOverview[] | undefined - >(locationState?.assessmentTypeOverviews); - const types = assessmentTypeOverviews?.map(overview => overview.typeName); - - useEffect(() => { - if (octokit === undefined) { - return; - } - - if (!courses) { - fetchCourses(octokit, setCourses, setSelectedCourse, setAssessmentTypeOverviews); - } - }, [courses, octokit]); - - const changeCourseHandler = React.useCallback( - (e: any) => { - if (octokit === undefined) { - return; - } - - fetchAssessmentOverviews(octokit, e.target.innerText, setAssessmentTypeOverviews); - setSelectedCourse(e.target.innerText); - }, - [octokit, setSelectedCourse, setAssessmentTypeOverviews] - ); - - const refreshAssessmentOverviews = () => { - if (octokit === undefined) { - return; - } - - fetchAssessmentOverviews(octokit, selectedCourse, setAssessmentTypeOverviews); - }; - - const navigateToLogin = ; - const navigateToAssessments = ( - - ); - - return ( -
    - { - handleGitHubLogOut(); - setCourses(undefined); - setAssessmentTypeOverviews(undefined); - setSelectedCourse(''); - }} - octokit={octokit} - courses={courses} - selectedCourse={selectedCourse} - types={types} - assessmentTypeOverviews={assessmentTypeOverviews} - /> - - 0 && !assessmentTypeOverviews)) ? ( - } />} - loadContentDispatch={() => {}} - /> - ) : octokit && courses && courses.length === 0 ? ( - - ) : octokit ? ( - navigateToAssessments - ) : ( - - } - loadContentDispatch={() => {}} - /> - ) - } - /> - : navigateToLogin} - /> - } /> - {octokit - ? types?.map((type, idx) => { - const filteredAssessments = assessmentTypeOverviews - ? assessmentTypeOverviews[idx].assessments - : undefined; - return ( - - } - key={idx} - /> - ); - }) - : null} - 0 ? navigateToAssessments : navigateToLogin - } - /> - -
    - ); -}; - -/** - * Retrieves list of organizations for the authenticated user. - * - * @param octokit The Octokit instance for the authenticated user - * @param setCourses The React setter function for an array of courses string names - * @param setSelectedCourse The React setter function for string name of selected course - */ -async function fetchCourses( - octokit: Octokit, - setCourses: (courses: string[]) => void, - setSelectedCourse: (course: string) => void, - setAssessmentTypeOverviews: (assessmentTypeOverviews: GHAssessmentTypeOverview[]) => void -) { - const courses: string[] = []; - const results = (await octokit.orgs.listForAuthenticatedUser({ per_page: 100 })).data; - const orgs = results.filter(org => org.login.includes('source-academy-course')); // filter only organisations with 'source-academy-course' in name - orgs.forEach(org => { - courses.push(org.login.replace('source-academy-course-', '')); - }); - setCourses(courses); - if (courses.length > 0) { - setSelectedCourse(courses[0]); - fetchAssessmentOverviews(octokit, courses[0], setAssessmentTypeOverviews); - } -} - -export type GHAssessmentTypeOverview = { - typeName: string; - assessments: GHAssessmentOverview[]; -}; - -export type GHAssessmentOverview = { - title: string; - coverImage: string; - webSummary: string; - missionRepoData: MissionRepoData; - dueDate: Date; - link?: string; -}; - -type GitHubAssessment = { - id: string; - title: string; - openAt: string; - closeAt: string; - published: string; - coverImage: string; - shortSummary: string; - acceptLink: string; - repoPrefix: string; -}; - -async function fetchAssessmentOverviews( - octokit: Octokit, - selectedCourse: string, - setAssessmentTypeOverviews: (assessmentTypeOverviews: GHAssessmentTypeOverview[]) => void -) { - const userLogin = (await octokit.users.getAuthenticated()).data.login; - const orgLogin = 'source-academy-course-'.concat(selectedCourse); - type ListForAuthenticatedUserData = GetResponseDataTypeFromEndpointMethod< - typeof octokit.repos.listForAuthenticatedUser - >; - const userRepos: ListForAuthenticatedUserData = ( - await octokit.repos.listForAuthenticatedUser({ per_page: 100 }) - ).data; - const courseRepos = userRepos.filter(repo => repo.owner!.login === orgLogin); - const courseInfoRepo = courseRepos.find(repo => repo.name.includes('course-info')); - - if (courseInfoRepo === undefined) { - showWarningMessage('The course-info repository cannot be located.', 2000); - return; - } - - const files = ( - await octokit.repos.getContent({ - owner: courseInfoRepo.owner!.login, - repo: courseInfoRepo.name, - path: '' - }) - ).data; - - if (Array.isArray(files)) { - if (files.find(file => file.name === 'course-info.json')) { - const result = await octokit.repos.getContent({ - owner: courseInfoRepo.owner!.login, - repo: courseInfoRepo.name, - path: 'course-info.json' - }); - - const courseInfo = JSON.parse(Buffer.from((result.data as any).content, 'base64').toString()); - - courseInfo.types.forEach((type: { typeName: string; assessments: [GitHubAssessment] }) => { - const assessmentOverviews: GHAssessmentOverview[] = []; - type.assessments.forEach((assessment: GitHubAssessment) => { - if (!assessment.published) { - return; - } - - const prefixLogin = assessment.repoPrefix + '-' + userLogin; - const missionRepo = userRepos.find(repo => repo.name === prefixLogin); - - let createdAt = new Date(); - let acceptLink = undefined; - if (missionRepo === undefined) { - acceptLink = assessment.acceptLink; - } else { - if (missionRepo.created_at !== null) { - createdAt = new Date(missionRepo.created_at); - } - } - - assessmentOverviews.push({ - title: assessment.title, - coverImage: assessment.coverImage, - webSummary: assessment.shortSummary, - missionRepoData: { - repoOwner: courseInfoRepo.owner!.login, - repoName: prefixLogin, - dateOfCreation: createdAt - }, - dueDate: new Date(assessment.closeAt), - link: acceptLink - }); - - assessmentOverviews.sort((a, b) => (a.dueDate <= b.dueDate ? -1 : 1)); - }); - (type as any).assessments = assessmentOverviews; - }); - setAssessmentTypeOverviews(courseInfo.types); - } else { - showWarningMessage('The course-info.json file cannot be located.', 2000); - return; - } - } -} - -// react-router lazy loading -// https://reactrouter.com/en/main/route/lazy -export const Component = GitHubClassroom; -Component.displayName = 'GitHubClassroom'; - -export default GitHubClassroom; diff --git a/src/pages/githubAssessments/GitHubClassroomWelcome.tsx b/src/pages/githubAssessments/GitHubClassroomWelcome.tsx deleted file mode 100644 index e45cafc1a3..0000000000 --- a/src/pages/githubAssessments/GitHubClassroomWelcome.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Card, H2, H4, UL } from '@blueprintjs/core'; - -const GitHubClassroomWelcome: React.FC = () => { - return ( -
    - -
    -
    -

    Welcome to Source Academy with GitHub Classroom!

    -
    -

    Source Academy does not find any course information for this account.

    -
    -
      -
    • - If you are enrolled in a Source Academy course that uses GitHub Classroom, check - with the course staff to make sure your account is added to the course. -
    • -
    • - If you are looking for a course to join, check{' '} - here to find a - course that suits your needs. -
    • -
    -
    -
    -
    -
    -
    - ); -}; - -export default GitHubClassroomWelcome; diff --git a/src/pages/githubAssessments/__tests__/GitHubClassroom.tsx b/src/pages/githubAssessments/__tests__/GitHubClassroom.tsx deleted file mode 100644 index 283f3ea50e..0000000000 --- a/src/pages/githubAssessments/__tests__/GitHubClassroom.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { act } from '@testing-library/react'; -import { useSelector } from 'react-redux'; -import { shallowRender } from 'src/commons/utils/TestUtils'; - -import GitHubClassroom from '../GitHubClassroom'; - -type objectPerPage = { - per_page: number; -}; - -async function listOrgsForAuthenticatedUser(orgProp: objectPerPage) { - return { - data: [ - { login: 'source-academy-course-test' }, - { login: 'not' }, - { login: 'source-academy-course-second' }, - { login: 'valid' }, - { login: 'source-academy-course-third' } - ] - }; -} - -async function listReposForAuthenticatedUser(repoProp: objectPerPage) { - return { - data: [ - { name: 'course-info', owner: { login: 'source-academy-course-test' } }, - { name: 'sa-test-mission', owner: { login: 'source-academy-course-test' } }, - { name: 'sa-demo-mission', owner: { login: 'source-academy-course-test' } }, - { name: 'sa-autotester-test', owner: { login: 'source-academy-course-test' } } - ] - }; -} - -const mockCourseInfo = - 'ewogICJDb3Vyc2VOYW1lIjogIkNTMTEwMVMiLAogICJ0eXBlcyI6CiAgWwogICAgewogICAgICAidHlwZU5hbWUiOiAiTm90TWlzc2lvbnMiLAogICAgICAiYXNzZXNzbWVudHMiOgogICAgICBbCiAgICAgICAgewogICAgICAgICAgImlkIjogIjEiLAogICAgICAgICAgInRpdGxlIjogIkN1cnZlIEludHJvZHVjdGlvbiIsCiAgICAgICAgICAib3BlbkF0IjogIjIwMjAtMTItMDFUMDA6MDA6MDArMDg6MDAiLAogICAgICAgICAgImNsb3NlQXQiOiAiMjAyMS0xMi0zMVQyMzo1OTo1OSswODowMCIsCiAgICAgICAgICAicHVibGlzaGVkIjogInllcyIsCiAgICAgICAgICAiY292ZXJJbWFnZSI6ICJodHRwczovL2kuaW1ndXIuY29tL3EyTzRpd2EucG5nIiwKICAgICAgICAgICJzaG9ydFN1bW1hcnkiOiAiSW4gdGhpcyBtaXNzaW9uLCB5b3UgZ2V0IGludHJvZHVjZWQgdG8gdmlzaWJsZSBmdW5jdGlvbnMsIGNhbGxlZCBDdXJ2ZXMhIiwKICAgICAgICAgICJhY2NlcHRMaW5rIjogImh0dHBzOi8vY2xhc3Nyb29tLmdpdGh1Yi5jb20vYS9QeUFVaGRmZSIsCiAgICAgICAgICAicmVwb1ByZWZpeCI6ICJzYS10ZXN0LW1pc3Npb24iCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAiMiIsCiAgICAgICAgICAidGl0bGUiOiAiRGVtbyBNaXNzaW9uIiwKICAgICAgICAgICJvcGVuQXQiOiAiMjAyMC0xMi0wMVQwMDowMDowMCswODowMCIsCiAgICAgICAgICAiY2xvc2VBdCI6ICIyMDIxLTEyLTMxVDIzOjU5OjU5KzA4OjAwIiwKICAgICAgICAgICJwdWJsaXNoZWQiOiAieWVzIiwKICAgICAgICAgICJjb3ZlckltYWdlIjogImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8zNTYyMDcwNT9zPTQwMCZ1PTMyZjcyZmQxZDY1YTBkNjg3N2FkMWQ1ODcwZmZhMzI3ZGRhNzU0ZjEmdj00IiwKICAgICAgICAgICJzaG9ydFN1bW1hcnkiOiAiUXVpY2tzb3J0IGFzc2lnbm1lbnQgZGVzY3JpcHRpb24hIiwKICAgICAgICAgICJhY2NlcHRMaW5rIjogImh0dHBzOi8vY2xhc3Nyb29tLmdpdGh1Yi5jb20vYS9DeGxxakxhUCIsCiAgICAgICAgICAicmVwb1ByZWZpeCI6ICJzYS1kZW1vLW1pc3Npb24iCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAiMyIsCiAgICAgICAgICAidGl0bGUiOiAiU29ydGluZyBUaGluZ3MgT3V0IiwKICAgICAgICAgICJvcGVuQXQiOiAiMjAyMC0xMi0wMVQwMDowMDowMCswODowMCIsCiAgICAgICAgICAiY2xvc2VBdCI6ICIyMDIxLTEyLTMxVDIzOjU5OjU5KzA4OjAwIiwKICAgICAgICAgICJwdWJsaXNoZWQiOiAieWVzIiwKICAgICAgICAgICJjb3ZlckltYWdlIjogIi8vczMtYXAtc291dGhlYXN0LTEuYW1hem9uYXdzLmNvbS9taXNzaW9uLWFzc2V0cy9taXNzaW9ucy9RdWlja3NvcnQucG5nIiwKICAgICAgICAgICJzaG9ydFN1bW1hcnkiOiAiQSBxdWljayBsb29rIGF0IHF1aWNrc29ydC4iLAogICAgICAgICAgImFjY2VwdExpbmsiOiAiaHR0cHM6Ly9jbGFzc3Jvb20uZ2l0aHViLmNvbS9hL0QxNmhXdmpBIiwKICAgICAgICAgICJyZXBvUHJlZml4IjogInNhLWF1dG90ZXN0ZXItdGVzdCIKICAgICAgICB9CiAgICAgIF0KICAgIH0sCiAgICB7CiAgICAgICJ0eXBlTmFtZSI6ICJOb3RRdWVzdHMiLAogICAgICAiYXNzZXNzbWVudHMiOgogICAgICBbCiAgICAgICAgewogICAgICAgICAgImlkIjogIjQiLAogICAgICAgICAgInRpdGxlIjogIkZha2UgUXVlc3QiLAogICAgICAgICAgIm9wZW5BdCI6ICIyMDIwLTEyLTAxVDAwOjAwOjAwKzA4OjAwIiwKICAgICAgICAgICJjbG9zZUF0IjogIjIwMjEtMTItMzFUMjM6NTk6NTkrMDg6MDAiLAogICAgICAgICAgInB1Ymxpc2hlZCI6ICJ5ZXMiLAogICAgICAgICAgImNvdmVySW1hZ2UiOiAiaHR0cHM6Ly9pLmt5bS1jZG4uY29tL2VudHJpZXMvaWNvbnMvZmFjZWJvb2svMDAwLzAzNy8wMzcvcGFpbnBla29jb3Zlci5qcGciLAogICAgICAgICAgInNob3J0U3VtbWFyeSI6ICJBIGZha2UgcXVlc3QgdGhhdCBzaG91bGQgc2hvdyB1cC4iLAogICAgICAgICAgImFjY2VwdExpbmsiOiAiaHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj1NNVZfSVhNZXdsNCIsCiAgICAgICAgICAicmVwb1ByZWZpeCI6ICJzYS10aGlzLWRvZXMtbm90LWV4aXN0IgogICAgICAgIH0KICAgICAgXQogICAgfSwKICAgIHsKICAgICAgInR5cGVOYW1lIjoiTm90UGF0aHMiLAogICAgICAiYXNzZXNzbWVudHMiOltdCiAgICB9LAogICAgewogICAgICAidHlwZU5hbWUiOiJOb3RDb250ZXN0cyIsCiAgICAgICJhc3Nlc3NtZW50cyI6W10KICAgIH0sCiAgICB7CiAgICAgICJ0eXBlTmFtZSI6Ik90aGVycyIsCiAgICAgICJhc3Nlc3NtZW50cyI6W10KICAgIH0KICBdCn0='; - -type getContentProp = { - owner: string; - repo: string; - path: string; -}; - -async function getContent(getContentProp: getContentProp) { - const owner = getContentProp.owner; - const repo = getContentProp.repo; - const path = getContentProp.path; - if (owner === 'source-academy-course-test' && repo === 'course-info') { - if (path === '' || path === undefined) { - return { - data: [{ name: 'course-info.json' }] - }; - } - if (path === 'course-info.json') { - return { - data: { - content: mockCourseInfo - } - }; - } - } - return { - data: [{ name: 'not' }, { name: 'important' }] - }; -} - -async function getAuthenticated() { - return { - data: { - login: 'Fubuki' - } - }; -} - -const mockStore = { - session: { - githubOctokitObject: { - octokit: { - orgs: { - listForAuthenticatedUser: listOrgsForAuthenticatedUser - }, - repos: { - listForAuthenticatedUser: listReposForAuthenticatedUser, - getContent: getContent - }, - users: { - getAuthenticated: getAuthenticated - } - } - } - } -}; - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), - useDispatch: jest.fn() -})); - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: () => ({ - state: undefined - }), - useParams: () => ({ - selectedType: undefined - }) -})); - -describe('GitHubClassroom', () => { - beforeEach(() => { - (useSelector as jest.Mock).mockImplementation(callback => { - return callback(mockStore); - }); - }); - - it('renders correctly', async () => { - await act(async () => { - const tree = shallowRender(); - expect(tree).toMatchSnapshot(); - }); - }); -}); diff --git a/src/pages/githubAssessments/__tests__/__snapshots__/GitHubClassroom.tsx.snap b/src/pages/githubAssessments/__tests__/__snapshots__/GitHubClassroom.tsx.snap deleted file mode 100644 index e0dac6ee90..0000000000 --- a/src/pages/githubAssessments/__tests__/__snapshots__/GitHubClassroom.tsx.snap +++ /dev/null @@ -1,69 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`GitHubClassroom renders correctly 1`] = ` -
    - - - } - iconMuted={true} - iconSize={48} - layout="vertical" - /> - } - loadContentDispatch={[Function]} - /> - } - path="login" - /> - } - path="welcome" - /> - } - path="editor" - /> - - } - path="*" - /> - -
    -`; diff --git a/src/routes/routerConfig.tsx b/src/routes/routerConfig.tsx index 0975b7d4b2..eee0624946 100644 --- a/src/routes/routerConfig.tsx +++ b/src/routes/routerConfig.tsx @@ -2,7 +2,6 @@ import { Navigate, redirect, RouteObject } from 'react-router'; import Application from '../commons/application/Application'; import { Role } from '../commons/application/ApplicationTypes'; -import Constants from '../commons/utils/Constants'; /** * Partial migration to be compatible with react-router v6.4 data loader APIs. @@ -15,18 +14,17 @@ import Constants from '../commons/utils/Constants'; // Conditionally allow access to route via `loader` instead of conditionally defining these routes in react-router v6.4. // See https://github.com/remix-run/react-router/discussions/10223#discussioncomment-5909050 -const conditionalLoader = (condition: boolean, redirectTo: string, returnValue?: any) => () => { - if (condition) { - return redirect(redirectTo); - } - return returnValue ?? null; -}; +// const conditionalLoader = (condition: boolean, redirectTo: string, returnValue?: any) => () => { +// if (condition) { +// return redirect(redirectTo); +// } +// return returnValue ?? null; +// }; const Login = () => import('../pages/login/Login'); const Contributors = () => import('../pages/contributors/Contributors'); const GitHubCallback = () => import('../pages/githubCallback/GitHubCallback'); const Sicp = () => import('../pages/sicp/Sicp'); -const GitHubClassroom = () => import('../pages/githubAssessments/GitHubClassroom'); const Playground = () => import('../pages/playground/Playground'); const NotFound = () => import('../pages/notFound/NotFound'); const Welcome = () => import('../pages/welcome/Welcome'); @@ -54,11 +52,6 @@ const commonChildrenRoutes: RouteObject[] = [ { path: 'sicpjs/:section?', lazy: Sicp - }, - { - path: 'githubassessments/*', - lazy: GitHubClassroom, - loader: conditionalLoader(!Constants.enableGitHubAssessments, '/') } ]; From 952ccfa159792f825e768b45a1362e282aa220f9 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 14 Mar 2024 19:01:19 +0800 Subject: [PATCH 4/8] Remove GitHub Assessments CSS module --- src/styles/GithubAssessments.module.scss | 42 ------------------------ 1 file changed, 42 deletions(-) delete mode 100644 src/styles/GithubAssessments.module.scss diff --git a/src/styles/GithubAssessments.module.scss b/src/styles/GithubAssessments.module.scss deleted file mode 100644 index 99ddeea726..0000000000 --- a/src/styles/GithubAssessments.module.scss +++ /dev/null @@ -1,42 +0,0 @@ -@import '_global'; - -.missionBrowser { - background-color: $cadet-color-3; - min-width: 600px; -} - -.missionLoading { - height: 100%; - display: flex; - background-color: $cadet-color-1; - flex-direction: column; - flex: 1 1 100%; -} - -.SideContentMissionEditor { - height: 100%; - display: flex; - flex-direction: column; - flex: 1 1 100%; - - .SideContentMissionEditorRow { - display: flex; - flex-direction: row; - flex-wrap: wrap; - width: 100%; - } - - .SideContentMissionEditorLabelColumn { - display: flex; - flex-direction: column; - flex-basis: 100%; - flex: 0.4; - } - - .SideContentMissionEditorOptionColumn { - display: flex; - flex-direction: column; - flex-basis: 100%; - flex: 1; - } -} From a712dcdf1a6b35b2baa1ad0423f517eab24e07fa Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 14 Mar 2024 19:04:45 +0800 Subject: [PATCH 5/8] Remove missed component --- .../SideContentTestcaseEditor.tsx | 266 ------------------ 1 file changed, 266 deletions(-) delete mode 100644 src/commons/sideContent/content/githubAssessments/SideContentTestcaseEditor.tsx diff --git a/src/commons/sideContent/content/githubAssessments/SideContentTestcaseEditor.tsx b/src/commons/sideContent/content/githubAssessments/SideContentTestcaseEditor.tsx deleted file mode 100644 index 3f39dc253e..0000000000 --- a/src/commons/sideContent/content/githubAssessments/SideContentTestcaseEditor.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { Button, Collapse, Icon, PopoverPosition } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; -import { Tooltip2 } from '@blueprintjs/popover2'; -import React from 'react'; -import AceEditor from 'react-ace'; - -import { Testcase } from '../../../assessment/AssessmentTypes'; -import ControlButton from '../../../ControlButton'; -import { showSimpleConfirmDialog } from '../../../utils/DialogHelper'; -import SideContentTestcaseCard from '../../content/SideContentTestcaseCard'; -import SideContentEditableTestcaseCard from './SideContentEditableTestcaseCard'; - -export type SideContentTestcaseEditorProps = DispatchProps & StateProps; - -type DispatchProps = { - handleTestcaseEval: (testcaseId: number) => void; - setTaskTestcases: (newTestcases: Testcase[]) => void; - setTestPrepend: (newTestPrepend: string) => void; - setTestPostpend: (newTestPostpend: string) => void; -}; - -type StateProps = { - allowEdits: boolean; - testcases: Testcase[]; - testPrepend: string; - testPostpend: string; -}; - -const SideContentTestcaseEditor: React.FunctionComponent< - SideContentTestcaseEditorProps -> = props => { - const [showsTestPrepend, setTestPrependShown] = React.useState(true); - const [showsTestPostpend, setTestPostpendShown] = React.useState(true); - const [showsTestcases, setTestcasesShown] = React.useState(true); - - const { - testcases, - testPrepend, - testPostpend, - allowEdits, - handleTestcaseEval, - setTaskTestcases, - setTestPrepend, - setTestPostpend - } = props; - - const setTestcaseProgramSetterCreator = React.useCallback( - (testcaseId: number) => { - return (newProgram: string) => { - const newTestcases = testcases.map((testcase: Testcase) => Object.assign({}, testcase)); - newTestcases[testcaseId].program = newProgram; - delete newTestcases[testcaseId].result; - setTaskTestcases(newTestcases); - }; - }, - [setTaskTestcases, testcases] - ); - - const setTestcaseExpectedResultSetterCreator = React.useCallback( - (testcaseId: number) => { - return (newExpectedResult: string) => { - const newTestcases = testcases.map((testcase: Testcase) => Object.assign({}, testcase)); - newTestcases[testcaseId].answer = newExpectedResult; - delete newTestcases[testcaseId].result; - setTaskTestcases(newTestcases); - }; - }, - [setTaskTestcases, testcases] - ); - - const addTestcase = React.useCallback(() => { - const newTestcases = [...testcases]; - newTestcases.push({ - answer: '', - program: '', - score: 0, - type: 'public' - }); - setTaskTestcases(newTestcases); - }, [testcases, setTaskTestcases]); - - const deleteTestcase = React.useCallback( - async (testcaseId: number) => { - const confirmDelete = await showSimpleConfirmDialog({ - title: 'confirm testcase deletion', - contents: 'Are you sure you want to delete this testcase? This cannot be undone.', - positiveLabel: 'Confirm', - negativeLabel: 'Cancel' - }); - - if (!confirmDelete) { - return; - } - - const newTestcases = testcases.slice(0, testcaseId).concat(testcases.slice(testcaseId + 1)); - setTaskTestcases(newTestcases); - }, - [testcases, setTaskTestcases] - ); - - const convertToTestcaseCard = React.useCallback( - (testcase: Testcase, index: number) => { - if (allowEdits) { - return ( - - ); - } else { - return ( - - ); - } - }, - [ - allowEdits, - deleteTestcase, - handleTestcaseEval, - setTestcaseProgramSetterCreator, - setTestcaseExpectedResultSetterCreator - ] - ); - - const testcaseCards = React.useMemo( - () => - testcases.length > 0 ? ( -
    - {testcasesHeader} - {testcases.map((testcase, index) => convertToTestcaseCard(testcase, index))} -
    - ) : ( -
    There are no testcases provided for this question.
    - ), - [testcases, convertToTestcaseCard] - ); - - const createTestCaseButton = React.useMemo( - () => ( - - -
    - {testcaseCards} - {allowEdits && createTestCaseButton} -
    -
    - - {allowEdits && collapseButton('Testcase Prepend', showsTestPrepend, toggleTestPrepend)} - {allowEdits && ( - -
    - {createEditor(testPrepend, (newValue: string) => setTestPrepend(newValue))} -
    -
    - )} - - {allowEdits && collapseButton('Testcase Postpend', showsTestPostpend, toggleTestPostpend)} - {allowEdits && ( - -
    - {createEditor(testPostpend, (newValue: string) => setTestPostpend(newValue))} -
    -
    - )} - - ); -}; - -function createEditor(value: string, onChange: (newValue: string) => void) { - return ( - - ); -} - -const autograderTooltip = ( -
    -

    Click on each testcase below to execute it with the program in the editor.

    -

    - To execute all testcases at once, evaluate the program in the editor with this tab active. -

    -

    A green or red background indicates a passed or failed testcase respectively.

    -

    Private testcases (only visible to staff when grading) have a grey background.

    -
    -); - -const columnHeader = (colClass: string, colTitle: string) => ( -
    - {colTitle} - -
    -); - -const testcasesHeader = ( -
    - {columnHeader('header-fn', 'Testcase')} - {columnHeader('header-expected', 'Expected result')} - {columnHeader('header-actual', 'Actual result')} -
    -); - -const collapseButton = (label: string, isOpen: boolean, toggleFunc: () => void) => ( - -); - -export default SideContentTestcaseEditor; From 665f5b9134574b96e6be0a37a875a6b48153c1bd Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 14 Mar 2024 19:06:51 +0800 Subject: [PATCH 6/8] Remove GitHub Assessments-related types --- src/commons/application/ApplicationTypes.ts | 5 ----- src/commons/sagas/GitHubPersistenceSaga.ts | 7 +------ src/commons/workspace/WorkspaceTypes.ts | 2 -- src/commons/workspace/__tests__/WorkspaceReducer.ts | 7 +------ src/features/githubAssessment/GitHubAssessmentTypes.ts | 7 ------- src/pages/fileSystem/createInBrowserFileSystem.ts | 1 - 6 files changed, 2 insertions(+), 27 deletions(-) delete mode 100644 src/features/githubAssessment/GitHubAssessmentTypes.ts diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 4cc24a7a3a..4d9ee2cd2b 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -483,10 +483,6 @@ export const defaultWorkspaceManager: WorkspaceManagerState = { } ] }, - githubAssessment: { - ...createDefaultWorkspace('githubAssessment'), - hasUnsavedChanges: false - }, stories: { ...createDefaultWorkspace('stories') // TODO: Perhaps we can add default values? @@ -554,7 +550,6 @@ export const defaultSideContentManager: SideContentManagerState = { assessment: defaultSideContent, grading: defaultSideContent, playground: defaultSideContent, - githubAssessment: defaultSideContent, sicp: defaultSideContent, sourcecast: defaultSideContent, sourcereel: defaultSideContent, diff --git a/src/commons/sagas/GitHubPersistenceSaga.ts b/src/commons/sagas/GitHubPersistenceSaga.ts index 84b96f1173..a11896757f 100644 --- a/src/commons/sagas/GitHubPersistenceSaga.ts +++ b/src/commons/sagas/GitHubPersistenceSaga.ts @@ -20,7 +20,7 @@ import RepositoryDialog, { RepositoryDialogProps } from '../gitHubOverlay/Reposi import { actions } from '../utils/ActionsHelper'; import Constants from '../utils/Constants'; import { promisifyDialog } from '../utils/DialogHelper'; -import { showSuccessMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper'; +import { showSuccessMessage } from '../utils/notifications/NotificationsHelper'; import { EditorTabState } from '../workspace/WorkspaceTypes'; export function* GitHubPersistenceSaga(): SagaIterator { @@ -58,11 +58,6 @@ function* githubLoginSaga() { } function* githubLogoutSaga() { - if (store.getState() && store.getState().workspaces.githubAssessment.hasUnsavedChanges) { - yield call(showWarningMessage, 'You have unsaved changes!', 2000); - return; - } - yield put(actions.removeGitHubOctokitObjectAndAccessToken()); yield call(showSuccessMessage, `Logged out from GitHub`, 1000); } diff --git a/src/commons/workspace/WorkspaceTypes.ts b/src/commons/workspace/WorkspaceTypes.ts index 3f3722b3b9..39d6853d48 100644 --- a/src/commons/workspace/WorkspaceTypes.ts +++ b/src/commons/workspace/WorkspaceTypes.ts @@ -1,6 +1,5 @@ import { Context } from 'js-slang'; -import { GitHubAssessmentWorkspaceState } from '../../features/githubAssessment/GitHubAssessmentTypes'; import { SourcecastWorkspaceState } from '../../features/sourceRecorder/sourcecast/SourcecastTypes'; import { SourcereelWorkspaceState } from '../../features/sourceRecorder/sourcereel/SourcereelTypes'; import { InterpreterOutput } from '../application/ApplicationTypes'; @@ -102,7 +101,6 @@ export type WorkspaceManagerState = { readonly sourcecast: SourcecastWorkspaceState; readonly sourcereel: SourcereelWorkspaceState; readonly sicp: SicpWorkspaceState; - readonly githubAssessment: GitHubAssessmentWorkspaceState; readonly stories: StoriesWorkspaceState; }; diff --git a/src/commons/workspace/__tests__/WorkspaceReducer.ts b/src/commons/workspace/__tests__/WorkspaceReducer.ts index 9c36e3eb27..efe63ba183 100644 --- a/src/commons/workspace/__tests__/WorkspaceReducer.ts +++ b/src/commons/workspace/__tests__/WorkspaceReducer.ts @@ -77,8 +77,7 @@ const locations: ReadonlyArray = [ 'playground', 'sourcecast', 'sourcereel', - 'sicp', - 'githubAssessment' + 'sicp' ] as const; function generateActions(type: string, payload: any = {}): any[] { @@ -114,10 +113,6 @@ function generateDefaultWorkspace(payload: any = {}): WorkspaceManagerState { ...defaultWorkspaceManager.sicp, ...cloneDeep(payload) }, - githubAssessment: { - ...defaultWorkspaceManager.githubAssessment, - ...cloneDeep(payload) - }, stories: { ...defaultWorkspaceManager.stories, ...cloneDeep(payload) diff --git a/src/features/githubAssessment/GitHubAssessmentTypes.ts b/src/features/githubAssessment/GitHubAssessmentTypes.ts deleted file mode 100644 index 04403f058c..0000000000 --- a/src/features/githubAssessment/GitHubAssessmentTypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { WorkspaceState } from '../../commons/workspace/WorkspaceTypes'; - -export type GitHubAssessmentWorkspaceAttr = { - hasUnsavedChanges: boolean; -}; - -export type GitHubAssessmentWorkspaceState = WorkspaceState & GitHubAssessmentWorkspaceAttr; diff --git a/src/pages/fileSystem/createInBrowserFileSystem.ts b/src/pages/fileSystem/createInBrowserFileSystem.ts index 86ab66b937..ab175213bc 100644 --- a/src/pages/fileSystem/createInBrowserFileSystem.ts +++ b/src/pages/fileSystem/createInBrowserFileSystem.ts @@ -15,7 +15,6 @@ import { EditorTabState, WorkspaceManagerState } from '../../commons/workspace/W */ export const WORKSPACE_BASE_PATHS: Record = { assessment: '', - githubAssessment: '', grading: '', playground: '/playground', sicp: '/sicp', From 7b447e6f09366ef79c3693f735d52b33a10a8709 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 14 Mar 2024 19:10:16 +0800 Subject: [PATCH 7/8] Update test snapshots --- .../__snapshots__/NavigationBar.tsx.snap | 60 -------------- .../GitHubAssessmentsNavigationBar.tsx.snap | 82 ------------------- 2 files changed, 142 deletions(-) delete mode 100644 src/commons/navigationBar/subcomponents/__tests__/__snapshots__/GitHubAssessmentsNavigationBar.tsx.snap diff --git a/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap b/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap index a1be3d835c..a3fca35ac0 100644 --- a/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap +++ b/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap @@ -41,22 +41,6 @@ exports[`NavigationBar Renders "Not logged in" correctly 1`] = ` - - -
    - Classroom -
    -
    - - - -
    - Classroom -
    -
    - - - -
    - Classroom -
    -
    - - - - -
    - Missions -
    -
    - - -
    - Quests -
    -
    -
    - - } - placement="bottom-end" - > - - - } - onChange={[Function]} - placeholder="Select Course" - value="" - /> - - - -`; From 34b0381c701bce41fb157016987c983710cdfef5 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Thu, 14 Mar 2024 19:17:23 +0800 Subject: [PATCH 8/8] Fix compile error --- src/commons/mobileWorkspace/MobileWorkspace.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/commons/mobileWorkspace/MobileWorkspace.tsx b/src/commons/mobileWorkspace/MobileWorkspace.tsx index eff24f3773..68a8351f5f 100644 --- a/src/commons/mobileWorkspace/MobileWorkspace.tsx +++ b/src/commons/mobileWorkspace/MobileWorkspace.tsx @@ -284,9 +284,7 @@ const MobileWorkspace: React.FC = props => { sideBarTabs ]); - const inAssessmentWorkspace = - props.mobileSideContentProps.workspaceLocation === 'assessment' || - props.mobileSideContentProps.workspaceLocation === 'githubAssessment'; + const inAssessmentWorkspace = props.mobileSideContentProps.workspaceLocation === 'assessment'; return (