diff --git a/src/commons/application/Application.tsx b/src/commons/application/Application.tsx index 5e8e52cf00..a27f747c7a 100644 --- a/src/commons/application/Application.tsx +++ b/src/commons/application/Application.tsx @@ -6,8 +6,7 @@ import Academy from '../../pages/academy/AcademyContainer'; import Achievement from '../../pages/achievement/AchievementContainer'; import Contributors from '../../pages/contributors/Contributors'; import Disabled from '../../pages/disabled/Disabled'; -import GitHubAssessmentWorkspaceContainer from '../../pages/githubAssessments/GitHubAssessmentWorkspaceContainer'; -import GitHubMissionListing from '../../pages/githubAssessments/GitHubMissionListing'; +import GitHubClassroom from '../../pages/githubAssessments/GitHubClassroom'; import GitHubCallback from '../../pages/githubCallback/GitHubCallback'; import Login from '../../pages/login/LoginContainer'; import MissionControlContainer from '../../pages/missionControl/MissionControlContainer'; @@ -149,21 +148,15 @@ const Application: React.FC = props => { {Constants.enableGitHubAssessments && ( ( - )} /> )} - {Constants.enableGitHubAssessments && ( - - )} diff --git a/src/commons/application/__tests__/__snapshots__/Application.tsx.snap b/src/commons/application/__tests__/__snapshots__/Application.tsx.snap index 6f5e6060cf..9f088128d6 100644 --- a/src/commons/application/__tests__/__snapshots__/Application.tsx.snap +++ b/src/commons/application/__tests__/__snapshots__/Application.tsx.snap @@ -8,8 +8,7 @@ exports[`Application renders correctly 1`] = ` - - + diff --git a/src/commons/controlBar/github/ControlBarGitHubLoginButton.tsx b/src/commons/controlBar/github/ControlBarGitHubLoginButton.tsx index f0e1750cc7..f521ffe3f6 100644 --- a/src/commons/controlBar/github/ControlBarGitHubLoginButton.tsx +++ b/src/commons/controlBar/github/ControlBarGitHubLoginButton.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { useSelector } from 'react-redux'; import { useMediaQuery } from 'react-responsive'; +import { OverallState } from '../../application/ApplicationTypes'; import controlButton from '../../ControlButton'; import Constants from '../../utils/Constants'; @@ -20,7 +21,7 @@ export type ControlBarGitHubLoginButtonProps = { export const ControlBarGitHubLoginButton: React.FC = props => { const isMobileBreakpoint = useMediaQuery({ maxWidth: Constants.mobileBreakpoint }); const isLoggedIn = - useSelector((store: any) => store.session.githubOctokitObject).octokit !== undefined; + useSelector((store: OverallState) => store.session.githubOctokitObject).octokit !== undefined; const loginButton = isLoggedIn ? controlButton('Log Out', IconNames.GIT_BRANCH, props.onClickLogOut) diff --git a/src/commons/githubAssessments/GitHubMissionDataUtils.ts b/src/commons/githubAssessments/GitHubMissionDataUtils.ts index 9968b8ac81..7c0a3c04b0 100644 --- a/src/commons/githubAssessments/GitHubMissionDataUtils.ts +++ b/src/commons/githubAssessments/GitHubMissionDataUtils.ts @@ -265,91 +265,18 @@ export async function getContentAsString( * @param metadataString The file contents of the '.metadata' file of a mission repository */ function convertMetadataStringToMissionMetadata(metadataString: string) { - const missionMetadata: MissionMetadata = { - coverImage: '', - type: '', - id: '', - title: '', - sourceVersion: 1, - dueDate: new Date(8640000000000000), - reading: '', - webSummary: '' - }; - const stringPropsToExtract = ['coverImage', 'type', 'id', 'title', 'reading', 'webSummary']; - const numPropsToExtract = ['sourceVersion']; - const datePropsToExtract = ['dueDate']; - - const retVal = parseMetadataProperties( - missionMetadata, - stringPropsToExtract, - numPropsToExtract, - datePropsToExtract, - metadataString - ); - - return retVal; + try { + return JSON.parse(metadataString) as MissionMetadata; + } catch (err) { + console.error(err); + return { + sourceVersion: 4 + } as MissionMetadata; + } } function convertMissionMetadataToMetadataString(missionMetadata: MissionMetadata) { - const properties: string[] = [ - 'title', - 'coverImage', - 'webSummary', - 'dueDate', - 'type', - 'id', - 'sourceVersion', - 'reading' - ]; - const propertyValuePairs = properties.map(property => property + '=' + missionMetadata[property]); - return propertyValuePairs.join('\n'); -} - -/** - * Converts the contents of a '.metadata' file into an object of type R. - * - * @param propertyContainer The object of which properties will be set - * @param stringProps An array containing the names of properties with string values - * @param numProps An array containing the names of properties with numerical values - * @param dateProps An array containing the names of properties with date values - * @param metadataString The content of the '.metadata' file to be parsed - */ -export function parseMetadataProperties( - propertyContainer: R, - stringProps: string[], - numProps: string[], - dateProps: string[], - metadataString: string -) { - const lines = metadataString.replace(/\r/g, '').split(/\n/); - - lines.forEach(line => { - for (let i = 0; i < stringProps.length; i++) { - const propName = stringProps[i]; - if (line.startsWith(propName)) { - propertyContainer[propName] = line.substr(propName.length + 1); - return; - } - } - - for (let i = 0; i < numProps.length; i++) { - const propName = numProps[i]; - if (line.startsWith(propName)) { - propertyContainer[propName] = parseInt(line.substr(propName.length + 1), 10); - return; - } - } - - for (let i = 0; i < dateProps.length; i++) { - const propName = dateProps[i]; - if (line.startsWith(propName)) { - propertyContainer[propName] = new Date(line.substr(propName.length + 1)); - return; - } - } - }); - - return propertyContainer; + return jsonStringify(missionMetadata); } /** diff --git a/src/commons/githubAssessments/GitHubMissionMobileLoginButton.tsx b/src/commons/githubAssessments/GitHubMissionMobileLoginButton.tsx index b4c670540a..df372f7044 100644 --- a/src/commons/githubAssessments/GitHubMissionMobileLoginButton.tsx +++ b/src/commons/githubAssessments/GitHubMissionMobileLoginButton.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { useSelector } from 'react-redux'; import { useMediaQuery } from 'react-responsive'; +import { OverallState } from '../application/ApplicationTypes'; import controlButton from '../ControlButton'; import Constants from '../utils/Constants'; @@ -21,7 +22,7 @@ export const ControlBarGitHubMobileLoginButton: React.FC { const isMobileBreakpoint = useMediaQuery({ maxWidth: Constants.mobileBreakpoint }); const isLoggedIn = - useSelector((store: any) => store.session.githubOctokitObject).octokit !== undefined; + useSelector((store: OverallState) => store.session.githubOctokitObject).octokit !== undefined; const loginButton = isLoggedIn ? controlButton('Log Out', IconNames.GIT_BRANCH, props.onClickLogOut) diff --git a/src/commons/githubAssessments/GitHubMissionTypes.ts b/src/commons/githubAssessments/GitHubMissionTypes.ts index 528768de5c..0eb75b6d96 100644 --- a/src/commons/githubAssessments/GitHubMissionTypes.ts +++ b/src/commons/githubAssessments/GitHubMissionTypes.ts @@ -17,15 +17,7 @@ export type TaskData = { * An code representation of a GitHub-hosted mission's '.metadata' file. */ export type MissionMetadata = { - coverImage: string; - type: string; - id: string; - title: string; sourceVersion: number; - dueDate: Date; - - reading: string; - webSummary: string; }; /** diff --git a/src/commons/githubAssessments/__tests__/GitHubMissionDataUtils.ts b/src/commons/githubAssessments/__tests__/GitHubMissionDataUtils.ts index 1e4a5de529..d085cd8b45 100644 --- a/src/commons/githubAssessments/__tests__/GitHubMissionDataUtils.ts +++ b/src/commons/githubAssessments/__tests__/GitHubMissionDataUtils.ts @@ -2,7 +2,7 @@ import { Octokit } from '@octokit/rest'; import { IMCQQuestion } from '../../assessment/AssessmentTypes'; import * as GitHubMissionDataUtils from '../GitHubMissionDataUtils'; -import { MissionMetadata, MissionRepoData } from '../GitHubMissionTypes'; +import { MissionRepoData } from '../GitHubMissionTypes'; test('getContentAsString correctly gets content and translates from Base64 to utf-8', async () => { const octokit = new Octokit(); @@ -23,40 +23,6 @@ test('getContentAsString correctly gets content and translates from Base64 to ut expect(content).toBe('Hello World!'); }); -test('parseMetadataProperties correctly discovers properties', () => { - const missionMetadata = Object.assign({}, dummyMissionMetadata); - const stringPropsToExtract = ['coverImage', 'type', 'id', 'title', 'reading', 'webSummary']; - const numPropsToExtract = ['sourceVersion']; - const datePropsToExtract = ['dueDate']; - - const metadataString = - 'coverImage=www.somelink.com\n' + - 'type=Mission\n' + - 'id=M3\n' + - 'title=Dummy Mission\n' + - 'reading=Textbook Pages 1 to 234763\n' + - 'dueDate=December 17, 1995 03:24:00\n' + - 'webSummary=no\n' + - 'sourceVersion=3'; - - const retVal = GitHubMissionDataUtils.parseMetadataProperties( - missionMetadata, - stringPropsToExtract, - numPropsToExtract, - datePropsToExtract, - metadataString - ); - - expect(retVal.coverImage).toBe('www.somelink.com'); - expect(retVal.type).toBe('Mission'); - expect(retVal.id).toBe('M3'); - expect(retVal.title).toBe('Dummy Mission'); - expect(retVal.reading).toBe('Textbook Pages 1 to 234763'); - expect(retVal.webSummary).toBe('no'); - expect(retVal.sourceVersion).toBe(3); - expect(retVal.dueDate).toStrictEqual(new Date('December 17, 1995 03:24:00')); -}); - test('getMissionData works properly', async () => { const missionRepoData: MissionRepoData = { repoOwner: 'Pain', @@ -79,13 +45,9 @@ test('getMissionData works properly', async () => { // Metadata String const contentResponse = generateGetContentResponse(); (contentResponse.data as any).content = Buffer.from( - 'coverImage=www.somelink.com\n' + - 'type=Mission\n' + - 'id=M3\n' + - 'title=Dummy Mission\n' + - 'reading=Textbook Pages 1 to 234763\n' + - 'webSummary=no\n' + - 'sourceVersion=3', + `{ + "sourceVersion": 3 + }`, 'utf-8' ).toString('base64'); return contentResponse; @@ -183,13 +145,6 @@ test('getMissionData works properly', async () => { expect(missionData.missionRepoData.repoName).toBe('Peko'); expect(missionData.missionBriefing).toBe('Briefing Content'); - - expect(missionData.missionMetadata.coverImage).toBe('www.somelink.com'); - expect(missionData.missionMetadata.type).toBe('Mission'); - expect(missionData.missionMetadata.id).toBe('M3'); - expect(missionData.missionMetadata.title).toBe('Dummy Mission'); - expect(missionData.missionMetadata.reading).toBe('Textbook Pages 1 to 234763'); - expect(missionData.missionMetadata.webSummary).toBe('no'); expect(missionData.missionMetadata.sourceVersion).toBe(3); expect(missionData.tasksData.length).toBe(2); @@ -932,14 +887,7 @@ function generateGetContentResponse() { } const dummyMissionMetadata = { - coverImage: 'www.eh', - type: 'mission', - id: 'M2', - title: 'Dummy', - sourceVersion: 1, - dueDate: new Date('December 17, 1996 03:24:00'), - reading: 'none', - webSummary: 'no' + sourceVersion: 1 }; const defaultMissionMetadata = { diff --git a/src/commons/navigationBar/NavigationBar.tsx b/src/commons/navigationBar/NavigationBar.tsx index 72e4de132d..660273d45a 100644 --- a/src/commons/navigationBar/NavigationBar.tsx +++ b/src/commons/navigationBar/NavigationBar.tsx @@ -17,13 +17,12 @@ import classNames from 'classnames'; import * as React from 'react'; import { useMediaQuery } from 'react-responsive'; import { NavLink, Route, Switch } from 'react-router-dom'; -import SicpNavigationBar from 'src/commons/navigationBar/subcomponents/SicpNavigationBar'; +import SicpNavigationBar from '../../commons/navigationBar/subcomponents/SicpNavigationBar'; import { Role } from '../application/ApplicationTypes'; import Dropdown from '../dropdown/Dropdown'; import Constants from '../utils/Constants'; import AcademyNavigationBar from './subcomponents/AcademyNavigationBar'; -import GitHubAssessmentsNavigationBar from './subcomponents/GitHubAssessmentsNavigationBar'; import NavigationBarMobileSideMenu from './subcomponents/NavigationBarMobileSideMenu'; type NavigationBarProps = DispatchProps & StateProps; @@ -129,7 +128,7 @@ const NavigationBar: React.FC = props => {
GitHub Assessments
@@ -225,10 +224,10 @@ const NavigationBar: React.FC = props => { -
GitHub Assessments
+
Classroom
)} @@ -302,11 +301,6 @@ const NavigationBar: React.FC = props => { - - {Constants.enableGitHubAssessments && !isMobileBreakpoint && desktopMenuOpen && ( - - )} - diff --git a/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap b/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap index 7408135b02..ad54d31b24 100644 --- a/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap +++ b/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap @@ -22,10 +22,10 @@ exports[`NavigationBar renders "Not logged in" correctly 1`] = ` Playground
- +
- GitHub Assessments + Classroom
@@ -52,9 +52,6 @@ exports[`NavigationBar renders "Not logged in" correctly 1`] = ` - - - @@ -85,10 +82,10 @@ exports[`NavigationBar renders correctly with student role 1`] = ` Playground - +
- GitHub Assessments + Classroom
@@ -125,9 +122,6 @@ exports[`NavigationBar renders correctly with student role 1`] = ` - - - diff --git a/src/commons/navigationBar/subcomponents/GitHubAssessmentsNavigationBar.tsx b/src/commons/navigationBar/subcomponents/GitHubAssessmentsNavigationBar.tsx index 2953cf2dda..2017300340 100644 --- a/src/commons/navigationBar/subcomponents/GitHubAssessmentsNavigationBar.tsx +++ b/src/commons/navigationBar/subcomponents/GitHubAssessmentsNavigationBar.tsx @@ -1,42 +1,113 @@ -import { Alignment, Classes, Icon, Navbar, NavbarGroup } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; +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 * as React from 'react'; import { NavLink } from 'react-router-dom'; +import { assessmentTypeLink } from '../../../commons/utils/ParamParseHelper'; +import { GHAssessmentTypeOverview } from '../../../pages/githubAssessments/GitHubClassroom'; import { ControlBarGitHubLoginButton } from '../../controlBar/github/ControlBarGitHubLoginButton'; -type OwnProps = { - handleGitHubLogIn: any; - handleGitHubLogOut: any; +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. - * - * @param props Component properties */ -const GitHubAssessmentsNavigationBar: React.FunctionComponent = props => ( - - - - -
Missions
-
-
- - - - -
-); +const GitHubAssessmentsNavigationBar: React.FC = props => { + const handleClick = (e: any) => { + props.changeCourseHandler(e); + }; + + return ( + + + {props.types?.map((type, idx) => { + return ( + + +
{type}
+
+ ); + })} +
+ + {props.octokit !== undefined && props.types && props.types.length > 0 && ( + + {props.courses?.map((course: string) => ( + + ))} + + } + placement={'bottom-end'} + > + + ), + [] + ); + + 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) + ); + 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 +) { + 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 = () => history.push(`/githubassessments/editor`, 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 index f7b3baa7e5..8398810262 100644 --- a/src/pages/githubAssessments/GitHubAssessmentWorkspace.tsx +++ b/src/pages/githubAssessments/GitHubAssessmentWorkspace.tsx @@ -45,11 +45,6 @@ import { discoverFilesToBeCreatedWithoutMissionRepoData, getMissionData } from '../../commons/githubAssessments/GitHubMissionDataUtils'; -import { - GitHubMissionSaveDialog, - GitHubMissionSaveDialogProps, - GitHubMissionSaveDialogResolution -} from '../../commons/githubAssessments/GitHubMissionSaveDialog'; import { MissionData, MissionMetadata, @@ -86,6 +81,7 @@ import { defaultMissionMetadata, defaultTask } from './GitHubAssessmentDefaultValues'; +import { GHAssessmentOverview } from './GitHubClassroom'; export type GitHubAssessmentWorkspaceProps = DispatchProps & StateProps & RouteComponentProps; @@ -136,7 +132,7 @@ const GitHubAssessmentWorkspace: React.FC = prop const octokit = getGitHubOctokitInstance(); if (octokit === undefined) { - history.push('/githubassessments/missions'); + history.push('/githubassessments/login'); } /** @@ -164,9 +160,10 @@ const GitHubAssessmentWorkspace: React.FC = prop const [currentTaskIsMCQ, setCurrentTaskIsMCQ] = React.useState(false); const [displayMCQInEditor, setDisplayMCQInEditor] = React.useState(true); const [mcqQuestion, setMCQQuestion] = React.useState(defaultMCQQuestion); - const [missionRepoData, setMissionRepoData] = React.useState( - props.location.state as MissionRepoData + const [missionRepoData, setMissionRepoData] = React.useState( + undefined ); + const assessmentOverview = props.location.state as GHAssessmentOverview; const [showBriefingOverlay, setShowBriefingOverlay] = React.useState(false); const [selectedTab, setSelectedTab] = React.useState(SideContentType.questionOverview); @@ -261,35 +258,22 @@ const GitHubAssessmentWorkspace: React.FC = prop /** * Sets up the workspace for when the user is retrieving Mission information from a GitHub repository */ - const setUpWithMissionRepoData = useCallback(async () => { + 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; - }) - .then(async (userOwnsRepo: boolean) => { - if (userOwnsRepo) return true; - - const userOrganisations = (await octokit.orgs.listForAuthenticatedUser()).data; - let userOrganisationOwnsRepo = false; - for (let i = 0; i < userOrganisations.length; i++) { - const org = userOrganisations[i]; - // User has admin access to an organization owning the repo - userOrganisationOwnsRepo = org.login === missionRepoData.repoOwner; - if (userOrganisationOwnsRepo) { - break; - } - } - return userOrganisationOwnsRepo; - }); + 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); @@ -316,12 +300,17 @@ const GitHubAssessmentWorkspace: React.FC = prop setIsLoading(false); }); - }, [changeStateDueToChangedTaskNumber, missionRepoData, octokit, handleUpdateHasUnsavedChanges]); + }, [ + assessmentOverview, + octokit, + changeStateDueToChangedTaskNumber, + handleUpdateHasUnsavedChanges + ]); /** * Sets up the workspace for when the user is creating a new Mission */ - const setUpWithoutMissionRepoData = useCallback(() => { + const setUpWithoutAssessmentOverview = useCallback(() => { setSummary(defaultMissionBriefing); setMissionMetadata(defaultMissionMetadata); @@ -346,12 +335,12 @@ const GitHubAssessmentWorkspace: React.FC = prop }, [changeStateDueToChangedTaskNumber, handleUpdateHasUnsavedChanges]); useEffect(() => { - if (missionRepoData === undefined) { - setUpWithoutMissionRepoData(); + if (assessmentOverview === undefined) { + setUpWithoutAssessmentOverview(); } else { - setUpWithMissionRepoData(); + setUpWithAssessmentOverview(); } - }, [missionRepoData, setUpWithMissionRepoData, setUpWithoutMissionRepoData]); + }, [assessmentOverview, setUpWithAssessmentOverview, setUpWithoutAssessmentOverview]); const briefingOverlay = ( @@ -399,18 +388,20 @@ const GitHubAssessmentWorkspace: React.FC = prop githubEmail: string | null, commitMessage: string ) => { + const typedMissionRepoData = missionRepoData as MissionRepoData; + const { saveType } = await checkIfFileCanBeSavedAndGetSaveType( octokit, - missionRepoData.repoOwner, - missionRepoData.repoName, + typedMissionRepoData.repoOwner, + typedMissionRepoData.repoName, changedFile ); if (saveType === 'Overwrite') { await performOverwritingSave( octokit, - missionRepoData.repoOwner, - missionRepoData.repoName, + typedMissionRepoData.repoOwner, + typedMissionRepoData.repoName, changedFile, githubName, githubEmail, @@ -422,8 +413,8 @@ const GitHubAssessmentWorkspace: React.FC = prop if (saveType === 'Create') { await performCreatingSave( octokit, - missionRepoData.repoOwner, - missionRepoData.repoName, + typedMissionRepoData.repoOwner, + typedMissionRepoData.repoName, changedFile, githubName, githubEmail, @@ -442,10 +433,12 @@ const GitHubAssessmentWorkspace: React.FC = prop githubEmail: string | null, commitMessage: string ) => { + const typedMissionRepoData = missionRepoData as MissionRepoData; + await performFolderDeletion( octokit, - missionRepoData.repoOwner, - missionRepoData.repoName, + typedMissionRepoData.repoOwner, + typedMissionRepoData.repoName, fileName, githubName, githubEmail, @@ -473,21 +466,7 @@ const GitHubAssessmentWorkspace: React.FC = prop cachedTaskList, isTeacherMode ); - const changedFiles = Object.keys(filenameToContentMap).sort(); - - const dialogResults = await promisifyDialog< - GitHubMissionSaveDialogProps, - GitHubMissionSaveDialogResolution - >(GitHubMissionSaveDialog, resolve => ({ - repoName: missionRepoData.repoName, - filesToChangeOrCreate: changedFiles, - filesToDelete: foldersToDelete, - resolveDialog: dialogResults => resolve(dialogResults) - })); - - if (!dialogResults.confirmSave) { - return; - } + const changedFiles = Object.keys(filenameToContentMap); type GetAuthenticatedResponse = GetResponseTypeFromEndpointMethod< typeof octokit.users.getAuthenticated @@ -495,7 +474,7 @@ const GitHubAssessmentWorkspace: React.FC = prop const authUser: GetAuthenticatedResponse = await octokit.users.getAuthenticated(); const githubName = authUser.data.name; const githubEmail = authUser.data.email; - const commitMessage = dialogResults.commitMessage; + const commitMessage = ''; for (let i = 0; i < foldersToDelete.length; i++) { await conductDelete(foldersToDelete[i], githubName, githubEmail, commitMessage); @@ -515,17 +494,16 @@ const GitHubAssessmentWorkspace: React.FC = prop setHasUnsavedChangesToBriefing(false); setHasUnsavedChangesToMetadata(false); }, [ + octokit, + isTeacherMode, briefingContent, - cachedBriefingContent, + missionMetadata, taskList, cachedTaskList, - missionMetadata, + cachedBriefingContent, cachedMissionMetadata, conductSave, - conductDelete, - octokit, - missionRepoData, - isTeacherMode + conductDelete ]); /** @@ -605,12 +583,17 @@ const GitHubAssessmentWorkspace: React.FC = prop }, [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(); } - }, [missionRepoData, saveWithMissionRepoData, saveWithoutMissionRepoData]); + }, [assessmentOverview, missionRepoData, saveWithMissionRepoData, saveWithoutMissionRepoData]); const onClickReset = useCallback(async () => { const confirmReset = await showSimpleConfirmDialog({ @@ -657,7 +640,14 @@ const GitHubAssessmentWorkspace: React.FC = prop ); const onClickPrevious = useCallback(() => { - if (shouldProceedToChangeTask(currentTaskNumber, taskList, cachedTaskList, missionRepoData)) { + if ( + shouldProceedToChangeTask( + currentTaskNumber, + taskList, + cachedTaskList, + missionRepoData as MissionRepoData + ) + ) { let activeTaskList = taskList; if (missionRepoData !== undefined) { activeTaskList = cachedTaskList.map((taskData: TaskData) => Object.assign({}, taskData)); @@ -677,7 +667,14 @@ const GitHubAssessmentWorkspace: React.FC = prop ]); const onClickNext = useCallback(() => { - if (shouldProceedToChangeTask(currentTaskNumber, taskList, cachedTaskList, missionRepoData)) { + if ( + shouldProceedToChangeTask( + currentTaskNumber, + taskList, + cachedTaskList, + missionRepoData as MissionRepoData + ) + ) { let activeTaskList = taskList; if (missionRepoData !== undefined) { activeTaskList = cachedTaskList.map((taskData: TaskData) => Object.assign({}, taskData)); diff --git a/src/pages/githubAssessments/GitHubClassroom.tsx b/src/pages/githubAssessments/GitHubClassroom.tsx new file mode 100644 index 0000000000..76fc902c2a --- /dev/null +++ b/src/pages/githubAssessments/GitHubClassroom.tsx @@ -0,0 +1,306 @@ +import { NonIdealState, Spinner } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { Octokit } from '@octokit/rest'; +import { GetResponseDataTypeFromEndpointMethod } from '@octokit/types'; +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; + +import { OverallState } from '../../commons/application/ApplicationTypes'; +import ContentDisplay from '../../commons/ContentDisplay'; +import { MissionRepoData } from '../../commons/githubAssessments/GitHubMissionTypes'; +import GitHubAssessmentsNavigationBar from '../../commons/navigationBar/subcomponents/GitHubAssessmentsNavigationBar'; +import { showWarningMessage } from '../../commons/utils/NotificationsHelper'; +import { assessmentTypeLink } from '../../commons/utils/ParamParseHelper'; +import GitHubAssessmentListing from './GitHubAssessmentListing'; +import GitHubAssessmentWorkspaceContainer from './GitHubAssessmentWorkspaceContainer'; +import GitHubClassroomWelcome from './GitHubClassroomWelcome'; + +type DispatchProps = { + handleGitHubLogIn: () => void; + handleGitHubLogOut: () => void; +}; + +/** + * 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 = props => { + const location = useLocation<{ + courses: string[] | undefined; + assessmentTypeOverviews: GHAssessmentTypeOverview[] | undefined; + selectedCourse: string | undefined; + }>(); + const octokit: Octokit | undefined = useSelector( + (store: OverallState) => store.session.githubOctokitObject + ).octokit; + const [courses, setCourses] = useState(location.state?.courses); + const [selectedCourse, setSelectedCourse] = useState( + location.state?.selectedCourse || '' + ); + const [assessmentTypeOverviews, setAssessmentTypeOverviews] = useState< + GHAssessmentTypeOverview[] | undefined + >(location.state?.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 redirectToLogin = () => ; + const redirectToAssessments = () => ( + + ); + + return ( +
+ { + props.handleGitHubLogOut(); + setCourses(undefined); + setAssessmentTypeOverviews(undefined); + setSelectedCourse(''); + }} + octokit={octokit} + courses={courses} + selectedCourse={selectedCourse} + types={types} + assessmentTypeOverviews={assessmentTypeOverviews} + /> + + { + return octokit && (!courses || (courses.length > 0 && !assessmentTypeOverviews)) ? ( + } />} + loadContentDispatch={() => {}} + /> + ) : octokit && courses && courses.length === 0 ? ( + + ) : octokit ? ( + redirectToAssessments() + ) : ( + + } + loadContentDispatch={() => {}} + /> + ); + }} + /> + (octokit ? : redirectToLogin())} + /> + + {octokit + ? types?.map((type, idx) => { + const filteredAssessments = assessmentTypeOverviews + ? assessmentTypeOverviews[idx].assessments + : undefined; + return ( + ( + + )} + key={idx} + /> + ); + }) + : null} + 0 ? redirectToAssessments : redirectToLogin + } + /> + +
+ ); +}; + +/** + * 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; + } + } +} + +export default GitHubClassroom; diff --git a/src/pages/githubAssessments/GitHubClassroomWelcome.tsx b/src/pages/githubAssessments/GitHubClassroomWelcome.tsx new file mode 100644 index 0000000000..ab29280fc9 --- /dev/null +++ b/src/pages/githubAssessments/GitHubClassroomWelcome.tsx @@ -0,0 +1,34 @@ +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/GitHubMissionListing.tsx b/src/pages/githubAssessments/GitHubMissionListing.tsx deleted file mode 100644 index 7af8eed519..0000000000 --- a/src/pages/githubAssessments/GitHubMissionListing.tsx +++ /dev/null @@ -1,436 +0,0 @@ -import { - Button, - Card, - Divider, - Elevation, - H4, - H6, - Icon, - NonIdealState, - Spinner, - SpinnerSize, - TagInput, - Text -} from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; -import { Octokit } from '@octokit/rest'; -import { - GetResponseDataTypeFromEndpointMethod, - GetResponseTypeFromEndpointMethod -} from '@octokit/types'; -import * as React from 'react'; -import { useEffect, useMemo, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { useMediaQuery } from 'react-responsive'; - -import defaultCoverImage from '../../assets/default_cover_image.jpg'; -import ContentDisplay from '../../commons/ContentDisplay'; -import controlButton from '../../commons/ControlButton'; -import { - getContentAsString, - parseMetadataProperties -} from '../../commons/githubAssessments/GitHubMissionDataUtils'; -import { MissionRepoData } from '../../commons/githubAssessments/GitHubMissionTypes'; -import Markdown from '../../commons/Markdown'; -import Constants from '../../commons/utils/Constants'; -import { history } from '../../commons/utils/HistoryHelper'; -import { getGitHubOctokitInstance } from '../../features/github/GitHubUtils'; - -type DispatchProps = { - handleGitHubLogIn: () => void; - handleGitHubLogOut: () => void; -}; - -/** - * A page that lists the missions available to the authenticated user. - * This page should only be reachable if using a GitHub-hosted deployment. - */ -const GitHubMissionListing: React.FC = props => { - const isMobileBreakpoint = useMediaQuery({ maxWidth: Constants.mobileBreakpoint }); - const octokit: Octokit = useSelector((store: any) => store.session.githubOctokitObject).octokit; - - const [display, setDisplay] = useState(<>); - const [browsableMissions, setBrowsableMissions] = useState([]); - const [filterTagNodes, setFilterTagNodes] = useState([]); - const [filterTagStrings, setFilterTagStrings] = useState([]); - - const handleTagChange = React.useCallback((values: React.ReactNode[]) => { - setFilterTagNodes(values); - - const newFilterTagStrings: string[] = []; - for (let i = 0; i < values.length; i++) { - const value = values[i]; - if (value) { - newFilterTagStrings.push(value.toString().toLowerCase()); - } - } - setFilterTagStrings(newFilterTagStrings); - }, []); - - const handleTagClear = React.useCallback(() => handleTagChange([]), [handleTagChange]); - - const signInToGitHubDisplay = useMemo( - () => , - [] - ); - const noMissionReposFoundDisplay = useMemo( - () => ( - - ), - [] - ); - const createMissionButton = useMemo( - () => ( - - ), - [] - ); - - // After browsable missions retrieved, display mission listing - useEffect(() => { - if (octokit === undefined) { - return; - } - - if (browsableMissions.length === 0) { - setDisplay( - <> - {createMissionButton} - {noMissionReposFoundDisplay} - - ); - return; - } - - // Create tag filter - const clearButton = - - - - - - ); -} - -export default GitHubMissionListing; diff --git a/src/pages/githubAssessments/__tests__/GitHubClassroom.tsx b/src/pages/githubAssessments/__tests__/GitHubClassroom.tsx new file mode 100644 index 0000000000..dce137593b --- /dev/null +++ b/src/pages/githubAssessments/__tests__/GitHubClassroom.tsx @@ -0,0 +1,126 @@ +import { act } from '@testing-library/react'; +import { shallow } from 'enzyme'; +import { useSelector } from 'react-redux'; + +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() +})); + +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); + }); + }); + + const mockProps = { + handleGitHubLogIn: () => {}, + handleGitHubLogOut: () => {} + }; + + it('renders correctly', async () => { + await act(async () => { + const tree = shallow(); + expect(tree.debug()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/pages/githubAssessments/__tests__/__snapshots__/GitHubClassroom.tsx.snap b/src/pages/githubAssessments/__tests__/__snapshots__/GitHubClassroom.tsx.snap new file mode 100644 index 0000000000..009f12b34d --- /dev/null +++ b/src/pages/githubAssessments/__tests__/__snapshots__/GitHubClassroom.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GitHubClassroom renders correctly 1`] = ` +"
+ + + + + + + +
" +`; diff --git a/src/styles/_academy.scss b/src/styles/_academy.scss index d9ee2e2c65..88fb031b6d 100644 --- a/src/styles/_academy.scss +++ b/src/styles/_academy.scss @@ -120,6 +120,7 @@ .listing-text { padding: 0 0 0 0.5rem; + justify-content: space-between; .#{$ns}-heading { margin: 0; diff --git a/src/styles/_commons.scss b/src/styles/_commons.scss index 8929af97ea..b35195cf0a 100644 --- a/src/styles/_commons.scss +++ b/src/styles/_commons.scss @@ -1,5 +1,5 @@ .ContentDisplay { - height: 100%; + height: fit-content; width: 100%; &.row { diff --git a/src/styles/_github.scss b/src/styles/_github.scss index 98b5bb7f1b..9aedebe36c 100644 --- a/src/styles/_github.scss +++ b/src/styles/_github.scss @@ -41,3 +41,19 @@ width: auto; overflow-y: scroll; } + +.github-welcome { + margin-top: 20px; + margin-bottom: 20px; + text-align: center; + + .github-welcome-content { + padding: 10px 20px 10px 20px; + display: inline-block; + margin: 0 0 10px 0; + width: 80%; + @include mQ(750px) { + width: 90%; + } + } +} diff --git a/src/styles/_githubAssessments.scss b/src/styles/_githubAssessments.scss index d77a6ce89d..0f6a7c3425 100644 --- a/src/styles/_githubAssessments.scss +++ b/src/styles/_githubAssessments.scss @@ -11,180 +11,6 @@ flex: 1 1 100%; } -// This is mostly a copy of 'Assessment' style in _academy.scss -// Scrollbar is added to fit better within the dialog -.MissionBrowserContent { - color: $cadet-color-3; - - @media only screen and (max-width: 768px) { - // for mobile display - .ContentDisplay { - .contentdisplay-content.#{$ns}-card { - padding: 10px; - } - - .listing { - height: 160px; - } - - .listing-picture { - height: 100%; - padding: 0; - } - - .listing-text { - padding: 0 0 0 0.5rem; - justify-content: space-between; - - .#{$ns}-heading { - margin: 0; - } - .listing-header { - margin-bottom: 0; - } - - .listing-description { - max-height: 52px; - overflow-y: auto; - font-size: 12px; - margin: 0.5rem 0; - .#{$ns}-running-text { - font-size: 12px; - } - } - - .listing-footer { - font-size: 12px; - } - } - } - } - - .contentdisplay-content.#{$ns}-card { - padding: 10px 20px 10px 20px; - - button.collapse-button { - /* To override the center-xs center alignment */ - display: block; - margin: 0 0 10px 0; - } - } - - .listing.#{$ns}-card { - margin: 0 0 1rem 0; - } - - .listing { - background-color: $cadet-color-5; - margin: 0px; - padding: 0; - text-align: justify; - - & > * { - overflow-wrap: break-word; - } - } - - .listing-picture { - padding: 0; - position: relative; - - img { - height: 100%; - width: 100%; - object-fit: cover; - border-radius: 3px 0 0 3px; - } - - img.cover-image-submitted { - filter: gray; /* IE6-9 */ - -webkit-filter: grayscale(1); /* Google Chrome, Safari 6+ & Opera 15+ */ - filter: grayscale(1); /* Microsoft Edge and Firefox 35+ */ - } - - /* Disable grayscale on hover */ - img.cover-image-submitted:hover { - -webkit-filter: grayscale(0); - filter: none; - } - } - - .listing-text { - padding: 0.5rem 0.5rem 0.5rem 1rem; - border: 1rem; - display: flex; - flex-direction: column; - } - - .listing-header { - margin-bottom: 0.8rem; - display: flex; - align-items: center; - justify-content: space-between; - - .listing-title { - margin-bottom: 0; - - h4 { - margin-top: 4px; - } - } - - .listing-title-tooltip { - /* Space out icon tooltips */ - margin-left: 2px; - - /* Visually separate first icon tooltip from assessment title */ - &:first-of-type { - margin-left: 6px; - } - - .#{$ns}-icon { - vertical-align: baseline; - } - } - } - - .listing-description { - flex-grow: 1; - flex-shrink: 0; - /* Creates space between the description text, buttons and title */ - margin: 0.5rem 0rem 0.5rem 0.5rem; - - & > * { - /* Limit height of description on smaller screens */ - max-height: 30vh; - /* Add padding to visually separate scrollbar from content */ - padding-right: 0.5rem; - overflow-y: auto; - } - } - - .listing-footer { - display: flex; - align-items: center; - justify-content: space-between; - - .listing-due-date { - display: flex; - overflow-x: hidden; - white-space: nowrap; - text-overflow: ellipsis; - align-items: center; - } - - .listing-due-icon { - margin-right: 0.4rem; - } - } - - .listing-button { - /* Prevent
container collapsing and forcing button text to two lines */ - flex-grow: 0; - flex-shrink: 0; - } -} - .SideContentMissionEditor { height: 100%; display: flex;