diff --git a/package-lock.json b/package-lock.json index 646b42d..886c345 100755 --- a/package-lock.json +++ b/package-lock.json @@ -7254,8 +7254,7 @@ "decode-uri-component": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", - "dev": true + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" }, "decompress-response": { "version": "3.3.0", @@ -13485,6 +13484,16 @@ "cast-array": "~1.0.0", "object-filter": "~1.0.2", "query-string": "~2.4.1" + }, + "dependencies": { + "query-string": { + "version": "2.4.2", + "resolved": "http://registry.npmjs.org/query-string/-/query-string-2.4.2.tgz", + "integrity": "sha1-fbBmZCCAS6qSrp8miWKFWnYUPfs=", + "requires": { + "strict-uri-encode": "^1.0.0" + } + } } }, "make-dir": { @@ -19187,11 +19196,19 @@ "dev": true }, "query-string": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-2.4.2.tgz", - "integrity": "sha1-fbBmZCCAS6qSrp8miWKFWnYUPfs=", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.2.0.tgz", + "integrity": "sha512-5wupExkIt8RYL4h/FE+WTg3JHk62e6fFPWtAZA9J5IWK1PfTfKkMS93HBUHcFpeYi9KsY5pFbh+ldvEyaz5MyA==", "requires": { - "strict-uri-encode": "^1.0.0" + "decode-uri-component": "^0.2.0", + "strict-uri-encode": "^2.0.0" + }, + "dependencies": { + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" + } } }, "querystring": { diff --git a/package.json b/package.json index 76e2dcb..3fae152 100755 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "font-awesome": "^4.7.0", "history": "^4.7.2", "prop-types": "^15.5.10", + "query-string": "^6.2.0", "react": "^16.2.0", "react-dom": "^16.2.0", "react-redux": "^5.0.7", diff --git a/src/components/Gradebook/gradebook.scss b/src/components/Gradebook/gradebook.scss index 35f6189..d4c535e 100644 --- a/src/components/Gradebook/gradebook.scss +++ b/src/components/Gradebook/gradebook.scss @@ -1,3 +1,12 @@ +.student-filters{ + display: flex; + .label{ + padding-top: 30px; + } + .form-group{ + margin-left: 10px; + } +} .gbook { overflow-x: scroll; diff --git a/src/components/Gradebook/index.jsx b/src/components/Gradebook/index.jsx index 56d2dd6..bd72088 100644 --- a/src/components/Gradebook/index.jsx +++ b/src/components/Gradebook/index.jsx @@ -1,7 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import emailPropType from 'email-prop-type'; -import { Button, Modal, SearchField, Table } from '@edx/paragon'; +import { Button, Modal, SearchField, Table, InputSelect } from '@edx/paragon'; +import queryString from 'query-string'; export default class Gradebook extends React.Component { @@ -21,7 +22,14 @@ export default class Gradebook extends React.Component { } componentDidMount() { - this.props.getUserGrades(this.props.match.params.courseId); + const urlQuery = queryString.parse(this.props.location.search); + this.props.getUserGrades( + this.props.match.params.courseId, + urlQuery.cohort, + urlQuery.track, + ); + this.props.getTracks(this.props.match.params.courseId); + this.props.getCohorts(this.props.match.params.courseId); } sortAlphaDesc = (gradeRowA, gradeRowB) => { @@ -223,6 +231,75 @@ export default class Gradebook extends React.Component { }, ]); } + updateQueryParams = (queryKey, queryValue) => { + const parsed = queryString.parse(this.props.location.search); + parsed[queryKey] = queryValue; + return `?${queryString.stringify(parsed)}`; + }; + + mapCohortsEntries = (entries) => { + let mapped = entries.map(entry => ({ + id: entry.id, + label: entry.name, + })); + mapped.unshift({id:0, label:'Cohorts'}); + return mapped; + }; + + mapTracksEntries = (entries) => { + let mapped = entries.map(entry => ({ + id: entry.slug, + label: entry.name, + })); + mapped.unshift({ label:'Tracks' }); + return mapped; + }; + + updateTracks = (event) => { + const selectedTrackItem = this.props.tracks.find(x=>x.name===event); + let selectedTrackSlug = null; + if(selectedTrackItem) { + selectedTrackSlug = selectedTrackItem.slug; + } + this.props.getUserGrades( + this.props.match.params.courseId, + this.props.selectedCohort, + selectedTrackSlug, + ); + const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug) + this.props.history.push(updatedQueryStrings); + }; + + updateCohorts = (event) => { + const selectedCohortItem = this.props.cohorts.find(x=>x.name===event); + let selectedCohortId = null; + if(selectedCohortItem) { + selectedCohortId = selectedCohortItem.id; + } + this.props.getUserGrades( + this.props.match.params.courseId, + selectedCohortId, + this.props.selectedTrack, + ); + const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId) + this.props.history.push(updatedQueryStrings); + }; + + mapSelectedCohortEntry = (entry) => { + const selectedCohortEntry = this.props.cohorts.find(x => x.id === parseInt(entry, 10)); + if (selectedCohortEntry) { + return selectedCohortEntry.name; + } + return 'Cohorts'; + }; + + mapSelectedTrackEntry = (entry) => { + const selectedTrackEntry = this.props.tracks.find(x => x.slug === entry); + if (selectedTrackEntry) { + return selectedTrackEntry.name; + } + return 'Tracks'; + }; render() { return ( @@ -294,21 +371,44 @@ export default class Gradebook extends React.Component { type="radio" name="category" value="exam" - onClick={() => this.setState({ headings: this.mapHeadingsExam(this.props.results[0]) })} + onClick={() => this.setState({ headings: this.mapHeadingsExam(this.props.results[0]) })} /> Exam + {(this.props.tracks.length > 0 || this.props.cohorts.length > 0) && +
+ + Student Groups: + + {this.props.tracks.length > 0 && + + } + {this.props.cohorts.length > 0 && + + } +
+ }
Download Grade Report
this.props.searchForUser(this.props.match.params.courseId, value)} + onSubmit={value => this.props.searchForUser(this.props.match.params.courseId, value, this.props.selectedCohort, this.props.selectedTrack)} onChange={filterValue => this.setState({ filterValue })} - onClear={() => this.props.getUserGrades(this.props.match.params.courseId)} + onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)} value={this.state.filterValue} />
@@ -360,19 +460,3 @@ export default class Gradebook extends React.Component { } } -// CommentDetails.defaultProps = { -// id: null, -// postId: null, -// name: '', -// email: 'example@example.com', -// body: '', -// }; - -// CommentDetails.propTypes = { -// id: PropTypes.number, -// postId: PropTypes.number, -// name: PropTypes.string, -// email: emailPropType, -// body: PropTypes.string, -// }; - diff --git a/src/containers/GradebookPage/index.jsx b/src/containers/GradebookPage/index.jsx index cbc052f..7c34b10 100644 --- a/src/containers/GradebookPage/index.jsx +++ b/src/containers/GradebookPage/index.jsx @@ -2,20 +2,32 @@ import { connect } from 'react-redux'; import Gradebook from '../../components/Gradebook'; import { fetchGrades, fetchMatchingUserGrades, updateGrades } from '../../data/actions/grades'; +import { fetchCohorts } from '../../data/actions/cohorts'; +import { fetchTracks } from '../../data/actions/tracks'; const mapStateToProps = state => ( { grades: state.grades.results, + tracks: state.tracks.results, + cohorts: state.cohorts.results, + selectedTrack: state.grades.selectedTrack, + selectedCohort: state.grades.selectedCohort, } ); const mapDispatchToProps = dispatch => ( { - getUserGrades: (courseId) => { - dispatch(fetchGrades(courseId)); + getUserGrades: (courseId, cohort, track) => { + dispatch(fetchGrades(courseId, cohort, track)); }, - searchForUser: (courseId, searchText) => { - dispatch(fetchMatchingUserGrades(courseId, searchText)); + searchForUser: (courseId, searchText, cohort, track) => { + dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track)); + }, + getCohorts: (courseId) => { + dispatch(fetchCohorts(courseId)); + }, + getTracks: (courseId) => { + dispatch(fetchTracks(courseId)); }, updateGrades: (courseId, updateData) => { dispatch(updateGrades(courseId, updateData)); diff --git a/src/data/actions/cohorts.js b/src/data/actions/cohorts.js new file mode 100644 index 0000000..915c4f6 --- /dev/null +++ b/src/data/actions/cohorts.js @@ -0,0 +1,31 @@ +import { + STARTED_FETCHING_COHORTS, + GOT_COHORTS, + ERROR_FETCHING_COHORTS, +} from '../constants/actionTypes/cohorts'; +import LmsApiService from '../services/LmsApiService'; + +const startedFetchingCohorts = () => ({ type: STARTED_FETCHING_COHORTS }); +const errorFetchingCohorts = () => ({ type: ERROR_FETCHING_COHORTS }); +const gotCohorts = cohorts => ({ type: GOT_COHORTS, cohorts }); + +const fetchCohorts = courseId => ( + (dispatch) => { + dispatch(startedFetchingCohorts()); + return LmsApiService.fetchCohorts(courseId) + .then(response => response.data) + .then((data) => { + dispatch(gotCohorts(data.cohorts)); + }) + .catch((error) => { + dispatch(errorFetchingCohorts()); + }); + } +); + +export { + fetchCohorts, + startedFetchingCohorts, + gotCohorts, + errorFetchingCohorts, +}; diff --git a/src/data/actions/grades.js b/src/data/actions/grades.js index 03caa63..ea1bdca 100644 --- a/src/data/actions/grades.js +++ b/src/data/actions/grades.js @@ -12,7 +12,12 @@ import LmsApiService from '../services/LmsApiService'; const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES }); const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES }); const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES }); -const gotGrades = grades => ({ type: GOT_GRADES, grades }); +const gotGrades = (grades, cohort, track) => ({ + type: GOT_GRADES, + grades, + cohort, + track, +}); const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST }); const gradeUpdateSuccess = responseData => ({ @@ -24,13 +29,13 @@ const gradeUpdateFailure = error => ({ payload: { error }, }); -const fetchGrades = courseId => ( +const fetchGrades = (courseId, cohort, track) => ( (dispatch) => { dispatch(startedFetchingGrades()); - return LmsApiService.fetchGradebookData(courseId) + return LmsApiService.fetchGradebookData(courseId, null, cohort, track) .then(response => response.data) .then((data) => { - dispatch(gotGrades(data.results)); + dispatch(gotGrades(data.results, cohort, track)); dispatch(finishedFetchingGrades()); }) .catch((error) => { @@ -39,13 +44,13 @@ const fetchGrades = courseId => ( } ); -const fetchMatchingUserGrades = (courseId, searchText) => ( +const fetchMatchingUserGrades = (courseId, searchText, cohort, track) => ( (dispatch) => { dispatch(startedFetchingGrades()); - return LmsApiService.fetchGradebookData(courseId, searchText) + return LmsApiService.fetchGradebookData(courseId, searchText, cohort, track) .then(response => response.data) .then((data) => { - dispatch(gotGrades(data.results)); + dispatch(gotGrades(data.results, cohort, track)); dispatch(finishedFetchingGrades()); }) .catch((error) => { diff --git a/src/data/actions/tracks.js b/src/data/actions/tracks.js new file mode 100644 index 0000000..9b3bc28 --- /dev/null +++ b/src/data/actions/tracks.js @@ -0,0 +1,31 @@ +import { + STARTED_FETCHING_TRACKS, + GOT_TRACKS, + ERROR_FETCHING_TRACKS, +} from '../constants/actionTypes/tracks'; +import LmsApiService from '../services/LmsApiService'; + +const startedFetchingTracks = () => ({ type: STARTED_FETCHING_TRACKS }); +const errorFetchingTracks = () => ({ type: ERROR_FETCHING_TRACKS }); +const gotTracks = tracks => ({ type: GOT_TRACKS, tracks }); + +const fetchTracks = courseId => ( + (dispatch) => { + dispatch(startedFetchingTracks()); + return LmsApiService.fetchTracks(courseId) + .then(response => response.data) + .then((data) => { + dispatch(gotTracks(data.course_modes)); + }) + .catch((error) => { + dispatch(errorFetchingTracks()); + }); + } +); + +export { + fetchTracks, + startedFetchingTracks, + gotTracks, + errorFetchingTracks, +}; diff --git a/src/data/constants/actionTypes/cohorts.js b/src/data/constants/actionTypes/cohorts.js new file mode 100644 index 0000000..2027c26 --- /dev/null +++ b/src/data/constants/actionTypes/cohorts.js @@ -0,0 +1,9 @@ +const STARTED_FETCHING_COHORTS = 'STARTED_FETCHING_COHORTS'; +const GOT_COHORTS = 'GOT_COHORTS'; +const ERROR_FETCHING_COHORTS = 'ERROR_FETCHING_COHORTS'; + +export { + STARTED_FETCHING_COHORTS, + GOT_COHORTS, + ERROR_FETCHING_COHORTS, +}; diff --git a/src/data/constants/actionTypes/tracks.js b/src/data/constants/actionTypes/tracks.js new file mode 100644 index 0000000..aa89e98 --- /dev/null +++ b/src/data/constants/actionTypes/tracks.js @@ -0,0 +1,9 @@ +const STARTED_FETCHING_TRACKS = 'STARTED_FETCHING_TRACKS'; +const GOT_TRACKS = 'GOT_TRACKS'; +const ERROR_FETCHING_TRACKS = 'ERROR_FETCHING_TRACKS'; + +export { + STARTED_FETCHING_TRACKS, + GOT_TRACKS, + ERROR_FETCHING_TRACKS, +}; diff --git a/src/data/reducers/cohorts.js b/src/data/reducers/cohorts.js new file mode 100644 index 0000000..f53e8cd --- /dev/null +++ b/src/data/reducers/cohorts.js @@ -0,0 +1,39 @@ +import { + STARTED_FETCHING_COHORTS, + ERROR_FETCHING_COHORTS, + GOT_COHORTS, +} from '../constants/actionTypes/cohorts'; + +const initialState = { + results: [], + startedFetching: false, + errorFetching: false, +}; + + +const cohorts = (state = initialState, action) => { + switch (action.type) { + case GOT_COHORTS: + return { + ...state, + results: action.cohorts, + errorFetching: false, + }; + case STARTED_FETCHING_COHORTS: + return { + ...state, + startedFetching: true, + }; + case ERROR_FETCHING_COHORTS: + return { + ...state, + finishedFetching: true, + errorFetching: true, + }; + default: + return state; + } +}; + +export default cohorts; + diff --git a/src/data/reducers/grades.js b/src/data/reducers/grades.js index 45bec53..056c214 100644 --- a/src/data/reducers/grades.js +++ b/src/data/reducers/grades.js @@ -19,6 +19,8 @@ const grades = (state = initialState, action) => { results: action.grades, finishedFetching: true, errorFetching: false, + selectedTrack: action.track, + selectedCohort: action.cohort, }; case STARTED_FETCHING_GRADES: return { diff --git a/src/data/reducers/index.js b/src/data/reducers/index.js index 041f3e6..f4b0ef4 100755 --- a/src/data/reducers/index.js +++ b/src/data/reducers/index.js @@ -1,9 +1,13 @@ import { combineReducers } from 'redux'; +import cohorts from './cohorts'; import grades from './grades'; +import tracks from './tracks'; const rootReducer = combineReducers({ grades, + cohorts, + tracks, }); export default rootReducer; diff --git a/src/data/reducers/tracks.js b/src/data/reducers/tracks.js new file mode 100644 index 0000000..1c1836e --- /dev/null +++ b/src/data/reducers/tracks.js @@ -0,0 +1,39 @@ +import { + STARTED_FETCHING_TRACKS, + ERROR_FETCHING_TRACKS, + GOT_TRACKS, +} from '../constants/actionTypes/tracks'; + +const initialState = { + results: [], + startedFetching: false, + errorFetching: false, +}; + + +const tracks = (state = initialState, action) => { + switch (action.type) { + case GOT_TRACKS: + return { + ...state, + results: action.tracks, + errorFetching: false, + }; + case STARTED_FETCHING_TRACKS: + return { + ...state, + startedFetching: true, + }; + case ERROR_FETCHING_TRACKS: + return { + ...state, + finishedFetching: true, + errorFetching: true, + }; + default: + return state; + } +}; + +export default tracks; + diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index ce41870..fef622b 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -4,10 +4,19 @@ import { configuration } from '../../config'; class LmsApiService { static baseUrl = configuration.LMS_BASE_URL; - static fetchGradebookData(courseId, searchText) { + static fetchGradebookData(courseId, searchText, cohort, track) { let gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/`; + if (searchText || track || cohort) { + gradebookUrl += '?'; + } if (searchText) { - gradebookUrl += `?username_contains=${searchText}`; + gradebookUrl += `username_contains=${searchText}&`; + } + if (cohort) { + gradebookUrl += `cohort_id=${cohort}&`; + } + if (track) { + gradebookUrl += `enrollment_mode=${track}`; } return apiClient.get(gradebookUrl); } @@ -35,6 +44,16 @@ class LmsApiService { const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/bulk-update`; return apiClient.post(gradebookUrl, updateData); } + + static fetchTracks(courseId) { + const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}`; + return apiClient.get(trackUrl); + } + + static fetchCohorts(courseId) { + const cohortsUrl = `${LmsApiService.baseUrl}/courses/${courseId}/cohorts/`; + return apiClient.get(cohortsUrl); + } } export default LmsApiService;