diff --git a/.env.example b/.env.example index 44cd079732..647eaf0872 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ +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_GAME=TRUE -REACT_APP_ENABLE_ACHIEVEMENTS=TRUE REACT_APP_ENABLE_GITHUB_ASSESSMENTS=TRUE REACT_APP_URL_SHORTENER_SIGNATURE= @@ -24,7 +24,7 @@ REACT_APP_OAUTH2_PROVIDER3_ENDPOINT=http://localhost:8000/login?provider=test&co ## LumiNUS example ## the provider ID, must be URL-friendly (must match the backend configuration) -# REACT_APP_OAUTH2_PROVIDER1=nusnet_id +# REACT_APP_OAUTH2_PROVIDER1=luminus ## the name shown on the login screen: "Log in with ..." # REACT_APP_OAUTH2_PROVIDER1_NAME=LumiNUS ## the OAuth2 endpoint (which must include a client_id, as part of the OAuth2 specification) diff --git a/README.md b/README.md index 4516b8690c..e29874d8f5 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,8 @@ The frontend can be configured to disable itself (based on user's system time) d #### Other configuration +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_GAME`: Whether to enable the game. Off by default. -1. `REACT_APP_ENABLE_ACHIEVEMENTS`: Whether to enable the incentives/achievements system. Off by default. 1. `REACT_APP_ENABLE_GITHUB_ASSESSMENTS`: Whether to enable the GitHub Assessments feature. Off by default. ## Development diff --git a/package.json b/package.json index 3234af08fc..a3933a64ff 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "react-konva": "^17.0.2-4", "react-latex-next": "^2.0.0", "react-mde": "^11.5.0", + "react-papaparse": "^3.16.1", "react-redux": "^7.2.4", "react-responsive": "^8.2.0", "react-router": "^5.2.0", diff --git a/src/commons/XMLParser/XMLParserHelper.ts b/src/commons/XMLParser/XMLParserHelper.ts index aaecf18e75..752d27cbc0 100644 --- a/src/commons/XMLParser/XMLParserHelper.ts +++ b/src/commons/XMLParser/XMLParserHelper.ts @@ -3,9 +3,9 @@ import { Builder } from 'xml2js'; import { ExternalLibraryName } from '../application/types/ExternalTypes'; import { Assessment, - AssessmentCategories, AssessmentOverview, AssessmentStatuses, + AssessmentType, BaseQuestion, GradingStatuses, IMCQQuestion, @@ -60,24 +60,18 @@ export const storeLocalAssessmentOverview = (overview: AssessmentOverview): void export const makeEntireAssessment = (result: any): [AssessmentOverview, Assessment] => { const assessmentArr = makeAssessment(result); - const overview = makeAssessmentOverview(result, assessmentArr[1], assessmentArr[2]); + const overview = makeAssessmentOverview(result, assessmentArr[1]); return [overview, assessmentArr[0]]; }; -const makeAssessmentOverview = ( - result: any, - maxGradeVal: number, - maxXpVal: number -): AssessmentOverview => { +const makeAssessmentOverview = (result: any, maxXpVal: number): AssessmentOverview => { const task: XmlParseStrTask = result.CONTENT.TASK[0]; const rawOverview: XmlParseStrOverview = task.$; return { - category: capitalizeFirstLetter(rawOverview.kind) as AssessmentCategories, + type: capitalizeFirstLetter(rawOverview.kind) as AssessmentType, closeAt: rawOverview.duedate, coverImage: rawOverview.coverimage, - grade: 1, id: EDITING_ID, - maxGrade: maxGradeVal, maxXp: maxXpVal, number: rawOverview.number || '', openAt: rawOverview.startdate, @@ -91,13 +85,13 @@ const makeAssessmentOverview = ( }; }; -const makeAssessment = (result: any): [Assessment, number, number] => { +const makeAssessment = (result: any): [Assessment, number] => { const task: XmlParseStrTask = result.CONTENT.TASK[0]; const rawOverview: XmlParseStrOverview = task.$; const questionArr = makeQuestions(task); return [ { - category: capitalizeFirstLetter(rawOverview.kind) as AssessmentCategories, + type: capitalizeFirstLetter(rawOverview.kind) as AssessmentType, id: EDITING_ID, globalDeployment: makeLibrary(task.DEPLOYMENT), graderDeployment: makeLibrary(task.GRADERDEPLOYMENT), @@ -106,8 +100,7 @@ const makeAssessment = (result: any): [Assessment, number, number] => { questions: questionArr[0], title: rawOverview.title }, - questionArr[1], - questionArr[2] + questionArr[1] ]; }; @@ -147,8 +140,7 @@ const makeLibrary = (deploymentArr: XmlParseStrDeployment[] | undefined): Librar } }; -const makeQuestions = (task: XmlParseStrTask): [Question[], number, number] => { - let maxGrade = 0; +const makeQuestions = (task: XmlParseStrTask): [Question[], number] => { let maxXp = 0; const questions: Array = []; task.PROBLEMS[0].PROBLEM.forEach((problem: XmlParseStrProblem, curId: number) => { @@ -161,11 +153,8 @@ const makeQuestions = (task: XmlParseStrTask): [Question[], number, number] => { graderLibrary: makeLibrary(problem.GRADERDEPLOYMENT), type: problem.$.type, xp: 0, - grade: 0, - maxGrade: parseInt(problem.$.maxgrade, 10), maxXp: localMaxXp }; - maxGrade += parseInt(problem.$.maxgrade, 10); maxXp += localMaxXp; if (question.type === 'programming') { questions.push(makeProgramming(problem as XmlParseStrPProblem, question)); @@ -174,7 +163,7 @@ const makeQuestions = (task: XmlParseStrTask): [Question[], number, number] => { questions.push(makeMCQ(problem as XmlParseStrCProblem, question)); } }); - return [questions, maxGrade, maxXp]; + return [questions, maxXp]; }; const makeMCQ = (problem: XmlParseStrCProblem, question: BaseQuestion): IMCQQuestion => { @@ -306,7 +295,7 @@ export const assessmentToXml = ( const rawOverview: XmlParseStrOverview = { coverimage: overview.coverImage, duedate: overview.closeAt, - kind: overview.category.toLowerCase(), + kind: overview.type.toLowerCase(), number: overview.number || '', startdate: overview.openAt, story: overview.story, @@ -331,8 +320,7 @@ export const assessmentToXml = ( assessment.questions.forEach((question: Question) => { const problem = { $: { - type: question.type, - maxgrade: question.maxGrade + type: question.type }, SNIPPET: { SOLUTION: question.answer diff --git a/src/commons/achievement/AchievementManualEditor.tsx b/src/commons/achievement/AchievementManualEditor.tsx index 301a055e2e..d16f105c36 100644 --- a/src/commons/achievement/AchievementManualEditor.tsx +++ b/src/commons/achievement/AchievementManualEditor.tsx @@ -12,7 +12,7 @@ type AchievementManualEditorProps = { studio: string; users: AchievementUser[]; getUsers: () => void; - updateGoalProgress: (studentId: number, progress: GoalProgress) => void; + updateGoalProgress: (studentCourseRegId: number, progress: GoalProgress) => void; }; const GoalSelect = Select.ofType(); @@ -24,10 +24,13 @@ function AchievementManualEditor(props: AchievementManualEditorProps) { const { studio, getUsers, updateGoalProgress } = props; const users = studio === 'Staff' - ? [...props.users].sort((user1, user2) => user1.name.localeCompare(user2.name)) + ? // The name can be null for users who have yet to log in. We push these to the back of the array. + [...props.users].sort((user1, user2) => + user1.name ? user1.name.localeCompare(user2.name) : 1 + ) : props.users .filter(user => user.group === studio) - .sort((user1, user2) => user1.name.localeCompare(user2.name)); + .sort((user1, user2) => (user1.name ? user1.name.localeCompare(user2.name) : 1)); useEffect(getUsers, [getUsers]); @@ -42,7 +45,7 @@ function AchievementManualEditor(props: AchievementManualEditorProps) { const UserSelect = Select.ofType(); const userRenderer: ItemRenderer = (user, { handleClick }) => ( - + ); const updateGoal = () => { @@ -53,7 +56,7 @@ function AchievementManualEditor(props: AchievementManualEditorProps) { targetCount: goal.targetCount, completed: count >= goal.targetCount }; - updateGoalProgress(selectedUser.userId, progress); + updateGoalProgress(selectedUser.courseRegId, progress); } }; diff --git a/src/commons/achievement/utils/InsertFakeAchievements.ts b/src/commons/achievement/utils/InsertFakeAchievements.ts index 1830a0f97c..434790a97d 100644 --- a/src/commons/achievement/utils/InsertFakeAchievements.ts +++ b/src/commons/achievement/utils/InsertFakeAchievements.ts @@ -24,7 +24,7 @@ function insertFakeAchievements( inferencer.insertFakeGoalDefinition( { uuid: idString + '0', - text: `Submitted ${assessmentOverview.category.toLowerCase()}`, + text: `Submitted ${assessmentOverview.type}`, achievementUuids: [idString], meta: { type: GoalType.ASSESSMENT, @@ -38,7 +38,7 @@ function insertFakeAchievements( inferencer.insertFakeGoalDefinition( { uuid: idString + '1', - text: `Graded ${assessmentOverview.category.toLowerCase()}`, + text: `Graded ${assessmentOverview.type}`, achievementUuids: [idString], meta: { type: GoalType.ASSESSMENT, @@ -53,7 +53,7 @@ function insertFakeAchievements( uuid: idString, title: assessmentOverview.title, ability: - assessmentOverview.category === 'Mission' || assessmentOverview.category === 'Path' + assessmentOverview.type === 'Missions' || assessmentOverview.type === 'Paths' ? AchievementAbility.CORE : AchievementAbility.EFFORT, xp: @@ -71,7 +71,7 @@ function insertFakeAchievements( view: { coverImage: `${coverImageUrl}/default.png`, description: assessmentOverview.shortSummary, - completionText: `Grade: ${assessmentOverview.grade} / ${assessmentOverview.maxGrade}` + completionText: `XP: ${assessmentOverview.xp} / ${assessmentOverview.maxXp}` } }); } diff --git a/src/commons/application/Application.tsx b/src/commons/application/Application.tsx index f3fdd74e06..04f777cedc 100644 --- a/src/commons/application/Application.tsx +++ b/src/commons/application/Application.tsx @@ -13,11 +13,14 @@ import MissionControlContainer from '../../pages/missionControl/MissionControlCo import NotFound from '../../pages/notFound/NotFound'; import Playground from '../../pages/playground/PlaygroundContainer'; import Sicp from '../../pages/sicp/Sicp'; -import SourcecastContainer from '../../pages/sourcecast/SourcecastContainer'; +import Sourcecast from '../../pages/sourcecast/SourcecastContainer'; +import Welcome from '../../pages/welcome/Welcome'; +import { AssessmentType } from '../assessment/AssessmentTypes'; import NavigationBar from '../navigationBar/NavigationBar'; import Constants from '../utils/Constants'; import { parseQuery } from '../utils/QueryHelper'; import { Role } from './ApplicationTypes'; +import { UpdateCourseConfiguration, UserCourse } from './types/SessionTypes'; export type ApplicationProps = DispatchProps & StateProps & RouteComponentProps<{}>; @@ -25,12 +28,20 @@ export type DispatchProps = { handleLogOut: () => void; handleGitHubLogIn: () => void; handleGitHubLogOut: () => void; + fetchUserAndCourse: () => void; + updateLatestViewedCourse: (courseId: number) => void; + handleCreateCourse: (courseConfig: UpdateCourseConfiguration) => void; }; export type StateProps = { role?: Role; - title: string; name?: string; + courses: UserCourse[]; + courseId?: number; + courseShortName?: string; + enableAchievements?: boolean; + enableSourcecast?: boolean; + assessmentTypes?: AssessmentType[]; }; const Application: React.FC = props => { @@ -40,6 +51,15 @@ const Application: React.FC = props => { const isPWA = window.matchMedia('(display-mode: standalone)').matches; // Checks if user is accessing from the PWA const browserDimensions = React.useRef({ height: 0, width: 0 }); + // Effect to fetch the latest user info and course configurations from the backend on refresh, + // if the user was previously logged in + React.useEffect(() => { + if (props.name) { + props.fetchUserAndCourse(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + React.useEffect(() => { if (Constants.disablePeriods.length > 0) { intervalId.current = window.setInterval(() => { @@ -97,20 +117,74 @@ const Application: React.FC = props => { }, [isPWA, isMobile]); const loginPath = ; - const fullPaths = Constants.playgroundOnly - ? null - : [ - , + + const githubAssessmentsPaths = Constants.enableGitHubAssessments + ? [ , - loginPath - ]; - if (!Constants.playgroundOnly && Constants.enableAchievements) { - fullPaths?.push(); + path="/githubassessments" + component={() => ( + + )} + key="githubAssessments" + /> + ] + : []; + + // Paths for the playground-only deployment + const playgroundOnlyPaths = [ + , + , + , + , + , + , + ...githubAssessmentsPaths + ]; + + // Paths for the Source Academy @NUS deployment + const fullPaths = [ + loginPath, + )} + key="authPlayground" + />, + ...playgroundOnlyPaths, + , + )} key="welcome" />, + )} + key="mission-control" + /> + ]; + + if (props.enableSourcecast) { + fullPaths.push( + )} + key="sourcecast" + /> + ); + } + if (props.enableAchievements) { + fullPaths.push( + )} + key="achievements" + /> + ); } + const disabled = !['staff', 'admin'].includes(props.role!) && isDisabled; const renderDisabled = () => ( @@ -123,9 +197,16 @@ const Application: React.FC = props => { handleLogOut={props.handleLogOut} handleGitHubLogIn={props.handleGitHubLogIn} handleGitHubLogOut={props.handleGitHubLogOut} + updateLatestViewedCourse={props.updateLatestViewedCourse} + handleCreateCourse={props.handleCreateCourse} role={props.role} name={props.name} - title={props.title} + courses={props.courses} + courseId={props.courseId} + courseShortName={props.courseShortName} + enableAchievements={props.enableAchievements} + enableSourcecast={props.enableSourcecast} + assessmentTypes={props.assessmentTypes} />
{disabled && ( @@ -141,32 +222,17 @@ const Application: React.FC = props => { )} - {!disabled && ( + {!disabled && Constants.playgroundOnly && ( + + {playgroundOnlyPaths} + + + + )} + {!disabled && !Constants.playgroundOnly && ( - - - - {Constants.enableGitHubAssessments && ( - ( - - )} - /> - )} - - - - {fullPaths} - + )} @@ -178,6 +244,7 @@ const Application: React.FC = props => { const redirectToPlayground = () => ; const redirectToAcademy = () => ; const redirectToLogin = () => ; +const redirectToWelcome = () => ; const redirectToSicp = () => ; /** @@ -185,16 +252,30 @@ const redirectToSicp = () => ; * 1. If the user is logged in, render the Academy component * 2. If the user is not logged in, redirect to /login */ -const toAcademy = ({ role }: ApplicationProps) => - role === undefined ? redirectToLogin : () => ; +const toAcademy = ({ name, role }: ApplicationProps) => + name === undefined + ? redirectToLogin + : role === undefined + ? redirectToWelcome + : () => ; /** - * A user routes to /achievement, - * 1. If the user is logged in, render the Achievement component + * Routes a user to the specified route, + * 1. If the user is logged in, render the specified component * 2. If the user is not logged in, redirect to /login */ -const toAchievement = ({ role }: ApplicationProps) => - role === undefined ? redirectToLogin : () => ; +const ensureUserAndRouteTo = ({ name }: ApplicationProps, to: JSX.Element) => + name === undefined ? redirectToLogin : () => to; + +/** + * Routes a user to the specified route, + * 1. If the user is logged in and has a latest viewed course, render the + * specified component + * 2. If the user is not logged in, redirect to /login + * 3. If the user is logged in, but does not have a course, redirect to /welcome + */ +const ensureUserAndRoleAndRouteTo = ({ name, role }: ApplicationProps, to: JSX.Element) => + name === undefined ? redirectToLogin : role === undefined ? redirectToWelcome : () => to; const toLogin = (props: ApplicationProps) => () => { const qstr = parseQuery(props.location.search); @@ -211,8 +292,6 @@ const toLogin = (props: ApplicationProps) => () => { ); }; -const toIncubator = () => ; - function computeDisabledState() { const now = moment(); for (const { start, end, reason } of Constants.disablePeriods) { diff --git a/src/commons/application/ApplicationContainer.ts b/src/commons/application/ApplicationContainer.ts index 084282d56c..8c65738a03 100644 --- a/src/commons/application/ApplicationContainer.ts +++ b/src/commons/application/ApplicationContainer.ts @@ -1,9 +1,15 @@ import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; import { withRouter } from 'react-router'; import { bindActionCreators, Dispatch } from 'redux'; +import { createCourse } from 'src/features/academy/AcademyActions'; import { logOut } from './actions/CommonsActions'; -import { loginGitHub, logoutGitHub } from './actions/SessionActions'; +import { + fetchUserAndCourse, + loginGitHub, + logoutGitHub, + updateLatestViewedCourse +} from './actions/SessionActions'; import Application, { DispatchProps, StateProps } from './Application'; import { OverallState } from './ApplicationTypes'; @@ -15,14 +21,26 @@ import { OverallState } from './ApplicationTypes'; * provided using the withRouter() method below. */ const mapStateToProps: MapStateToProps = state => ({ - title: state.application.title, role: state.session.role, - name: state.session.name + name: state.session.name, + courses: state.session.courses, + courseId: state.session.courseId, + courseShortName: state.session.courseShortName, + enableAchievements: state.session.enableAchievements, + enableSourcecast: state.session.enableSourcecast, + assessmentTypes: state.session.assessmentConfigurations?.map(e => e.type) }); const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( - { handleLogOut: logOut, handleGitHubLogIn: loginGitHub, handleGitHubLogOut: logoutGitHub }, + { + handleLogOut: logOut, + handleGitHubLogIn: loginGitHub, + handleGitHubLogOut: logoutGitHub, + fetchUserAndCourse: fetchUserAndCourse, + updateLatestViewedCourse: updateLatestViewedCourse, + handleCreateCourse: createCourse + }, dispatch ); diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 7bfcead0f2..a8c1abd33d 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -29,7 +29,6 @@ export type OverallState = { }; export type ApplicationState = { - readonly title: string; readonly environment: ApplicationEnvironment; }; @@ -164,12 +163,14 @@ export const defaultAcademy: AcademyState = { }; export const defaultApplication: ApplicationState = { - title: 'Cadet', environment: currentEnvironment() }; export const defaultDashboard: DashboardState = { - gradingSummary: [] + gradingSummary: { + cols: [], + rows: [] + } }; export const defaultAchievement: AchievementState = { @@ -284,34 +285,28 @@ export const defaultWorkspaceManager: WorkspaceManagerState = { }; export const defaultSession: SessionState = { - accessToken: undefined, + courses: [], + group: null, + gameState: { + completed_quests: [], + collectibles: {} + }, + xp: 0, + story: { + story: '', + playStory: false + }, assessments: new Map(), assessmentOverviews: undefined, experimentApproval: false, // TODO: get this from backend or sth experimentCoinflip: Math.random() < 0.5, githubOctokitObject: { octokit: undefined }, - grade: 0, gradingOverviews: undefined, gradings: new Map(), - group: null, historyHelper: { lastAcademyLocations: [null, null], lastGeneralLocations: [null, null] }, - maxGrade: 0, - maxXp: 0, - refreshToken: undefined, - role: undefined, - name: undefined, - story: { - story: '', - playStory: false - }, - gameState: { - completed_quests: [], - collectibles: {} - }, - xp: 0, notifications: [] }; diff --git a/src/commons/application/__tests__/Application.tsx b/src/commons/application/__tests__/Application.tsx index 4e6afd5d99..fb40fa3d3b 100644 --- a/src/commons/application/__tests__/Application.tsx +++ b/src/commons/application/__tests__/Application.tsx @@ -18,10 +18,13 @@ window.matchMedia = const props: ApplicationProps = { ...mockRouterProps('/academy', {}), - title: 'Cadet', + courses: [], handleLogOut: () => {}, handleGitHubLogIn: () => {}, - handleGitHubLogOut: () => {} + handleGitHubLogOut: () => {}, + fetchUserAndCourse: () => {}, + updateLatestViewedCourse: () => {}, + handleCreateCourse: () => {} }; test('Application renders correctly', () => { diff --git a/src/commons/application/__tests__/__snapshots__/Application.tsx.snap b/src/commons/application/__tests__/__snapshots__/Application.tsx.snap index 822ba8dbd6..24365c4782 100644 --- a/src/commons/application/__tests__/__snapshots__/Application.tsx.snap +++ b/src/commons/application/__tests__/__snapshots__/Application.tsx.snap @@ -2,21 +2,21 @@ exports[`Application renders correctly 1`] = ` "
- +
+ + - - + - - - + + @@ -26,7 +26,7 @@ exports[`Application renders correctly 1`] = ` exports[`Application shows disabled when in disabled period 1`] = ` "
- +
diff --git a/src/commons/application/__tests__/__snapshots__/ApplicationReducer.ts.snap b/src/commons/application/__tests__/__snapshots__/ApplicationReducer.ts.snap index 21139a3532..c200e50f7f 100644 --- a/src/commons/application/__tests__/__snapshots__/ApplicationReducer.ts.snap +++ b/src/commons/application/__tests__/__snapshots__/ApplicationReducer.ts.snap @@ -3,6 +3,5 @@ exports[`initial state should match a snapshot 1`] = ` Object { "environment": "test", - "title": "Cadet", } `; diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index 9176862b62..ac6170487a 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -2,20 +2,34 @@ import { action } from 'typesafe-actions'; // EDITED import { MissionRepoData } from '../../../commons/githubAssessments/GitHubMissionTypes'; import { Grading, GradingOverview } from '../../../features/grading/GradingTypes'; -import { Assessment, AssessmentOverview, ContestEntry } from '../../assessment/AssessmentTypes'; +import { + Assessment, + AssessmentConfiguration, + AssessmentOverview, + ContestEntry +} from '../../assessment/AssessmentTypes'; import { Notification, NotificationFilterFunction } from '../../notificationBadge/NotificationBadgeTypes'; import { generateOctokitInstance } from '../../utils/GitHubPersistenceHelper'; +import { Role } from '../ApplicationTypes'; import { ACKNOWLEDGE_NOTIFICATIONS, + AdminPanelCourseRegistration, + CourseRegistration, + DELETE_ASSESSMENT_CONFIG, + DELETE_USER_COURSE_REGISTRATION, + FETCH_ADMIN_PANEL_COURSE_REGISTRATIONS, FETCH_ASSESSMENT, + FETCH_ASSESSMENT_CONFIGS, FETCH_ASSESSMENT_OVERVIEWS, FETCH_AUTH, + FETCH_COURSE_CONFIG, FETCH_GRADING, FETCH_GRADING_OVERVIEWS, FETCH_NOTIFICATIONS, + FETCH_USER_AND_COURSE, LOGIN, LOGIN_GITHUB, LOGOUT_GITHUB, @@ -23,6 +37,10 @@ import { REAUTOGRADE_ANSWER, REAUTOGRADE_SUBMISSION, REMOVE_GITHUB_OCTOKIT_OBJECT_AND_ACCESS_TOKEN, + SET_ADMIN_PANEL_COURSE_REGISTRATIONS, + SET_ASSESSMENT_CONFIGURATIONS, + SET_COURSE_CONFIGURATION, + SET_COURSE_REGISTRATION, SET_GITHUB_ACCESS_TOKEN, SET_GITHUB_ASSESSMENT, SET_GITHUB_OCTOKIT_OBJECT, @@ -33,19 +51,29 @@ import { SUBMIT_ASSESSMENT, SUBMIT_GRADING, SUBMIT_GRADING_AND_CONTINUE, + Tokens, UNSUBMIT_SUBMISSION, UPDATE_ASSESSMENT, + UPDATE_ASSESSMENT_CONFIGS, UPDATE_ASSESSMENT_OVERVIEWS, + UPDATE_COURSE_CONFIG, UPDATE_GRADING, UPDATE_GRADING_OVERVIEWS, UPDATE_HISTORY_HELPERS, + UPDATE_LATEST_VIEWED_COURSE, UPDATE_NOTIFICATIONS, + UPDATE_USER_ROLE, + UpdateCourseConfiguration, User } from '../types/SessionTypes'; export const fetchAuth = (code: string, providerId?: string) => action(FETCH_AUTH, { code, providerId }); +export const fetchUserAndCourse = () => action(FETCH_USER_AND_COURSE); + +export const fetchCourseConfig = () => action(FETCH_COURSE_CONFIG); + export const fetchAssessment = (id: number) => action(FETCH_ASSESSMENT, id); export const fetchAssessmentOverviews = () => action(FETCH_ASSESSMENT_OVERVIEWS); @@ -67,13 +95,7 @@ export const loginGitHub = () => action(LOGIN_GITHUB); export const logoutGitHub = () => action(LOGOUT_GITHUB); -export const setTokens = ({ - accessToken, - refreshToken -}: { - accessToken: string; - refreshToken: string; -}) => +export const setTokens = ({ accessToken, refreshToken }: Tokens) => action(SET_TOKENS, { accessToken, refreshToken @@ -81,6 +103,19 @@ export const setTokens = ({ export const setUser = (user: User) => action(SET_USER, user); +export const setCourseConfiguration = (courseConfiguration: UpdateCourseConfiguration) => + action(SET_COURSE_CONFIGURATION, courseConfiguration); + +export const setCourseRegistration = (courseRegistration: CourseRegistration) => + action(SET_COURSE_REGISTRATION, courseRegistration); + +export const setAssessmentConfigurations = (assessmentConfigurations: AssessmentConfiguration[]) => + action(SET_ASSESSMENT_CONFIGURATIONS, assessmentConfigurations); + +export const setAdminPanelCourseRegistrations = ( + courseRegistrations: AdminPanelCourseRegistration[] +) => action(SET_ADMIN_PANEL_COURSE_REGISTRATIONS, courseRegistrations); + export const setGoogleUser = (user?: string) => action(SET_GOOGLE_USER, user); export const setGitHubAssessment = (missionRepoData: MissionRepoData) => @@ -106,14 +141,12 @@ export const submitAssessment = (id: number) => action(SUBMIT_ASSESSMENT, id); export const submitGrading = ( submissionId: number, questionId: number, - gradeAdjustment: number = 0, xpAdjustment: number = 0, comments?: string ) => action(SUBMIT_GRADING, { submissionId, questionId, - gradeAdjustment, xpAdjustment, comments }); @@ -121,14 +154,12 @@ export const submitGrading = ( export const submitGradingAndContinue = ( submissionId: number, questionId: number, - gradeAdjustment: number = 0, xpAdjustment: number = 0, comments?: string ) => action(SUBMIT_GRADING_AND_CONTINUE, { submissionId, questionId, - gradeAdjustment, xpAdjustment, comments }); @@ -177,3 +208,26 @@ export const acknowledgeNotifications = (withFilter?: NotificationFilterFunction export const updateNotifications = (notifications: Notification[]) => action(UPDATE_NOTIFICATIONS, notifications); + +export const updateLatestViewedCourse = (courseId: number) => + action(UPDATE_LATEST_VIEWED_COURSE, { courseId }); + +export const updateCourseConfig = (courseConfiguration: UpdateCourseConfiguration) => + action(UPDATE_COURSE_CONFIG, courseConfiguration); + +export const fetchAssessmentConfigs = () => action(FETCH_ASSESSMENT_CONFIGS); + +export const updateAssessmentConfigs = (assessmentConfigs: AssessmentConfiguration[]) => + action(UPDATE_ASSESSMENT_CONFIGS, assessmentConfigs); + +export const deleteAssessmentConfig = (assessmentConfig: AssessmentConfiguration) => + action(DELETE_ASSESSMENT_CONFIG, assessmentConfig); + +export const fetchAdminPanelCourseRegistrations = () => + action(FETCH_ADMIN_PANEL_COURSE_REGISTRATIONS); + +export const updateUserRole = (courseRegId: number, role: Role) => + action(UPDATE_USER_ROLE, { courseRegId, role }); + +export const deleteUserCourseRegistration = (courseRegId: number) => + action(DELETE_USER_COURSE_REGISTRATION, { courseRegId }); diff --git a/src/commons/application/actions/__tests__/SessionActions.ts b/src/commons/application/actions/__tests__/SessionActions.ts index 26ee9fc95f..05b356da6d 100644 --- a/src/commons/application/actions/__tests__/SessionActions.ts +++ b/src/commons/application/actions/__tests__/SessionActions.ts @@ -1,18 +1,30 @@ +import { Variant } from 'js-slang/dist/types'; + import { Grading, GradingOverview } from '../../../../features/grading/GradingTypes'; import { Assessment, AssessmentOverview } from '../../../assessment/AssessmentTypes'; import { Notification } from '../../../notificationBadge/NotificationBadgeTypes'; import { GameState, Role, Story } from '../../ApplicationTypes'; import { ACKNOWLEDGE_NOTIFICATIONS, + DELETE_ASSESSMENT_CONFIG, + DELETE_USER_COURSE_REGISTRATION, + FETCH_ADMIN_PANEL_COURSE_REGISTRATIONS, FETCH_ASSESSMENT, + FETCH_ASSESSMENT_CONFIGS, FETCH_ASSESSMENT_OVERVIEWS, FETCH_AUTH, + FETCH_COURSE_CONFIG, FETCH_GRADING, FETCH_GRADING_OVERVIEWS, FETCH_NOTIFICATIONS, + FETCH_USER_AND_COURSE, LOGIN, REAUTOGRADE_ANSWER, REAUTOGRADE_SUBMISSION, + SET_ADMIN_PANEL_COURSE_REGISTRATIONS, + SET_ASSESSMENT_CONFIGURATIONS, + SET_COURSE_CONFIGURATION, + SET_COURSE_REGISTRATION, SET_GITHUB_ACCESS_TOKEN, SET_GITHUB_OCTOKIT_OBJECT, SET_TOKENS, @@ -23,23 +35,37 @@ import { SUBMIT_GRADING_AND_CONTINUE, UNSUBMIT_SUBMISSION, UPDATE_ASSESSMENT, + UPDATE_ASSESSMENT_CONFIGS, UPDATE_ASSESSMENT_OVERVIEWS, + UPDATE_COURSE_CONFIG, UPDATE_GRADING, UPDATE_GRADING_OVERVIEWS, UPDATE_HISTORY_HELPERS, - UPDATE_NOTIFICATIONS + UPDATE_LATEST_VIEWED_COURSE, + UPDATE_NOTIFICATIONS, + UPDATE_USER_ROLE } from '../../types/SessionTypes'; import { acknowledgeNotifications, + deleteAssessmentConfig, + deleteUserCourseRegistration, + fetchAdminPanelCourseRegistrations, fetchAssessment, + fetchAssessmentConfigs, fetchAssessmentOverviews, fetchAuth, + fetchCourseConfig, fetchGrading, fetchGradingOverviews, fetchNotifications, + fetchUserAndCourse, login, reautogradeAnswer, reautogradeSubmission, + setAdminPanelCourseRegistrations, + setAssessmentConfigurations, + setCourseConfiguration, + setCourseRegistration, setGitHubAccessToken, setGitHubOctokitObject, setTokens, @@ -50,11 +76,15 @@ import { submitGradingAndContinue, unsubmitSubmission, updateAssessment, + updateAssessmentConfigs, updateAssessmentOverviews, + updateCourseConfig, updateGrading, updateGradingOverviews, updateHistoryHelpers, - updateNotifications + updateLatestViewedCourse, + updateNotifications, + updateUserRole } from '../SessionActions'; test('acknowledgeNotifications generates correct action object', () => { @@ -77,6 +107,20 @@ test('fetchAuth generates correct action object', () => { }); }); +test('fetchUserAndCourse generates correct action object', () => { + const action = fetchUserAndCourse(); + expect(action).toEqual({ + type: FETCH_USER_AND_COURSE + }); +}); + +test('fetchCourseConfig generates correct action object', () => { + const action = fetchCourseConfig(); + expect(action).toEqual({ + type: FETCH_COURSE_CONFIG + }); +}); + test('fetchAssessment generates correct action object', () => { const id = 3; const action = fetchAssessment(id); @@ -152,11 +196,22 @@ test('setUser generates correct action object', () => { const user = { userId: 123, name: 'test student', - role: 'student' as Role, - group: '42D', - grade: 150, - story: {} as Story, - gameState: {} as GameState + courses: [ + { + courseId: 1, + courseName: `CS1101 Programming Methodology (AY20/21 Sem 1)`, + courseShortName: `CS1101S`, + role: Role.Admin, + viewable: true + }, + { + courseId: 2, + courseName: `CS2030S Programming Methodology II (AY20/21 Sem 2)`, + courseShortName: `CS2030S`, + role: Role.Staff, + viewable: true + } + ] }; const action = setUser(user); expect(action).toEqual({ @@ -165,6 +220,109 @@ test('setUser generates correct action object', () => { }); }); +test('setCourseConfiguration generates correct action object', () => { + const courseConfig = { + courseName: `CS1101 Programming Methodology (AY20/21 Sem 1)`, + courseShortName: `CS1101S`, + viewable: true, + enableGame: true, + enableAchievements: true, + enableSourcecast: true, + sourceChapter: 1, + sourceVariant: 'default' as Variant, + moduleHelpText: 'Help text', + assessmentTypes: ['Missions', 'Quests', 'Paths', 'Contests', 'Others'] + }; + const action = setCourseConfiguration(courseConfig); + expect(action).toEqual({ + type: SET_COURSE_CONFIGURATION, + payload: courseConfig + }); +}); + +test('setCourseRegistration generates correct action object', () => { + const courseRegistration = { + courseRegId: 1, + role: Role.Student, + group: '42D', + gameState: { + collectibles: {}, + completed_quests: [] + } as GameState, + courseId: 1, + grade: 1, + maxGrade: 10, + xp: 1, + story: { + story: '', + playStory: false + } as Story + }; + const action = setCourseRegistration(courseRegistration); + expect(action).toEqual({ + type: SET_COURSE_REGISTRATION, + payload: courseRegistration + }); +}); + +test('setAssessmentConfigurations generates correct action object', () => { + const assesmentConfigurations = [ + { + assessmentConfigId: 1, + type: 'Mission1', + isManuallyGraded: true, + displayInDashboard: true, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + }, + { + assessmentConfigId: 2, + type: 'Mission2', + isManuallyGraded: true, + displayInDashboard: true, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + }, + { + assessmentConfigId: 3, + type: 'Mission3', + isManuallyGraded: true, + displayInDashboard: true, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + } + ]; + const action = setAssessmentConfigurations(assesmentConfigurations); + expect(action).toEqual({ + type: SET_ASSESSMENT_CONFIGURATIONS, + payload: assesmentConfigurations + }); +}); + +test('setAdminPanelCourseRegistrations generates correct action object', async () => { + const userCourseRegistrations = [ + { + courseRegId: 1, + courseId: 1, + name: 'Bob', + username: 'test/bob123', + role: Role.Student + }, + { + courseRegId: 2, + courseId: 1, + name: 'Avenger', + username: 'test/avenger456', + role: Role.Staff + } + ]; + const action = setAdminPanelCourseRegistrations(userCourseRegistrations); + expect(action).toEqual({ + type: SET_ADMIN_PANEL_COURSE_REGISTRATIONS, + payload: userCourseRegistrations + }); +}); + test('setGitHubOctokitInstance generates correct action object', async () => { const authToken = 'testAuthToken12345'; const action = setGitHubOctokitObject(authToken); @@ -216,7 +374,6 @@ test('submitGrading generates correct action object with default values', () => payload: { submissionId, questionId, - gradeAdjustment: 0, xpAdjustment: 0, comments: undefined } @@ -233,7 +390,6 @@ test('submitGradingAndContinue generates correct action object with default valu payload: { submissionId, questionId, - gradeAdjustment: 0, xpAdjustment: 0, comments: undefined } @@ -243,16 +399,14 @@ test('submitGradingAndContinue generates correct action object with default valu test('submitGrading generates correct action object', () => { const submissionId = 10; const questionId = 3; - const gradeAdjustment = 10; const xpAdjustment = 100; const comments = 'my comment'; - const action = submitGrading(submissionId, questionId, gradeAdjustment, xpAdjustment, comments); + const action = submitGrading(submissionId, questionId, xpAdjustment, comments); expect(action).toEqual({ type: SUBMIT_GRADING, payload: { submissionId, questionId, - gradeAdjustment, xpAdjustment, comments } @@ -262,22 +416,14 @@ test('submitGrading generates correct action object', () => { test('submitGradingAndContinue generates correct action object', () => { const submissionId = 4; const questionId = 7; - const gradeAdjustment = 90; const xpAdjustment = 55; const comments = 'another comment'; - const action = submitGradingAndContinue( - submissionId, - questionId, - gradeAdjustment, - xpAdjustment, - comments - ); + const action = submitGradingAndContinue(submissionId, questionId, xpAdjustment, comments); expect(action).toEqual({ type: SUBMIT_GRADING_AND_CONTINUE, payload: { submissionId, questionId, - gradeAdjustment, xpAdjustment, comments } @@ -326,12 +472,10 @@ test('updateHistoryHelpers generates correct action object', () => { test('updateAssessmentOverviews generates correct action object', () => { const overviews: AssessmentOverview[] = [ { - category: 'Mission', + type: 'Missions', closeAt: 'test_string', coverImage: 'test_string', - grade: 0, id: 0, - maxGrade: 0, maxXp: 0, openAt: 'test_string', title: 'test_string', @@ -351,7 +495,7 @@ test('updateAssessmentOverviews generates correct action object', () => { test('updateAssessment generates correct action object', () => { const assessment: Assessment = { - category: 'Mission', + type: 'Missions', globalDeployment: undefined, graderDeployment: undefined, id: 1, @@ -373,11 +517,7 @@ test('updateGradingOverviews generates correct action object', () => { { assessmentId: 1, assessmentName: 'test assessment', - assessmentCategory: 'Contest', - initialGrade: 0, - gradeAdjustment: 0, - currentGrade: 10, - maxGrade: 20, + assessmentType: 'Contests', initialXp: 0, xpBonus: 100, xpAdjustment: 50, @@ -411,8 +551,6 @@ test('updateGrading generates correct action object', () => { id: 234 }, grade: { - grade: 10, - gradeAdjustment: 0, xp: 100, xpAdjustment: 0, comments: 'Well done.', @@ -460,3 +598,131 @@ test('updateNotifications generates correct action object', () => { payload: notifications }); }); + +test('updateLatestViewedCourse generates correct action object', () => { + const courseId = 2; + const action = updateLatestViewedCourse(courseId); + expect(action).toEqual({ + type: UPDATE_LATEST_VIEWED_COURSE, + payload: { courseId } + }); +}); + +test('updateCourseConfig generates correct action object', () => { + const courseConfig = { + courseName: `CS1101 Programming Methodology (AY20/21 Sem 1)`, + courseShortName: `CS1101S`, + viewable: true, + enableGame: true, + enableAchievements: true, + enableSourcecast: true, + sourceChapter: 1, + sourceVariant: 'default' as Variant, + moduleHelpText: 'Help text', + assessmentTypes: ['Missions', 'Quests', 'Paths', 'Contests', 'Others'] + }; + const action = updateCourseConfig(courseConfig); + expect(action).toEqual({ + type: UPDATE_COURSE_CONFIG, + payload: courseConfig + }); +}); + +test('fetchAssessmentConfig generates correct action object', () => { + const action = fetchAssessmentConfigs(); + expect(action).toEqual({ + type: FETCH_ASSESSMENT_CONFIGS + }); +}); + +test('updateAssessmentTypes generates correct action object', () => { + const assessmentConfigs = [ + { + assessmentConfigId: 1, + type: 'Missions', + isManuallyGraded: true, + displayInDashboard: true, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + }, + { + assessmentConfigId: 2, + type: 'Quests', + isManuallyGraded: true, + displayInDashboard: true, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + }, + { + assessmentConfigId: 3, + type: 'Paths', + isManuallyGraded: true, + displayInDashboard: true, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + }, + { + assessmentConfigId: 4, + type: 'Contests', + isManuallyGraded: true, + displayInDashboard: true, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + }, + { + assessmentConfigId: 5, + type: 'Others', + isManuallyGraded: true, + displayInDashboard: true, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + } + ]; + const action = updateAssessmentConfigs(assessmentConfigs); + expect(action).toEqual({ + type: UPDATE_ASSESSMENT_CONFIGS, + payload: assessmentConfigs + }); +}); + +test('deleteAssessmentConfig generates correct action object', () => { + const assessmentConfig = { + assessmentConfigId: 1, + type: 'Mission1', + isManuallyGraded: true, + displayInDashboard: true, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + }; + const action = deleteAssessmentConfig(assessmentConfig); + expect(action).toEqual({ + type: DELETE_ASSESSMENT_CONFIG, + payload: assessmentConfig + }); +}); + +test('fetchAdminPanelCourseRegistrations generates correct action object', () => { + const action = fetchAdminPanelCourseRegistrations(); + expect(action).toEqual({ + type: FETCH_ADMIN_PANEL_COURSE_REGISTRATIONS + }); +}); + +test('updateUserRole generates correct action object', () => { + const courseRegId = 1; + const role = Role.Staff; + const action = updateUserRole(courseRegId, role); + expect(action).toEqual({ + type: UPDATE_USER_ROLE, + payload: { courseRegId, role } + }); +}); + +test('deleteUserCourseRegistration generates correct action object', () => { + const courseRegId = 1; + const action = deleteUserCourseRegistration(courseRegId); + expect(action).toEqual({ + type: DELETE_USER_COURSE_REGISTRATION, + payload: { courseRegId } + }); +}); diff --git a/src/commons/application/reducers/SessionsReducer.ts b/src/commons/application/reducers/SessionsReducer.ts index ab6dc8aea2..d4963494ac 100644 --- a/src/commons/application/reducers/SessionsReducer.ts +++ b/src/commons/application/reducers/SessionsReducer.ts @@ -10,6 +10,10 @@ import { LOG_OUT } from '../types/CommonsTypes'; import { REMOVE_GITHUB_OCTOKIT_OBJECT_AND_ACCESS_TOKEN, SessionState, + SET_ADMIN_PANEL_COURSE_REGISTRATIONS, + SET_ASSESSMENT_CONFIGURATIONS, + SET_COURSE_CONFIGURATION, + SET_COURSE_REGISTRATION, SET_GITHUB_ACCESS_TOKEN, SET_GITHUB_ASSESSMENT, SET_GITHUB_OCTOKIT_OBJECT, @@ -54,14 +58,33 @@ export const SessionsReducer: Reducer = ( case SET_TOKENS: return { ...state, - accessToken: action.payload.accessToken, - refreshToken: action.payload.refreshToken + ...action.payload }; case SET_USER: return { ...state, ...action.payload }; + case SET_COURSE_CONFIGURATION: + return { + ...state, + ...action.payload + }; + case SET_COURSE_REGISTRATION: + return { + ...state, + ...action.payload + }; + case SET_ASSESSMENT_CONFIGURATIONS: + return { + ...state, + assessmentConfigurations: action.payload + }; + case SET_ADMIN_PANEL_COURSE_REGISTRATIONS: + return { + ...state, + userCourseRegistrations: action.payload + }; case UPDATE_HISTORY_HELPERS: const helper = state.historyHelper; const isAcademy = isAcademyRe.exec(action.payload) != null; diff --git a/src/commons/application/reducers/__tests__/SessionReducer.ts b/src/commons/application/reducers/__tests__/SessionReducer.ts index aa885d4d81..5ad8ba1d46 100644 --- a/src/commons/application/reducers/__tests__/SessionReducer.ts +++ b/src/commons/application/reducers/__tests__/SessionReducer.ts @@ -1,7 +1,8 @@ +import { Variant } from 'js-slang/dist/types'; + import { Grading, GradingOverview } from '../../../../features/grading/GradingTypes'; import { Assessment, - AssessmentCategories, AssessmentOverview, AssessmentStatuses, GradingStatuses @@ -12,6 +13,10 @@ import { defaultSession, GameState, Role, Story } from '../../ApplicationTypes'; import { LOG_OUT } from '../../types/CommonsTypes'; import { SessionState, + SET_ADMIN_PANEL_COURSE_REGISTRATIONS, + SET_ASSESSMENT_CONFIGURATIONS, + SET_COURSE_CONFIGURATION, + SET_COURSE_REGISTRATION, SET_GITHUB_ACCESS_TOKEN, SET_TOKENS, SET_USER, @@ -54,21 +59,23 @@ test('SET_TOKEN sets accessToken and refreshToken correctly', () => { }); test('SET_USER works correctly', () => { - const story: Story = { - story: 'test story', - playStory: true - }; - const gameState: GameState = { - collectibles: {}, - completed_quests: [] - }; const payload = { name: 'test student', role: Role.Student, - group: '42D', - grade: 150, - story, - gameState + courses: [ + { + courseId: 1, + courseName: `CS1101 Programming Methodology (AY20/21 Sem 1)`, + courseShortName: `CS1101S`, + viewable: true + }, + { + courseId: 2, + courseName: `CS2030S Programming Methodology II (AY20/21 Sem 2)`, + courseShortName: `CS2030S`, + viewable: true + } + ] }; const action = { @@ -83,6 +90,131 @@ test('SET_USER works correctly', () => { }); }); +test('SET_COURSE_CONFIGURATION works correctly', () => { + const payload = { + courseName: `CS1101 Programming Methodology (AY20/21 Sem 1)`, + courseShortName: `CS1101S`, + viewable: true, + enableGame: true, + enableAchievements: true, + enableSourcecast: true, + sourceChapter: 1, + sourceVariant: 'default' as Variant, + moduleHelpText: 'Help text', + assessmentTypes: ['Missions', 'Quests', 'Paths', 'Contests', 'Others'] + }; + const action = { + type: SET_COURSE_CONFIGURATION, + payload + }; + const result: SessionState = SessionsReducer(defaultSession, action); + + expect(result).toEqual({ + ...defaultSession, + ...payload + }); +}); + +test('SET_COURSE_REGISTRATION works correctly', () => { + const payload = { + role: Role.Student, + group: '42D', + gameState: { + collectibles: {}, + completed_quests: [] + } as GameState, + courseId: 1, + grade: 1, + maxGrade: 10, + xp: 1, + story: { + story: '', + playStory: false + } as Story + }; + const action = { + type: SET_COURSE_REGISTRATION, + payload + }; + const result: SessionState = SessionsReducer(defaultSession, action); + + expect(result).toEqual({ + ...defaultSession, + ...payload + }); +}); + +test('SET_ASSESSMENT_CONFIGURATIONS works correctly', () => { + const payload = [ + { + assessmentConfigId: 1, + type: 'Mission1', + buildHidden: false, + buildSolution: false, + isContest: false, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + }, + { + assessmentConfigId: 1, + type: 'Mission1', + buildHidden: false, + buildSolution: false, + isContest: false, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + }, + { + assessmentConfigId: 1, + type: 'Mission1', + buildHidden: false, + buildSolution: false, + isContest: false, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + } + ]; + + const action = { + type: SET_ASSESSMENT_CONFIGURATIONS, + payload + }; + const result: SessionState = SessionsReducer(defaultSession, action); + + expect(result).toEqual({ + ...defaultSession, + assessmentConfigurations: payload + }); +}); + +test('SET_ADMIN_PANEL_COURSE_REGISTRATIONS works correctly', () => { + const payload = [ + { + courseRegId: 1, + courseId: 1, + name: 'Bob', + role: Role.Student + }, + { + courseRegId: 2, + courseId: 1, + name: 'Avenger', + role: Role.Staff + } + ]; + + const action = { + type: SET_ADMIN_PANEL_COURSE_REGISTRATIONS, + payload + }; + const result: SessionState = SessionsReducer(defaultSession, action); + + expect(result).toEqual({ + ...defaultSession, + userCourseRegistrations: payload + }); +}); + test('SET_GITHUB_ACCESS_TOKEN works correctly', () => { const token = 'githubAccessToken'; const action = { @@ -150,7 +282,7 @@ test('UPDATE_HISTORY_HELPERS works on academy location', () => { // Test Data for UPDATE_ASSESSMENT const assessmentTest1: Assessment = { - category: 'Mission', + type: 'Mission', globalDeployment: undefined, graderDeployment: undefined, id: 1, @@ -161,7 +293,7 @@ const assessmentTest1: Assessment = { }; const assessmentTest2: Assessment = { - category: 'Contest', + type: 'Contest', globalDeployment: undefined, graderDeployment: undefined, id: 1, @@ -172,7 +304,7 @@ const assessmentTest2: Assessment = { }; const assessmentTest3: Assessment = { - category: 'Path', + type: 'Path', globalDeployment: undefined, graderDeployment: undefined, id: 3, @@ -231,12 +363,10 @@ test('UPDATE_ASSESSMENT works correctly in updating assessment', () => { // Test data for UPDATE_ASSESSMENT_OVERVIEWS const assessmentOverviewsTest1: AssessmentOverview[] = [ { - category: AssessmentCategories.Mission, + type: 'Missions', closeAt: 'test_string', coverImage: 'test_string', - grade: 0, id: 0, - maxGrade: 0, maxXp: 0, openAt: 'test_string', title: 'test_string', @@ -250,13 +380,11 @@ const assessmentOverviewsTest1: AssessmentOverview[] = [ const assessmentOverviewsTest2: AssessmentOverview[] = [ { - category: AssessmentCategories.Contest, + type: 'Contests', closeAt: 'test_string_0', coverImage: 'test_string_0', fileName: 'test_sting_0', - grade: 1, id: 1, - maxGrade: 1, maxXp: 1, openAt: 'test_string_0', title: 'test_string_0', @@ -310,8 +438,6 @@ const gradingTest1: Grading = [ id: 234 }, grade: { - grade: 10, - gradeAdjustment: 0, xp: 100, xpAdjustment: 0, comments: 'Well done. Please try the quest!' @@ -327,8 +453,6 @@ const gradingTest2: Grading = [ id: 345 }, grade: { - grade: 30, - gradeAdjustment: 10, xp: 500, xpAdjustment: 20, comments: 'Good job! All the best for the finals.' @@ -400,11 +524,7 @@ const gradingOverviewTest1: GradingOverview[] = [ { assessmentId: 1, assessmentName: 'test assessment', - assessmentCategory: 'Contest', - initialGrade: 0, - gradeAdjustment: 0, - currentGrade: 10, - maxGrade: 20, + assessmentType: 'Contests', initialXp: 0, xpBonus: 100, xpAdjustment: 50, @@ -425,11 +545,7 @@ const gradingOverviewTest2: GradingOverview[] = [ { assessmentId: 2, assessmentName: 'another assessment', - assessmentCategory: 'Sidequest', - initialGrade: 5, - gradeAdjustment: 10, - currentGrade: 20, - maxGrade: 50, + assessmentType: 'Quests', initialXp: 20, xpBonus: 250, xpAdjustment: 100, diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index 7cb174bcc1..80ef2f3e55 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -1,14 +1,21 @@ import { Octokit } from '@octokit/rest'; +import { Variant } from 'js-slang/dist/types'; import { MissionRepoData } from '../../../commons/githubAssessments/GitHubMissionTypes'; import { Grading, GradingOverview } from '../../../features/grading/GradingTypes'; import { Device, DeviceSession } from '../../../features/remoteExecution/RemoteExecutionTypes'; -import { Assessment, AssessmentOverview } from '../../assessment/AssessmentTypes'; +import { + Assessment, + AssessmentConfiguration, + AssessmentOverview +} from '../../assessment/AssessmentTypes'; import { Notification } from '../../notificationBadge/NotificationBadgeTypes'; import { HistoryHelper } from '../../utils/HistoryHelper'; import { GameState, Role, Story } from '../ApplicationTypes'; export const FETCH_AUTH = 'FETCH_AUTH'; +export const FETCH_USER_AND_COURSE = 'FETCH_USER_AND_COURSE'; +export const FETCH_COURSE_CONFIG = 'FETCH_COURSE_CONFIG'; export const FETCH_ASSESSMENT = 'FETCH_ASSESSMENT'; export const FETCH_ASSESSMENT_OVERVIEWS = 'FETCH_ASSESSMENT_OVERVIEWS'; export const FETCH_GRADING = 'FETCH_GRADING'; @@ -19,6 +26,10 @@ export const LOGIN_GITHUB = 'LOGIN_GITHUB'; export const LOGOUT_GITHUB = 'LOGOUT_GITHUB'; export const SET_TOKENS = 'SET_TOKENS'; export const SET_USER = 'SET_USER'; +export const SET_COURSE_CONFIGURATION = 'SET_COURSE_CONFIGURATION'; +export const SET_COURSE_REGISTRATION = 'SET_COURSE_REGISTRATION'; +export const SET_ASSESSMENT_CONFIGURATIONS = 'SET_ASSESSMENT_CONFIGURATIONS'; +export const SET_ADMIN_PANEL_COURSE_REGISTRATIONS = 'SET_ADMIN_PANEL_COURSE_REGISTRATIONS'; export const SET_GOOGLE_USER = 'SET_GOOGLE_USER'; export const SET_GITHUB_ASSESSMENT = 'SET_GITHUB_ASSESSMENT'; export const SET_GITHUB_OCTOKIT_OBJECT = 'SET_GITHUB_OCTOKIT_OBJECT'; @@ -40,30 +51,58 @@ export const UPDATE_GRADING = 'UPDATE_GRADING'; export const FETCH_NOTIFICATIONS = 'FETCH_NOTIFICATIONS'; export const ACKNOWLEDGE_NOTIFICATIONS = 'ACKNOWLEDGE_NOTIFICATIONS'; export const UPDATE_NOTIFICATIONS = 'UPDATE_NOTIFICATIONS'; +export const UPDATE_LATEST_VIEWED_COURSE = 'UPDATE_LATEST_VIEWED_COURSE'; +export const UPDATE_COURSE_CONFIG = 'UPDATE_COURSE_CONFIG'; +export const FETCH_ASSESSMENT_CONFIGS = 'FETCH_ASSESSMENT_CONFIGS'; +export const UPDATE_ASSESSMENT_CONFIGS = 'UPDATE_ASSESSMENT_CONFIGS'; +export const DELETE_ASSESSMENT_CONFIG = 'DELETE_ASSESSMENT_CONFIG'; +export const FETCH_ADMIN_PANEL_COURSE_REGISTRATIONS = 'FETCH_ADMIN_PANEL_COURSE_REGISTRATIONS'; +export const UPDATE_USER_ROLE = 'UPDATE_USER_ROLE'; +export const DELETE_USER_COURSE_REGISTRATION = 'DELETE_USER_COURSE_REGISTRATION'; export const UPLOAD_KEYSTROKE_LOGS = 'UPLOAD_KEYSTROKE_LOGS'; export const UPLOAD_UNSENT_LOGS = 'UPLOAD_UNSENT_LOGS'; export type SessionState = { + // Tokens readonly accessToken?: string; + readonly refreshToken?: string; + + // User + readonly userId?: number; + readonly name?: string; + readonly courses: UserCourse[]; + + // Course Registration + readonly courseRegId?: number; + readonly role?: Role; + readonly group: string | null; + readonly gameState: GameState; + readonly courseId?: number; + readonly xp: number; + readonly story: Story; + + // Course Configuration + readonly courseName?: string; + readonly courseShortName?: string; + readonly viewable?: boolean; + readonly enableGame?: boolean; + readonly enableAchievements?: boolean; + readonly enableSourcecast?: boolean; + readonly sourceChapter?: number; + readonly sourceVariant?: Variant; + readonly moduleHelpText?: string; + + readonly assessmentConfigurations?: AssessmentConfiguration[]; + readonly userCourseRegistrations?: AdminPanelCourseRegistration[]; + readonly assessmentOverviews?: AssessmentOverview[]; readonly assessments: Map; readonly experimentApproval: boolean; readonly experimentCoinflip: boolean; - readonly grade: number; readonly gradingOverviews?: GradingOverview[]; readonly gradings: Map; - readonly group: string | null; readonly historyHelper: HistoryHelper; - readonly maxGrade: number; - readonly maxXp: number; - readonly refreshToken?: string; - readonly role?: Role; - readonly story: Story; - readonly gameState: GameState; - readonly name?: string; - readonly userId?: number; - readonly xp: number; readonly notifications: Notification[]; readonly googleUser?: string; readonly githubAssessment?: MissionRepoData; @@ -78,12 +117,49 @@ export type Tokens = { refreshToken: string; }; +export type UserCourse = { + courseId: number; + courseName: string; + courseShortName: string; + role: Role; + viewable: boolean; +}; + export type User = { userId: number; name: string; + courses: UserCourse[]; +}; + +export type CourseRegistration = { + courseRegId: number; role: Role; group: string | null; - grade: number; - story?: Story; gameState?: GameState; + courseId: number; + xp: number; + story?: Story; +}; + +export type CourseConfiguration = { + courseName: string; + courseShortName: string; + viewable: boolean; + enableGame: boolean; + enableAchievements: boolean; + enableSourcecast: boolean; + sourceChapter: number; + sourceVariant: Variant; + moduleHelpText: string; }; + +export type AdminPanelCourseRegistration = { + courseRegId: number; + courseId: number; + name?: string; + username: string; + role: Role; + group?: string; +}; + +export type UpdateCourseConfiguration = Partial; diff --git a/src/commons/assessment/Assessment.tsx b/src/commons/assessment/Assessment.tsx index 4444c79c78..c72e4738b1 100644 --- a/src/commons/assessment/Assessment.tsx +++ b/src/commons/assessment/Assessment.tsx @@ -35,9 +35,10 @@ import { filterNotificationsByAssessment } from '../notificationBadge/Notificati import { NotificationFilterFunction } from '../notificationBadge/NotificationBadgeTypes'; import Constants from '../utils/Constants'; import { beforeNow, getPrettyDate } from '../utils/DateHelper'; -import { assessmentCategoryLink, stringParamToInt } from '../utils/ParamParseHelper'; +import { assessmentTypeLink, stringParamToInt } from '../utils/ParamParseHelper'; +import AssessmentNotFound from './AssessmentNotFound'; import { - AssessmentCategory, + AssessmentConfiguration, AssessmentOverview, AssessmentStatuses, AssessmentWorkspaceParams, @@ -56,7 +57,7 @@ export type DispatchProps = { }; export type OwnProps = { - assessmentCategory: AssessmentCategory; + assessmentConfiguration: AssessmentConfiguration; }; export type StateProps = { @@ -132,7 +133,7 @@ const Assessment: React.FC = props => { } return ( @@ -167,7 +168,8 @@ const Assessment: React.FC = props => { renderAttemptButton: boolean, renderGradingStatus: boolean ) => { - const showGrade = overview.gradingStatus === 'graded' || overview.category === 'Path'; + const showGrade = + overview.gradingStatus === 'graded' || !props.assessmentConfiguration.isManuallyGraded; const ratio = isMobileBreakpoint ? 5 : 3; return (
@@ -186,13 +188,6 @@ const Assessment: React.FC = props => {
{makeOverviewCardTitle(overview, index, renderGradingStatus)} -
-
- {showGrade - ? `Grade: ${overview.grade} / ${overview.maxGrade}` - : `Max Grade: ${overview.maxGrade}`} -
-
{showGrade ? `XP: ${overview.xp} / ${overview.maxXp}` : `Max XP: ${overview.maxXp}`} @@ -252,13 +247,17 @@ const Assessment: React.FC = props => { // overviews must still be loaded for this, to send the due date. if (assessmentId !== null && assessmentOverviews !== undefined) { const overview = assessmentOverviews.filter(a => a.id === assessmentId)[0]; + if (!overview) { + return ; + } const assessmentWorkspaceProps: AssessmentWorkspaceOwnProps = { assessmentId, questionId, notAttempted: overview.status === AssessmentStatuses.not_attempted, canSave: !props.isStudent || - (overview.status !== AssessmentStatuses.submitted && !beforeNow(overview.closeAt)) + (overview.status !== AssessmentStatuses.submitted && !beforeNow(overview.closeAt)), + assessmentConfiguration: props.assessmentConfiguration }; return ; } @@ -328,7 +327,7 @@ const Assessment: React.FC = props => { // Define the betcha dialog (in each card's menu) const submissionText = betchaAssessment ? (

- You are about to finalise your submission for the {betchaAssessment.category.toLowerCase()}{' '} + You are about to finalise your submission for the {betchaAssessment.type.toLowerCase()}{' '} "{betchaAssessment.title}".

) : ( diff --git a/src/commons/assessment/AssessmentContainer.ts b/src/commons/assessment/AssessmentContainer.ts index 7523e15687..5454bdae75 100644 --- a/src/commons/assessment/AssessmentContainer.ts +++ b/src/commons/assessment/AssessmentContainer.ts @@ -13,7 +13,7 @@ import { AssessmentOverview } from './AssessmentTypes'; const mapStateToProps: MapStateToProps = (state, props) => { const categoryFilter = (overview: AssessmentOverview) => - overview.category === props.assessmentCategory; + overview.type === props.assessmentConfiguration.type; const stateProps: StateProps = { assessmentOverviews: state.session.assessmentOverviews ? state.session.assessmentOverviews.filter(categoryFilter) diff --git a/src/commons/assessment/AssessmentNotFound.tsx b/src/commons/assessment/AssessmentNotFound.tsx new file mode 100644 index 0000000000..2e55499725 --- /dev/null +++ b/src/commons/assessment/AssessmentNotFound.tsx @@ -0,0 +1,15 @@ +import { Classes, NonIdealState } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import classNames from 'classnames'; + +const AssessmentNotFound: React.FC = () => ( +
+ +
+); + +export default AssessmentNotFound; diff --git a/src/commons/assessment/AssessmentTypes.ts b/src/commons/assessment/AssessmentTypes.ts index 3393f46ca5..334d2847de 100644 --- a/src/commons/assessment/AssessmentTypes.ts +++ b/src/commons/assessment/AssessmentTypes.ts @@ -26,22 +26,15 @@ export enum GradingStatuses { } export type GradingStatus = keyof typeof GradingStatuses; -export enum AssessmentCategories { - Contest = 'Contest', - Mission = 'Mission', - Path = 'Path', - Sidequest = 'Sidequest', - Practical = 'Practical' -} -export type AssessmentCategory = keyof typeof AssessmentCategories; +export type AssessmentType = string; export enum TestcaseTypes { // These are rendered in full by the Mission Autograder public = 'public', // These are rendered with a placeholder by the Autograder - hidden = 'hidden', + opaque = 'opaque', // These should only exist in the grading workspace for submissions - private = 'private' + secret = 'secret' } export type TestcaseType = keyof typeof TestcaseTypes; @@ -61,15 +54,13 @@ W* Used to display information regarding an assessment in the UI. * the assessment opens */ export type AssessmentOverview = { - category: AssessmentCategory; + type: AssessmentType; closeAt: string; coverImage: string; fileName?: string; // For mission control - grade: number; gradingStatus: GradingStatus; id: number; isPublished?: boolean; - maxGrade: number; maxXp: number; number?: string; // For mission control openAt: string; @@ -86,7 +77,7 @@ export type AssessmentOverview = { * Used when an assessment is being actively attempted/graded. */ export type Assessment = { - category: AssessmentCategory; + type: AssessmentType; globalDeployment?: Library; // For mission control graderDeployment?: Library; // For mission control id: number; @@ -96,6 +87,15 @@ export type Assessment = { questions: Question[]; }; +export type AssessmentConfiguration = { + assessmentConfigId: number; + type: AssessmentType; + isManuallyGraded: boolean; + displayInDashboard: boolean; + hoursBeforeEarlyXpDecay: number; + earlySubmissionXp: number; +}; + export interface IProgrammingQuestion extends BaseQuestion { answer: string | null; autogradingResults: AutogradingResult[]; @@ -127,7 +127,6 @@ export type BaseQuestion = { comments?: string; content: string; editorValue?: string | null; - grade: number; gradedAt?: string; grader?: { name: string; @@ -136,10 +135,12 @@ export type BaseQuestion = { graderLibrary?: Library; // For mission control id: number; library: Library; - maxGrade: number; maxXp: number; type: QuestionType; xp: number; + blocking?: boolean; // Determines whether the learner can progress to the next question without passing local testcases + // TODO: The blocking field is made optional now as the Question type is being shared with GitHub Assessments, which has not implemented + // the question-level blocking feature. Is to be made compulsory after this is implemented in GitHub Assessments }; export type Question = IProgrammingQuestion | IMCQQuestion | IContestVotingQuestion; @@ -222,12 +223,10 @@ export const normalLibrary = (): Library => { export const overviewTemplate = (): AssessmentOverview => { return { - category: AssessmentCategories.Mission, + type: 'Missions', closeAt: '2100-12-01T00:00+08', coverImage: 'https://fakeimg.pl/300/', - grade: 1, id: -1, - maxGrade: 0, maxXp: 0, openAt: '2000-01-01T00:00+08', title: 'Insert title here', @@ -255,9 +254,8 @@ export const programmingTemplate = (): IProgrammingQuestion => { testcasesPrivate: [], type: 'programming', xp: 0, - grade: 0, - maxGrade: 0, - maxXp: 0 + maxXp: 0, + blocking: false }; }; @@ -298,15 +296,14 @@ export const mcqTemplate = (): IMCQQuestion => { type: 'mcq', solution: 0, xp: 0, - grade: 0, - maxGrade: 0, - maxXp: 0 + maxXp: 0, + blocking: false }; }; export const assessmentTemplate = (): Assessment => { return { - category: 'Mission', + type: 'Missions', globalDeployment: normalLibrary(), graderDeployment: emptyLibrary(), id: -1, diff --git a/src/commons/assessment/__tests__/Assessment.tsx b/src/commons/assessment/__tests__/Assessment.tsx index 6f8600a0f2..6f45b63751 100644 --- a/src/commons/assessment/__tests__/Assessment.tsx +++ b/src/commons/assessment/__tests__/Assessment.tsx @@ -6,10 +6,16 @@ import { store } from '../../../pages/createStore'; import { mockAssessmentOverviews } from '../../mocks/AssessmentMocks'; import { mockRouterProps } from '../../mocks/ComponentMocks'; import Assessment, { AssessmentProps } from '../Assessment'; -import { AssessmentCategories } from '../AssessmentTypes'; const defaultProps: AssessmentProps = { - assessmentCategory: AssessmentCategories.Mission, + assessmentConfiguration: { + assessmentConfigId: 1, + type: 'Missions', + isManuallyGraded: true, + displayInDashboard: true, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + }, assessmentOverviews: undefined, handleAcknowledgeNotifications: () => {}, handleAssessmentOverviewFetch: () => {}, diff --git a/src/commons/assessment/__tests__/__snapshots__/Assessment.tsx.snap b/src/commons/assessment/__tests__/__snapshots__/Assessment.tsx.snap index cc32dfda96..37d058cc28 100644 --- a/src/commons/assessment/__tests__/__snapshots__/Assessment.tsx.snap +++ b/src/commons/assessment/__tests__/__snapshots__/Assessment.tsx.snap @@ -4,7 +4,7 @@ exports[`Assessment page "loading" content renders correctly 1`] = ` " - +
@@ -51,7 +51,7 @@ exports[`Assessment page does not show attempt Button for upcoming assessments f " - +
@@ -127,13 +127,6 @@ exports[`Assessment page does not show attempt Button for upcoming assessments f
-
- -
- Max Grade: 3000 -
-
-
@@ -240,17 +233,10 @@ exports[`Assessment page does not show attempt Button for upcoming assessments f
-
- -
- Grade: 0 / 0 -
-
-
- XP: 0 / 200 + Max XP: 200
@@ -384,13 +370,6 @@ exports[`Assessment page does not show attempt Button for upcoming assessments f
-
- -
- Max Grade: 3000 -
-
-
@@ -420,10 +399,10 @@ exports[`Assessment page does not show attempt Button for upcoming assessments f
-
- -
- Max Grade: 3000 -
-
-
@@ -618,13 +590,6 @@ exports[`Assessment page does not show attempt Button for upcoming assessments f
-
- -
- Max Grade: 3000 -
-
-
@@ -737,13 +702,6 @@ exports[`Assessment page does not show attempt Button for upcoming assessments f
-
- -
- Max Grade: 3000 -
-
-
@@ -857,7 +815,7 @@ exports[`Assessment page with 0 missions renders correctly 1`] = ` " - +
@@ -904,7 +862,7 @@ exports[`Assessment page with multiple loaded missions renders correctly 1`] = ` " - +
@@ -980,13 +938,6 @@ exports[`Assessment page with multiple loaded missions renders correctly 1`] = `
-
- -
- Max Grade: 3000 -
-
-
@@ -1123,17 +1074,10 @@ exports[`Assessment page with multiple loaded missions renders correctly 1`] = `
-
- -
- Grade: 0 / 0 -
-
-
- XP: 0 / 200 + Max XP: 200
@@ -1267,13 +1211,6 @@ exports[`Assessment page with multiple loaded missions renders correctly 1`] = `
-
- -
- Max Grade: 3000 -
-
-
@@ -1303,10 +1240,10 @@ exports[`Assessment page with multiple loaded missions renders correctly 1`] = `
-
- -
- Max Grade: 3000 -
-
-
@@ -1501,13 +1431,6 @@ exports[`Assessment page with multiple loaded missions renders correctly 1`] = `
-
- -
- Max Grade: 3000 -
-
-
@@ -1620,13 +1543,6 @@ exports[`Assessment page with multiple loaded missions renders correctly 1`] = `
-
- -
- Max Grade: 3000 -
-
-
diff --git a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx index b3b1677e4e..04e5d9fc3b 100644 --- a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx +++ b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx @@ -27,7 +27,7 @@ import { InterpreterOutput } from '../application/ApplicationTypes'; import { ExternalLibraryName } from '../application/types/ExternalTypes'; import { Assessment, - AssessmentCategories, + AssessmentConfiguration, AutogradingResult, ContestEntry, IContestVotingQuestion, @@ -63,7 +63,7 @@ import SideContentVideoDisplay from '../sideContent/SideContentVideoDisplay'; import Constants from '../utils/Constants'; import { history } from '../utils/HistoryHelper'; import { showWarningMessage } from '../utils/NotificationsHelper'; -import { assessmentCategoryLink } from '../utils/ParamParseHelper'; +import { assessmentTypeLink } from '../utils/ParamParseHelper'; import Workspace, { WorkspaceProps } from '../workspace/Workspace'; import { WorkspaceState } from '../workspace/WorkspaceTypes'; import AssessmentWorkspaceGradingResult from './AssessmentWorkspaceGradingResult'; @@ -103,6 +103,7 @@ export type OwnProps = { questionId: number; notAttempted: boolean; canSave: boolean; + assessmentConfiguration: AssessmentConfiguration; }; export type StateProps = { @@ -424,7 +425,7 @@ const AssessmentWorkspace: React.FC = props => { toSpawn: () => true }, { - label: `${props.assessment!.category} Briefing`, + label: `Briefing`, iconName: IconNames.BRIEFCASE, body: ( @@ -433,15 +434,19 @@ const AssessmentWorkspace: React.FC = props => { toSpawn: () => true }, { - label: `${props.assessment!.category} Autograder`, + label: `Autograder`, iconName: IconNames.AIRPLANE, body: ( ), id: SideContentType.autograder, @@ -458,8 +463,6 @@ const AssessmentWorkspace: React.FC = props => { graderName={props.assessment!.questions[questionId].grader!.name} gradedAt={props.assessment!.questions[questionId].gradedAt!} xp={props.assessment!.questions[questionId].xp} - grade={props.assessment!.questions[questionId].grade} - maxGrade={props.assessment!.questions[questionId].maxGrade} maxXp={props.assessment!.questions[questionId].maxXp} comments={props.assessment!.questions[questionId].comments} /> @@ -518,7 +521,7 @@ const AssessmentWorkspace: React.FC = props => { * (see 'Rendering Logic' below), thus it is okay to use props.assessment! */ const controlBarProps: (q: number) => ControlBarProps = (questionId: number) => { - const listingPath = `/academy/${assessmentCategoryLink(props.assessment!.category)}`; + const listingPath = `/academy/${assessmentTypeLink(props.assessment!.type)}`; const assessmentWorkspacePath = listingPath + `/${props.assessment!.id.toString()}`; const questionProgress: [number, number] = [questionId + 1, props.assessment!.questions.length]; @@ -536,8 +539,11 @@ const AssessmentWorkspace: React.FC = props => { }; const onClickReturn = () => history.push(listingPath); - // Returns a nullary function that defers the navigation of the browser window, until the - // student's answer passes some checks - presently only used for Paths + /** + * Returns a nullary function that defers the navigation of the browser window, until the + * student's answer passes some checks - presently only used for assessments types with blocking = true + * (previously used for the 'Path' assessment type in SA Knight) + */ const onClickProgress = (deferredNavigate: () => void) => { return () => { // Perform question blocking - determine the highest question number previously accessed @@ -552,6 +558,15 @@ const AssessmentWorkspace: React.FC = props => { // Else evaluate its correctness - proceed iff the answer to the current question is correct const question: Question = props.assessment!.questions[questionId]; if (question.type === QuestionTypes.mcq) { + // Note that 0 is a falsy value! + if (question.answer === null) { + return showWarningMessage('Please select an option!', 750); + } + // If the question is 'blocking', but there is no MCQ solution provided (i.e. assessment uploader's + // mistake), allow the student to proceed after selecting an option + if ((question as IMCQQuestion).solution === undefined) { + return deferredNavigate(); + } if (question.answer !== (question as IMCQQuestion).solution) { return showWarningMessage('Your MCQ solution is incorrect!', 750); } @@ -577,12 +592,12 @@ const AssessmentWorkspace: React.FC = props => { const nextButton = ( - - Grade: - - - {this.props.grade} / {this.props.maxGrade} - - - - XP: diff --git a/src/commons/assessmentWorkspace/__tests__/AssessmentWorkspace.tsx b/src/commons/assessmentWorkspace/__tests__/AssessmentWorkspace.tsx index 5772d22695..2c9a066dee 100644 --- a/src/commons/assessmentWorkspace/__tests__/AssessmentWorkspace.tsx +++ b/src/commons/assessmentWorkspace/__tests__/AssessmentWorkspace.tsx @@ -18,6 +18,14 @@ const defaultProps: AssessmentWorkspaceProps = { autogradingResults: [], notAttempted: true, canSave: true, + assessmentConfiguration: { + assessmentConfigId: 1, + type: 'Missions', + isManuallyGraded: true, + displayInDashboard: true, + hoursBeforeEarlyXpDecay: 48, + earlySubmissionXp: 200 + }, editorPrepend: '', editorValue: null, editorPostpend: '', diff --git a/src/commons/dropdown/Dropdown.tsx b/src/commons/dropdown/Dropdown.tsx index c247d174b9..2471900176 100644 --- a/src/commons/dropdown/Dropdown.tsx +++ b/src/commons/dropdown/Dropdown.tsx @@ -3,25 +3,34 @@ import { IconNames } from '@blueprintjs/icons'; import { Popover2 } from '@blueprintjs/popover2'; import * as React from 'react'; +import { UpdateCourseConfiguration, UserCourse } from '../application/types/SessionTypes'; import controlButton from '../ControlButton'; import Profile from '../profile/ProfileContainer'; import DropdownAbout from './DropdownAbout'; +import DropdownCourses from './DropdownCourses'; +import DropdownCreateCourse from './DropdownCreateCourse'; import DropdownHelp from './DropdownHelp'; type DropdownProps = DispatchProps & StateProps; type DispatchProps = { handleLogOut: () => void; + updateLatestViewedCourse: (courseId: number) => void; + handleCreateCourse: (courseConfig: UpdateCourseConfiguration) => void; }; type StateProps = { name?: string; + courses: UserCourse[]; + courseId?: number; }; type State = { isAboutOpen: boolean; isHelpOpen: boolean; isProfileOpen: boolean; + isMyCoursesOpen: boolean; + isCreateCourseOpen: boolean; }; class Dropdown extends React.Component { @@ -30,7 +39,9 @@ class Dropdown extends React.Component { this.state = { isAboutOpen: false, isHelpOpen: false, - isProfileOpen: false + isProfileOpen: false, + isMyCoursesOpen: false, + isCreateCourseOpen: false }; } @@ -46,6 +57,22 @@ class Dropdown extends React.Component { + {this.props.name ? ( + + ) : null} + {this.props.name ? ( + + ) : null} {this.props.name ? ( ) : null} @@ -62,6 +89,14 @@ class Dropdown extends React.Component { /> ) : null; + const myCourses = this.props.name ? ( + + ) : null; + + const createCourse = this.props.name ? ( + + ) : null; + const logout = this.props.name ? ( ) : null; @@ -69,6 +104,8 @@ class Dropdown extends React.Component { return ( {profile} + {myCourses} + {createCourse} {logout} @@ -85,6 +122,12 @@ class Dropdown extends React.Component { private toggleProfileOpen = () => this.setState({ ...this.state, isProfileOpen: !this.state.isProfileOpen }); + + private toggleMyCoursesOpen = () => + this.setState({ ...this.state, isMyCoursesOpen: !this.state.isMyCoursesOpen }); + + private toggleCreateCourseOpen = () => + this.setState({ ...this.state, isCreateCourseOpen: !this.state.isCreateCourseOpen }); } const titleCase = (str: string) => diff --git a/src/commons/dropdown/DropdownCourses.tsx b/src/commons/dropdown/DropdownCourses.tsx new file mode 100644 index 0000000000..34f05db9ed --- /dev/null +++ b/src/commons/dropdown/DropdownCourses.tsx @@ -0,0 +1,53 @@ +import { Classes, Dialog, HTMLSelect } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import * as React from 'react'; + +import { Role } from '../application/ApplicationTypes'; +import { UserCourse } from '../application/types/SessionTypes'; + +type DialogProps = { + isOpen: boolean; + onClose: () => void; + updateLatestViewedCourse: (courseId: number) => void; + courses: UserCourse[]; + courseId?: number; +}; + +const DropdownCourses: React.FC = props => { + const { courses, updateLatestViewedCourse } = props; + + const options = courses.map(course => ({ + value: course.courseId, + label: course.courseName.concat(!course.viewable ? ' - disabled' : ''), + disabled: !course.viewable && course.role !== Role.Admin + })); + + const onChangeHandler = (e: React.ChangeEvent) => { + updateLatestViewedCourse(parseInt(e.currentTarget.value, 10)); + props.onClose(); + }; + + return ( + +
+
Select a course to switch to:
+ +
+
+ ); +}; + +export default DropdownCourses; diff --git a/src/commons/dropdown/DropdownCreateCourse.tsx b/src/commons/dropdown/DropdownCreateCourse.tsx new file mode 100644 index 0000000000..646c159271 --- /dev/null +++ b/src/commons/dropdown/DropdownCreateCourse.tsx @@ -0,0 +1,265 @@ +import { + Button, + Classes, + Dialog, + FormGroup, + HTMLSelect, + InputGroup, + Switch, + Tab, + Tabs, + Text, + TextArea +} from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { Variant } from 'js-slang/dist/types'; +import * as React from 'react'; + +import { CourseHelpTextEditorTab } from '../../pages/academy/adminPanel/subcomponents/CourseConfigPanel'; +import { sourceLanguages } from '../application/ApplicationTypes'; +import { UpdateCourseConfiguration } from '../application/types/SessionTypes'; +import Markdown from '../Markdown'; +import { showWarningMessage } from '../utils/NotificationsHelper'; + +type DialogProps = { + isOpen: boolean; + onClose: () => void; + handleCreateCourse: (courseConfig: UpdateCourseConfiguration) => void; +}; + +const DropdownCreateCourse: React.FC = props => { + const [courseConfig, setCourseConfig] = React.useState({ + courseName: '', + courseShortName: '', + viewable: true, + enableGame: true, + enableAchievements: true, + enableSourcecast: true, + sourceChapter: 1, + sourceVariant: 'default', + moduleHelpText: '' + }); + + const [courseHelpTextSelectedTab, setCourseHelpTextSelectedTab] = + React.useState(CourseHelpTextEditorTab.WRITE); + + const sourceChapterOptions = [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }]; + + const sourceVariantOptions = (chapter: number) => + sourceLanguages + .filter(e => e.chapter === chapter) + .map(e => { + return { + label: e.variant.replace(/^\w/, c => c.toUpperCase()), + value: e.variant + }; + }); + + const submitHandler = () => { + // Validate that courseName is not an empty string + if (courseConfig.courseName === '') { + showWarningMessage('Course Name cannot be empty!'); + return; + } + props.handleCreateCourse(courseConfig); + props.onClose(); + }; + + const onChangeTabs = React.useCallback( + ( + newTabId: CourseHelpTextEditorTab, + prevTabId: CourseHelpTextEditorTab, + event: React.MouseEvent + ) => { + if (newTabId === prevTabId) { + return; + } + setCourseHelpTextSelectedTab(newTabId); + }, + [setCourseHelpTextSelectedTab] + ); + + return ( + +
+
+ Create your own Source Academy course and manage your own learners! +
+
+ + + setCourseConfig({ + ...courseConfig, + courseName: e.target.value + }) + } + /> + + + + setCourseConfig({ + ...courseConfig, + courseShortName: e.target.value + }) + } + /> + + + Module Help Text  + + (optional) + + + + + + {courseHelpTextSelectedTab === CourseHelpTextEditorTab.WRITE && ( +