diff --git a/src/actions/__tests__/dashboard.ts b/src/actions/__tests__/dashboard.ts new file mode 100644 index 0000000000..28142c5ac6 --- /dev/null +++ b/src/actions/__tests__/dashboard.ts @@ -0,0 +1,25 @@ +import { IGroupOverview } from '../../components/dashboard/groupShape'; +import * as actionTypes from '../actionTypes'; +import { fetchGroupOverviews, updateGroupOverviews } from '../dashboard'; + +test('fetchGroupOverviews generates correct action object', () => { + const action = fetchGroupOverviews(); + expect(action).toEqual({ + type: actionTypes.FETCH_GROUP_OVERVIEWS + }); +}); + +test('updateGroupOverviews generates correct action object', () => { + const overviews: IGroupOverview[] = [ + { + id: 1, + avengerName: 'Billy', + groupName: 'Test Group 1' + } + ]; + const action = updateGroupOverviews(overviews); + expect(action).toEqual({ + type: actionTypes.UPDATE_GROUP_OVERVIEWS, + payload: overviews + }); +}); diff --git a/src/actions/actionTypes.ts b/src/actions/actionTypes.ts index 98f087fd4c..d0dfb7a8b2 100755 --- a/src/actions/actionTypes.ts +++ b/src/actions/actionTypes.ts @@ -121,3 +121,8 @@ export const FETCH_NOTIFICATIONS = 'FETCH_NOTIFICATIONS'; export const ACKNOWLEDGE_NOTIFICATIONS = 'ACKNOWLEDGE_NOTIFICATIONS'; export const UPDATE_NOTIFICATIONS = 'UPDATE_NOTIFICATIONS'; export const NOTIFY_CHATKIT_USERS = 'NOTIFY_CHATKIT_USERS'; + +/** Dashboard */ + +export const FETCH_GROUP_OVERVIEWS = 'FETCH_GROUP_OVERVIEWS'; +export const UPDATE_GROUP_OVERVIEWS = 'UPDATE_GROUP_OVERVIEWS'; diff --git a/src/actions/dashboard.ts b/src/actions/dashboard.ts new file mode 100644 index 0000000000..ac2ac6fa25 --- /dev/null +++ b/src/actions/dashboard.ts @@ -0,0 +1,10 @@ +import { action } from 'typesafe-actions'; + +import * as actionTypes from './actionTypes'; + +import { IGroupOverview } from '../components/dashboard/groupShape'; + +export const fetchGroupOverviews = () => action(actionTypes.FETCH_GROUP_OVERVIEWS); + +export const updateGroupOverviews = (groupOverviews: IGroupOverview[]) => + action(actionTypes.UPDATE_GROUP_OVERVIEWS, groupOverviews); diff --git a/src/actions/index.ts b/src/actions/index.ts index 4f37fef163..2ca34edd99 100755 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,5 +1,6 @@ export * from './collabEditing'; export * from './commons'; +export * from './dashboard'; export * from './game'; export * from './interpreter'; export * from './material'; diff --git a/src/components/academy/NavigationBar.tsx b/src/components/academy/NavigationBar.tsx index cbd9b8b740..767c7a79d4 100644 --- a/src/components/academy/NavigationBar.tsx +++ b/src/components/academy/NavigationBar.tsx @@ -81,6 +81,15 @@ const NavigationBar: React.SFC = props => ( {props.role === Role.Admin || props.role === Role.Staff ? ( + + +
Dashboard
+
+ + + +
+ Dashboard +
+
@@ -142,6 +148,12 @@ exports[`Grading NavLink renders for Role.Staff 1`] = ` + + +
+ Dashboard +
+
diff --git a/src/components/academy/index.tsx b/src/components/academy/index.tsx index bad17fcd0b..cfe6d56724 100644 --- a/src/components/academy/index.tsx +++ b/src/components/academy/index.tsx @@ -3,6 +3,7 @@ import { Redirect, Route, RouteComponentProps, Switch } from 'react-router'; import Grading from '../../containers/academy/grading'; import AssessmentContainer from '../../containers/assessment'; +import Dashboard from '../../containers/dashboard/DashboardContainer'; import Game from '../../containers/GameContainer'; import MaterialUpload from '../../containers/material/MaterialUploadContainer'; import Sourcereel from '../../containers/sourcecast/SourcereelContainer'; @@ -77,6 +78,7 @@ class Academy extends React.Component { render={assessmentRenderFactory(AssessmentCategories.Practical)} /> + diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx new file mode 100644 index 0000000000..54993b5450 --- /dev/null +++ b/src/components/dashboard/Dashboard.tsx @@ -0,0 +1,182 @@ +import { ColDef, GridApi, GridReadyEvent } from 'ag-grid'; +import { AgGridReact } from 'ag-grid-react'; +import 'ag-grid/dist/styles/ag-grid.css'; +import 'ag-grid/dist/styles/ag-theme-balham.css'; +import * as React from 'react'; + +import { GradingOverview } from '../academy/grading/gradingShape'; +import ContentDisplay from '../commons/ContentDisplay'; +import { IGroupOverview } from './groupShape'; + +type State = { + filterValue: string; + groupFilterEnabled: boolean; + currPage: number; + maxPages: number; + rowCountString: string; + isBackDisabled: boolean; + isForwardDisabled: boolean; +}; + +interface IDashboardProps extends IDispatchProps, IStateProps {} + +export interface IDispatchProps { + handleFetchGradingOverviews: (filterToGroup?: boolean) => void; + handleFetchGroupOverviews: () => void; +} + +export interface IStateProps { + gradingOverviews: GradingOverview[]; + groupOverviews: IGroupOverview[]; +} + +export type LeaderBoardInfo = { + avengerName: string; + numOfUngradedMissions: number; + totalNumOfMissions: number; + numOfUngradedQuests: number; + totalNumOfQuests: number; +}; + +class Dashboard extends React.Component { + private columnDefs: ColDef[]; + private gridApi?: GridApi; + + public constructor(props: IDashboardProps) { + super(props); + this.columnDefs = [ + { + headerName: 'Avenger', + field: 'avengerName', + width: 100 + }, + { + headerName: 'Number of Ungraded Missions', + field: 'numOfUngradedMissions' + }, + { + headerName: 'Number of Submitted Missions', + field: 'totalNumOfMissions' + }, + { + headerName: 'Number of Ungraded Quests', + field: 'numOfUngradedQuests' + }, + { + headerName: 'Number of Submitted Quests', + field: 'totalNumOfQuests' + } + ]; + } + + public componentDidMount() { + this.props.handleFetchGroupOverviews(); + } + + public componentDidUpdate(prevProps: IDashboardProps, prevState: State) { + if (this.gridApi && this.props.gradingOverviews.length !== prevProps.gradingOverviews.length) { + this.gridApi.setRowData(this.updateLeaderBoard()); + } + } + + public handleFetchGradingOverviews = () => { + this.props.handleFetchGradingOverviews(false); + }; + + public render() { + const data = this.updateLeaderBoard(); + const grid = ( +
+
+ +
+
+ ); + + return ( +
+ +
+ ); + } + + private updateLeaderBoard = () => { + if (this.props.groupOverviews.length === 0) { + return []; + } + const gradingOverview: GradingOverview[] = this.filterSubmissionsByCategory(); + const filteredData: LeaderBoardInfo[] = []; + for (const current of gradingOverview) { + if (current.submissionStatus !== 'submitted') { + continue; + } + const groupName = current.groupName; + const groupOverviews = this.props.groupOverviews; + const index = groupOverviews.findIndex(x => x.groupName === groupName); + + if (index !== -1) { + if (filteredData[index] === undefined) { + filteredData[index] = { + avengerName: groupOverviews[index].avengerName, + numOfUngradedMissions: 0, + totalNumOfMissions: 0, + numOfUngradedQuests: 0, + totalNumOfQuests: 0 + }; + } + + const currentEntry = filteredData[index]; + const gradingStatus = current.gradingStatus; + + if (current.assessmentCategory === 'Mission') { + if (gradingStatus === 'none' || gradingStatus === 'grading') { + currentEntry.numOfUngradedMissions++; + } + currentEntry.totalNumOfMissions++; + } else { + if (gradingStatus === 'none' || gradingStatus === 'grading') { + currentEntry.numOfUngradedQuests++; + } + currentEntry.totalNumOfQuests++; + } + } + } + return filteredData; + }; + + private filterSubmissionsByCategory = () => { + if (!this.props.gradingOverviews) { + return []; + } + return (this.props.gradingOverviews as GradingOverview[]).filter( + sub => sub.assessmentCategory === 'Sidequest' || sub.assessmentCategory === 'Mission' + ); + }; + + private onGridReady = (params: GridReadyEvent) => { + this.gridApi = params.api; + this.gridApi.sizeColumnsToFit(); + }; + + private resizeGrid = () => { + if (this.gridApi) { + this.gridApi.sizeColumnsToFit(); + } + }; +} + +export default Dashboard; diff --git a/src/components/dashboard/groupShape.ts b/src/components/dashboard/groupShape.ts new file mode 100644 index 0000000000..7c8c336def --- /dev/null +++ b/src/components/dashboard/groupShape.ts @@ -0,0 +1,5 @@ +export interface IGroupOverview { + id: number; + groupName: string; + avengerName: string; +} diff --git a/src/containers/dashboard/DashboardContainer.ts b/src/containers/dashboard/DashboardContainer.ts new file mode 100644 index 0000000000..15d4237b89 --- /dev/null +++ b/src/containers/dashboard/DashboardContainer.ts @@ -0,0 +1,27 @@ +import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; +import { bindActionCreators, Dispatch } from 'redux'; + +import { fetchGroupOverviews } from '../../actions/dashboard'; +import { fetchGradingOverviews } from '../../actions/session'; +import { IDispatchProps, IStateProps } from '../../components/dashboard/Dashboard'; +import Dashboard from '../../components/dashboard/Dashboard'; +import { IState } from '../../reducers/states'; + +const mapStateToProps: MapStateToProps = state => ({ + gradingOverviews: state.session.gradingOverviews ? state.session.gradingOverviews : [], + groupOverviews: state.dashboard.groupOverviews +}); + +const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => + bindActionCreators( + { + handleFetchGradingOverviews: fetchGradingOverviews, + handleFetchGroupOverviews: fetchGroupOverviews + }, + dispatch + ); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Dashboard); diff --git a/src/mocks/backend.ts b/src/mocks/backend.ts index 8815b12a57..51a00b3136 100644 --- a/src/mocks/backend.ts +++ b/src/mocks/backend.ts @@ -20,6 +20,7 @@ import { history } from '../utils/history'; import { showSuccessMessage, showWarningMessage } from '../utils/notification'; import { mockAssessmentOverviews, mockAssessments } from './assessmentAPI'; import { mockFetchGrading, mockFetchGradingOverview } from './gradingAPI'; +import { mockGroupOverviews } from './groupAPI'; import { mockNotifications } from './userAPI'; export function* mockBackendSaga(): SagaIterator { @@ -209,4 +210,8 @@ export function* mockBackendSaga(): SagaIterator { ) { yield put(actions.updateNotifications(mockNotifications)); }); + + yield takeEvery(actionTypes.FETCH_GROUP_OVERVIEWS, function*() { + yield put(actions.updateGroupOverviews([...mockGroupOverviews])); + }); } diff --git a/src/mocks/groupAPI.ts b/src/mocks/groupAPI.ts new file mode 100644 index 0000000000..aeb7b9a4de --- /dev/null +++ b/src/mocks/groupAPI.ts @@ -0,0 +1,19 @@ +import { IGroupOverview } from '../components/dashboard/groupShape'; + +export const mockGroupOverviews: IGroupOverview[] = [ + { + id: 1, + avengerName: 'John', + groupName: 'Mock Group 1' + }, + { + id: 2, + avengerName: 'Billy', + groupName: 'Mock Group 2' + }, + { + id: 3, + avengerName: 'Harry', + groupName: 'Mock Group 3' + } +]; diff --git a/src/mocks/store.ts b/src/mocks/store.ts index 495a38bd65..befe431460 100644 --- a/src/mocks/store.ts +++ b/src/mocks/store.ts @@ -4,6 +4,7 @@ import mockStore from 'redux-mock-store'; import { defaultAcademy, defaultApplication, + defaultDashBoard, defaultPlayground, defaultSession, defaultWorkspaceManager, @@ -15,6 +16,7 @@ export function mockInitialStore

(): Store { const state: IState = { academy: defaultAcademy, application: defaultApplication, + dashboard: defaultDashBoard, playground: defaultPlayground, workspaces: defaultWorkspaceManager, session: defaultSession diff --git a/src/reducers/__tests__/dashboard.ts b/src/reducers/__tests__/dashboard.ts new file mode 100644 index 0000000000..04093819bc --- /dev/null +++ b/src/reducers/__tests__/dashboard.ts @@ -0,0 +1,54 @@ +import { UPDATE_GROUP_OVERVIEWS } from '../../actions/actionTypes'; +import { IGroupOverview } from '../../components/dashboard/groupShape'; +import { reducer } from '../dashboard'; +import { defaultDashBoard, IDashBoardState } from '../states'; + +const groupOverviewsTest1: IGroupOverview[] = [ + { + id: 1, + avengerName: 'Billy', + groupName: 'Test Group 1' + } +]; + +const groupOverviewsTest2: IGroupOverview[] = [ + { + id: 2, + avengerName: 'Justin', + groupName: 'Test Group 2' + } +]; + +test('UPDATE_GROUP_OVERVIEWS works correctly in inserting group overviews', () => { + const action = { + type: UPDATE_GROUP_OVERVIEWS, + payload: groupOverviewsTest1 + }; + + const result: IDashBoardState = reducer(defaultDashBoard, action); + + expect(result).toEqual({ + ...defaultDashBoard, + groupOverviews: groupOverviewsTest1 + }); +}); + +test('UPDATE_GROUP_OVERVIEWS works correctly in updating group overviews', () => { + const newDefaultDashBoard = { + ...defaultDashBoard, + groupOverviews: groupOverviewsTest1 + }; + + const groupOverviewsPayload = [...groupOverviewsTest2, ...groupOverviewsTest1]; + const action = { + type: UPDATE_GROUP_OVERVIEWS, + payload: groupOverviewsPayload + }; + + const result: IDashBoardState = reducer(newDefaultDashBoard, action); + + expect(result).toEqual({ + ...defaultDashBoard, + groupOverviews: groupOverviewsPayload + }); +}); diff --git a/src/reducers/dashboard.ts b/src/reducers/dashboard.ts new file mode 100644 index 0000000000..f8c14b1ef7 --- /dev/null +++ b/src/reducers/dashboard.ts @@ -0,0 +1,21 @@ +import { Reducer } from 'redux'; +import { ActionType } from 'typesafe-actions'; + +import * as actions from '../actions'; +import { UPDATE_GROUP_OVERVIEWS } from '../actions/actionTypes'; +import { defaultDashBoard, IDashBoardState } from './states'; + +export const reducer: Reducer = ( + state = defaultDashBoard, + action: ActionType +) => { + switch (action.type) { + case UPDATE_GROUP_OVERVIEWS: + return { + ...state, + groupOverviews: action.payload + }; + default: + return state; + } +}; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 7edf88ee33..9de89c5f55 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -1,5 +1,6 @@ import { reducer as academy } from './academy'; import { reducer as application } from './application'; +import { reducer as dashboard } from './dashboard'; import { reducer as playground } from './playground'; import { reducer as session } from './session'; import { reducer as workspaces } from './workspaces'; @@ -7,6 +8,7 @@ import { reducer as workspaces } from './workspaces'; export default { academy, application, + dashboard, playground, session, workspaces diff --git a/src/reducers/states.ts b/src/reducers/states.ts index ba8bf51544..5be8f2f1dd 100755 --- a/src/reducers/states.ts +++ b/src/reducers/states.ts @@ -12,6 +12,7 @@ import { IAssessmentOverview, ITestcase } from '../components/assessment/assessmentShape'; +import { IGroupOverview } from '../components/dashboard/groupShape'; import { DirectoryData, MaterialData } from '../components/material/materialShape'; import { Notification } from '../components/notification/notificationShape'; import { @@ -33,6 +34,7 @@ export interface IState { readonly playground: IPlaygroundState; readonly session: ISessionState; readonly workspaces: IWorkspaceManagerState; + readonly dashboard: IDashBoardState; } export interface IAcademyState { @@ -44,6 +46,10 @@ export interface IApplicationState { readonly environment: ApplicationEnvironment; } +export interface IDashBoardState { + readonly groupOverviews: IGroupOverview[]; +} + export interface IPlaygroundState { readonly queryString?: string; readonly usingSubst: boolean; @@ -274,6 +280,10 @@ export const defaultApplication: IApplicationState = { environment: currentEnvironment() }; +export const defaultDashBoard: IDashBoardState = { + groupOverviews: [] +}; + export const defaultPlayground: IPlaygroundState = { usingSubst: false }; @@ -432,6 +442,7 @@ export const defaultSession: ISessionState = { export const defaultState: IState = { academy: defaultAcademy, application: defaultApplication, + dashboard: defaultDashBoard, playground: defaultPlayground, session: defaultSession, workspaces: defaultWorkspaceManager diff --git a/src/sagas/__tests__/backend.ts b/src/sagas/__tests__/backend.ts index fec3c11eaa..3bc91d17dc 100644 --- a/src/sagas/__tests__/backend.ts +++ b/src/sagas/__tests__/backend.ts @@ -15,6 +15,7 @@ import { mockAssessmentQuestions, mockAssessments } from '../../mocks/assessmentAPI'; +import { mockGroupOverviews } from '../../mocks/groupAPI'; import { mockNotifications } from '../../mocks/userAPI'; import { Role, Story } from '../../reducers/states'; import { showSuccessMessage, showWarningMessage } from '../../utils/notification'; @@ -22,6 +23,7 @@ import backendSaga from '../backend'; import { getAssessment, getAssessmentOverviews, + getGroupOverviews, getNotifications, getUser, postAcknowledgeNotifications, @@ -383,3 +385,26 @@ describe('Test NOTIFY_CHATKIT_USERS Action', () => { .silentRun(); }); }); + +describe('Test FETCH_GROUP_OVERVIEWS Action', () => { + test('when group overviews are obtained', () => { + return expectSaga(backendSaga) + .withState({ session: { ...mockTokens, role: Role.Staff } }) + .provide([[call(getGroupOverviews, mockTokens), mockGroupOverviews]]) + .put(actions.updateGroupOverviews(mockGroupOverviews)) + .hasFinalState({ session: { ...mockTokens, role: Role.Staff } }) + .dispatch({ type: actionTypes.FETCH_GROUP_OVERVIEWS }) + .silentRun(); + }); + + test('when response is null', () => { + return expectSaga(backendSaga) + .withState({ session: { ...mockTokens, role: Role.Staff } }) + .provide([[call(getGroupOverviews, mockTokens), null]]) + .call(getGroupOverviews, mockTokens) + .not.put.actionType(actionTypes.UPDATE_GROUP_OVERVIEWS) + .hasFinalState({ session: { ...mockTokens, role: Role.Staff } }) + .dispatch({ type: actionTypes.FETCH_GROUP_OVERVIEWS }) + .silentRun(); + }); +}); diff --git a/src/sagas/backend.ts b/src/sagas/backend.ts index f7df616127..eefca7e60e 100644 --- a/src/sagas/backend.ts +++ b/src/sagas/backend.ts @@ -555,6 +555,19 @@ function* backendSaga(): SagaIterator { yield put(actions.fetchMaterialIndex(parentId)); yield call(showSuccessMessage, 'Deleted successfully!', 1000); }); + + yield takeEvery(actionTypes.FETCH_GROUP_OVERVIEWS, function*( + action: ReturnType + ) { + const tokens = yield select((state: IState) => ({ + accessToken: state.session.accessToken, + refreshToken: state.session.refreshToken + })); + const groupOverviews = yield call(request.getGroupOverviews, tokens); + if (groupOverviews) { + yield put(actions.updateGroupOverviews(groupOverviews)); + } + }); } export default backendSaga; diff --git a/src/sagas/requests.ts b/src/sagas/requests.ts index 9899982ab6..be5f0dc456 100644 --- a/src/sagas/requests.ts +++ b/src/sagas/requests.ts @@ -17,6 +17,7 @@ import { QuestionType, QuestionTypes } from '../components/assessment/assessmentShape'; +import { IGroupOverview } from '../components/dashboard/groupShape'; import { MaterialData } from '../components/material/materialShape'; import { Notification } from '../components/notification/notificationShape'; import { IPlaybackData, ISourcecastData } from '../components/sourcecast/sourcecastShape'; @@ -593,6 +594,23 @@ export const postMaterialFolder = async (title: string, parentId: number, tokens return resp; }; +export async function getGroupOverviews(tokens: Tokens): Promise { + const resp = await request('groups', 'GET', { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + shouldRefresh: true + }); + if (!resp || !resp.ok) { + return null; + } + + const groupOverviews = await resp.json(); + + return groupOverviews.map((overview: any) => { + return overview as IGroupOverview; + }); +} + /** * @returns {(Response|null)} Response if successful, otherwise null. *