Clean up MFE/Redux usage (#179)

* refactor: clean up/standardize selector usage

* fix: fix eslint errors

* chore: bump version to 1.4.25

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
This commit is contained in:
Nathan Sprenkle
2021-05-07 12:44:00 -04:00
committed by GitHub
parent 4fdc541992
commit ee2c573017
32 changed files with 489 additions and 277 deletions

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.4.24",
"version": "1.4.25",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.4.24",
"version": "1.4.25",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",

View File

@@ -10,10 +10,10 @@ import {
} from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDownload } from '@fortawesome/free-solid-svg-icons';
import { configuration } from '../../config';
import selectors from 'data/selectors';
import { configuration } from '../../config';
import { submitFileUploadFormData } from '../../data/actions/grades';
import { getBulkManagementHistory } from '../../data/selectors/grades';
export class BulkManagement extends React.Component {
constructor(props) {
@@ -183,14 +183,14 @@ BulkManagement.propTypes = {
uploadSuccess: PropTypes.bool,
};
export const mapStateToProps = (state) => ({
bulkImportError: state.grades.bulkManagement
&& state.grades.bulkManagement.errorMessages
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
: '',
bulkManagementHistory: getBulkManagementHistory(state),
uploadSuccess: !!(state.grades.bulkManagement && state.grades.bulkManagement.uploadSuccess),
});
export const mapStateToProps = (state) => {
const { grades } = selectors;
return {
bulkImportError: grades.bulkImportError(state),
bulkManagementHistory: grades.bulkManagementHistoryEntries(state),
uploadSuccess: grades.uploadSuccess(state),
};
};
export const mapDispatchToProps = {
submitFileUploadFormData,

View File

@@ -10,14 +10,18 @@ import {
Table,
} from '@edx/paragon';
import selectors from 'data/selectors';
import {
doneViewingAssignment,
updateGrades,
} from '../../data/actions/grades';
const GRADE_OVERRIDE_HISTORY_COLUMNS = [{ label: 'Date', key: 'date' }, { label: 'Grader', key: 'grader' },
const GRADE_OVERRIDE_HISTORY_COLUMNS = [
{ label: 'Date', key: 'date' },
{ label: 'Grader', key: 'grader' },
{ label: 'Reason', key: 'reason' },
{ label: 'Adjusted grade', key: 'adjustedGrade' }];
{ label: 'Adjusted grade', key: 'adjustedGrade' },
];
export class EditModal extends React.Component {
constructor(props) {
@@ -185,15 +189,18 @@ EditModal.propTypes = {
updateGrades: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
gradeOverrides: state.grades.gradeOverrideHistoryResults,
gradeOverrideCurrentEarnedGradedOverride: state.grades.gradeOverrideCurrentEarnedGradedOverride,
gradeOverrideHistoryError: state.grades.gradeOverrideHistoryError,
gradeOriginalEarnedGraded: state.grades.gradeOriginalEarnedGraded,
grdaeOriginalPossibleGraded: state.grades.grdaeOriginalPossibleGraded,
selectedCohort: state.filters.cohort,
selectedTrack: state.filters.track,
});
export const mapStateToProps = (state) => {
const { filters, grades } = selectors;
return {
gradeOverrides: grades.gradeOverrides(state),
gradeOverrideCurrentEarnedGradedOverride: grades.gradeOverrideCurrentEarnedGradedOverride(state),
gradeOverrideHistoryError: grades.gradeOverrideHistoryError(state),
gradeOriginalEarnedGraded: grades.gradeOriginalEarnedGraded(state),
gradeOriginalPossibleGraded: grades.gradeOriginalPossibleGraded(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
};
};
export const mapDispatchToProps = {
doneViewingAssignment,

View File

@@ -3,9 +3,9 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { selectableAssignmentLabels } from 'data/selectors/filters';
import * as gradesActions from 'data/actions/grades';
import * as filterActions from 'data/actions/filters';
import selectors from 'data/selectors';
import SelectGroup from '../SelectGroup';
@@ -85,13 +85,16 @@ AssignmentFilter.propTypes = {
updateAssignmentFilter: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
assignmentFilterOptions: selectableAssignmentLabels(state),
selectedAssignment: (state.filters.assignment || {}).label,
selectedAssignmentType: state.filters.assignmentType,
selectedCohort: state.filters.cohort,
selectedTrack: state.filters.track,
});
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
assignmentFilterOptions: filters.selectableAssignmentLabels(state),
selectedAssignment: filters.selectedAssignmentLabel(state),
selectedAssignmentType: filters.assignmentType(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
};
};
export const mapDispatchToProps = {
updateAssignmentFilter: filterActions.updateAssignmentFilter,

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import selectors from 'data/selectors';
import { updateAssignmentFilter } from 'data/actions/filters';
import { updateGradesIfAssignmentGradeFiltersSet } from 'data/actions/grades';
import {
@@ -9,12 +10,20 @@ import {
mapDispatchToProps,
} from '.';
jest.mock('data/selectors/filters', () => ({
jest.mock('data/selectors', () => ({
/** Mocking to use passed state for validation purposes */
selectableAssignmentLabels: jest.fn().mockImplementation((state) => ({
state,
selectableLabels: 'selectableLabels',
})),
filters: {
selectableAssignmentLabels: jest.fn(() => ([{
label: 'assigNment',
subsectionLabel: 'subsection',
type: 'assignMentType',
id: 'subsectionId',
}])),
selectedAssignmentLabel: jest.fn(() => 'assigNment'),
assignmentType: jest.fn(() => 'assignMentType'),
cohort: jest.fn(() => 'COhort'),
track: jest.fn(() => 'traCK'),
},
}));
describe('AssignmentFilter', () => {
@@ -103,51 +112,47 @@ describe('AssignmentFilter', () => {
},
};
describe('assignmentFilterOptions', () => {
it('is drawn from selectableAssignmentLabels', () => {
expect(mapStateToProps(state).assignmentFilterOptions).toEqual({
state,
selectableLabels: 'selectableLabels',
});
it('is selected from filters.selectableAssignmentLabels', () => {
expect(
mapStateToProps(state).assignmentFilterOptions,
).toEqual(
selectors.filters.selectableAssignmentLabels(state),
);
});
});
describe('selectedAsssignment', () => {
it('is undefined if no assignment is passed', () => {
expect(
mapStateToProps({ filters: {} }).selectedAssignment,
).toEqual(undefined);
});
it('returns the label of selected assignment if there is one', () => {
describe('selectedAssignment', () => {
it('is selected from filters.selectedAssignmentLabel', () => {
expect(
mapStateToProps(state).selectedAssignment,
).toEqual(
state.filters.assignment.label,
selectors.filters.selectedAssignmentLabel(state),
);
});
});
describe('selectedAssignmentType', () => {
it('is drawn from state.filters.assignmentType', () => {
it('is selected from filters.assignmentType', () => {
expect(
mapStateToProps(state).selectedAssignmentType,
).toEqual(
state.filters.assignmentType,
selectors.filters.assignmentType(state),
);
});
});
describe('selectedCohort', () => {
it('is drawn from state.filters.cohort', () => {
it('is selected from filters.cohort', () => {
expect(
mapStateToProps(state).selectedCohort,
).toEqual(
state.filters.cohort,
selectors.filters.cohort(state),
);
});
});
describe('selectedTrack', () => {
it('is drawn from state.filters.track', () => {
it('is selected from filters.track', () => {
expect(
mapStateToProps(state).selectedTrack,
).toEqual(
state.filters.track,
selectors.filters.track(state),
);
});
});

View File

@@ -7,6 +7,7 @@ import { Button } from '@edx/paragon';
import * as gradesActions from 'data/actions/grades';
import * as filterActions from 'data/actions/filters';
import selectors from 'data/selectors';
import PercentGroup from '../PercentGroup';
@@ -106,12 +107,15 @@ AssignmentGradeFilter.propTypes = {
updateAssignmentLimits: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
selectedAssignment: (state.filters.assignment || {}).label,
selectedAssignmentType: state.filters.assignmentType,
selectedCohort: state.filters.cohort,
selectedTrack: state.filters.track,
});
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
selectedAssignment: filters.selectedAssignmentLabel(state),
selectedAssignmentType: filters.assignmentType(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
};
};
export const mapDispatchToProps = {
getUserGrades: gradesActions.fetchGrades,

View File

@@ -3,8 +3,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { selectableAssignmentLabels } from 'data/selectors/filters';
import * as gradesActions from 'data/actions/grades';
import selectors from 'data/selectors';
import SelectGroup from '../SelectGroup';
@@ -66,9 +66,9 @@ AssignmentTypeFilter.propTypes = {
};
export const mapStateToProps = (state) => ({
assignmentTypes: state.assignmentTypes.results,
assignmentFilterOptions: selectableAssignmentLabels(state),
selectedAssignmentType: state.filters.assignmentType,
assignmentTypes: selectors.assignmentTypes.allAssignmentTypes(state),
assignmentFilterOptions: selectors.filters.selectableAssignmentLabels(state),
selectedAssignmentType: selectors.filters.assignmentType(state),
});
export const mapDispatchToProps = {

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import { filterAssignmentType } from 'data/actions/grades';
import {
@@ -9,12 +10,20 @@ import {
mapDispatchToProps,
} from '.';
jest.mock('data/selectors/filters', () => ({
jest.mock('data/selectors', () => ({
/** Mocking to use passed state for validation purposes */
selectableAssignmentLabels: jest.fn().mockImplementation((state) => ({
state,
selectableLabels: 'selectableLabels',
})),
assignmentTypes: {
allAssignmentTypes: jest.fn(() => (['assignment', 'labs'])),
},
filters: {
selectableAssignmentLabels: jest.fn(() => ([{
label: 'assigNment',
subsectionLabel: 'subsection',
type: 'assignMentType',
id: 'subsectionId',
}])),
assignmentType: jest.fn(() => 'assignMentType'),
},
}));
describe('AssignmentTypeFilter', () => {
@@ -89,26 +98,29 @@ describe('AssignmentTypeFilter', () => {
},
};
describe('assignmentTypes', () => {
it('is drawn from assignmentTypes.results', () => {
expect(mapStateToProps(state).assignmentTypes).toEqual(
state.assignmentTypes.results,
it('is selected from assignmentTypes.allAssignmentTypes', () => {
expect(
mapStateToProps(state).assignmentTypes,
).toEqual(
selectors.assignmentTypes.allAssignmentTypes(state),
);
});
});
describe('assignmentFilterOptions', () => {
it('is drawn from selectableAssignmentLabels', () => {
expect(mapStateToProps(state).assignmentFilterOptions).toEqual({
state,
selectableLabels: 'selectableLabels',
});
it('is selected from filters.selectableAssignmentLabels', () => {
expect(
mapStateToProps(state).assignmentFilterOptions,
).toEqual(
selectors.filters.selectableAssignmentLabels(state),
);
});
});
describe('selectedAssignmentType', () => {
it('is drawn from state.filters.assignmentType', () => {
it('is selected from filters.assignmentType', () => {
expect(
mapStateToProps(state).selectedAssignmentType,
).toEqual(
state.filters.assignmentType,
selectors.filters.assignmentType(state),
);
});
});

View File

@@ -8,6 +8,7 @@ import {
import { updateCourseGradeFilter } from 'data/actions/filters';
import { fetchGrades } from 'data/actions/grades';
import selectors from 'data/selectors';
import PercentGroup from '../PercentGroup';
export class CourseGradeFilter extends React.Component {
@@ -118,11 +119,14 @@ CourseGradeFilter.propTypes = {
updateFilter: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
selectedCohort: state.filters.cohort,
selectedTrack: state.filters.track,
selectedAssignmentType: state.filters.assignmentType,
});
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
selectedAssignmentType: filters.assignmentType(state),
};
};
export const mapDispatchToProps = {
updateFilter: updateCourseGradeFilter,

View File

@@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { fetchGrades } from 'data/actions/grades';
import selectors from 'data/selectors';
import SelectGroup from '../SelectGroup';
export class StudentGroupsFilter extends React.Component {
@@ -134,13 +135,16 @@ StudentGroupsFilter.propTypes = {
})),
};
export const mapStateToProps = (state) => ({
cohorts: state.cohorts.results,
selectedAssignmentType: state.filters.assignmentType,
selectedCohort: state.filters.cohort,
selectedTrack: state.filters.track,
tracks: state.tracks.results,
});
export const mapStateToProps = (state) => {
const { filters, cohorts, tracks } = selectors;
return {
cohorts: cohorts.allCohorts(state),
selectedAssignmentType: filters.assignmentType(state),
selectedCohort: filters.cohort(state),
selectedTrack: filters.track(state),
tracks: tracks.allTracks(state),
};
};
export const mapDispatchToProps = {
getUserGrades: fetchGrades,

View File

@@ -6,6 +6,7 @@ import { connect } from 'react-redux';
import { Collapsible, Form } from '@edx/paragon';
import * as filterActions from 'data/actions/filters';
import selectors from 'data/selectors';
import AssignmentTypeFilter from './AssignmentTypeFilter';
import AssignmentFilter from './AssignmentFilter';
@@ -109,7 +110,7 @@ GradebookFilters.propTypes = {
};
export const mapStateToProps = (state) => ({
includeCourseRoleMembers: state.filters.includeCourseRoleMembers,
includeCourseRoleMembers: selectors.filters.includeCourseRoleMembers(state),
});
export const mapDispatchToProps = {

View File

@@ -2,13 +2,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Table, OverlayTrigger, Tooltip, Icon,
} from '@edx/paragon';
import { formatDateForDisplay } from '../../data/actions/utils';
import { getHeadings } from '../../data/selectors/grades';
import { fetchGradeOverrideHistory } from '../../data/actions/grades';
import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from '../../data/constants/grades';
import selectors from '../../data/selectors';
const DECIMAL_PRECISION = 2;
@@ -215,12 +217,15 @@ GradebookTable.propTypes = {
fetchGradeOverrideHistory: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
format: state.grades.gradeFormat,
grades: state.grades.results,
headings: getHeadings(state),
});
export const mapStateToProps = (state) => {
const { assignmentTypes, grades, root } = selectors;
return {
areGradesFrozen: assignmentTypes.areGradesFrozen(state),
format: grades.gradeFormat(state),
grades: grades.allGrades(state),
headings: root.getHeadings(state),
};
};
export const mapDispatchToProps = {
fetchGradeOverrideHistory,

View File

@@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { Button, Icon, SearchField } from '@edx/paragon';
import selectors from 'data/selectors';
import {
fetchGrades,
fetchMatchingUserGrades,
@@ -96,11 +97,14 @@ SearchControls.propTypes = {
selectedTrack: PropTypes.string,
};
export const mapStateToProps = (state) => ({
selectedAssignmentType: state.filters.assignmentType,
selectedTrack: state.filters.track,
selectedCohort: state.filters.cohort,
});
export const mapStateToProps = (state) => {
const { filters } = selectors;
return {
selectedAssignmentType: filters.assignmentType(state),
selectedTrack: filters.track(state),
selectedCohort: filters.cohort(state),
};
};
export const mapDispatchToProps = {
getUserGrades: fetchGrades,

View File

@@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { StatusAlert } from '@edx/paragon';
import selectors from 'data/selectors';
import { closeBanner } from '../../data/actions/grades';
export const maxCourseGradeInvalidMessage = 'Maximum course grade value must be between 0 and 100. ';
@@ -61,7 +62,7 @@ StatusAlerts.propTypes = {
};
export const mapStateToProps = (state) => ({
showSuccessBanner: state.grades.showSuccess,
showSuccessBanner: selectors.grades.showSuccess(state),
});
export const mapDispatchToProps = {

View File

@@ -1,5 +1,6 @@
import { connect } from 'react-redux';
import selectors from 'data/selectors';
import Gradebook from '../../components/Gradebook';
import {
fetchGradeOverrideHistory,
@@ -19,96 +20,45 @@ import {
updateAssignmentFilter,
updateAssignmentLimits,
} from '../../data/actions/filters';
import stateHasMastersTrack from '../../data/selectors/tracks';
import {
getBulkManagementHistory,
getHeadings,
formatMinAssignmentGrade,
formatMaxAssignmentGrade,
formatMinCourseGrade,
formatMaxCourseGrade,
} from '../../data/selectors/grades';
import { selectableAssignmentLabels } from '../../data/selectors/filters';
import { hasSpecialBulkManagementAccess } from '../../data/selectors/special';
import { getCohortNameById } from '../../data/selectors/cohorts';
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
import { getRoles } from '../../data/actions/roles';
import LmsApiService from '../../data/services/LmsApiService';
function shouldShowSpinner(state) {
if (state.roles.canUserViewGradebook === true) {
return state.grades.showSpinner;
} if (state.roles.canUserViewGradebook === false) {
return false;
} // canUserViewGradebook === null
return true;
}
const mapStateToProps = (state, ownProps) => {
const {
root,
assignmentTypes,
filters,
grades,
roles,
} = selectors;
const mapStateToProps = (state, ownProps) => (
{
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
assignmentTypes: state.assignmentTypes.results,
assignmentFilterOptions: selectableAssignmentLabels(state),
bulkImportError: state.grades.bulkManagement
&& state.grades.bulkManagement.errorMessages
? `Errors while processing: ${state.grades.bulkManagement.errorMessages.join(', ')}`
: '',
bulkManagementHistory: getBulkManagementHistory(state),
courseId: ownProps.match.params.courseId,
canUserViewGradebook: state.roles.canUserViewGradebook,
filteredUsersCount: state.grades.filteredUsersCount,
format: state.grades.gradeFormat,
gradeExportUrl: LmsApiService.getGradeExportCsvUrl(ownProps.match.params.courseId, {
cohort: getCohortNameById(state, state.filters.cohort),
track: state.filters.track,
assignment: (state.filters.assignment || {}).id,
assignmentType: state.filters.assignmentType,
assignmentGradeMin: formatMinAssignmentGrade(
state.filters.assignmentGradeMin,
{ assignmentId: (state.filters.assignment || {}).id },
),
assignmentGradeMax: formatMaxAssignmentGrade(
state.filters.assignmentGradeMax,
{ assignmentId: (state.filters.assignment || {}).id },
),
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
excludedCourseRoles: state.filters.includeCourseRoleMembers ? '' : 'all',
}),
grades: state.grades.results,
headings: getHeadings(state),
interventionExportUrl:
LmsApiService.getInterventionExportCsvUrl(ownProps.match.params.courseId, {
cohort: getCohortNameById(state, state.filters.cohort),
assignment: (state.filters.assignment || {}).id,
assignmentType: state.filters.assignmentType,
assignmentGradeMin: formatMinAssignmentGrade(
state.filters.assignmentGradeMin,
{ assignmentId: (state.filters.assignment || {}).id },
),
assignmentGradeMax: formatMaxAssignmentGrade(
state.filters.assignmentGradeMax,
{ assignmentId: (state.filters.assignment || {}).id },
),
courseGradeMin: formatMinCourseGrade(state.filters.courseGradeMin),
courseGradeMax: formatMaxCourseGrade(state.filters.courseGradeMax),
}),
const { courseId } = ownProps.match.params;
return {
courseId,
areGradesFrozen: assignmentTypes.areGradesFrozen(state),
assignmentTypes: assignmentTypes.allAssignmentTypes(state),
assignmentFilterOptions: filters.selectableAssignmentLabels(state),
bulkImportError: grades.bulkImportError(state),
bulkManagementHistory: grades.bulkManagementHistoryEntries(state),
canUserViewGradebook: roles.canUserViewGradebook(state),
filteredUsersCount: grades.filteredUsersCount(state),
format: grades.gradeFormat(state),
gradeExportUrl: root.gradeExportUrl(state, { courseId }),
grades: grades.allGrades(state),
headings: root.getHeadings(state),
interventionExportUrl: root.interventionExportUrl(state, { courseId }),
nextPage: state.grades.nextPage,
prevPage: state.grades.prevPage,
selectedTrack: state.filters.track,
selectedCohort: state.filters.cohort,
selectedAssignmentType: state.filters.assignmentType,
selectedAssignment: (state.filters.assignment || {}).label,
showBulkManagement: (
hasSpecialBulkManagementAccess(ownProps.match.params.courseId)
|| (stateHasMastersTrack(state) && state.config.bulkManagementAvailable)
),
showSpinner: shouldShowSpinner(state),
totalUsersCount: state.grades.totalUsersCount,
uploadSuccess: !!(state.grades.bulkManagement
&& state.grades.bulkManagement.uploadSuccess),
}
);
selectedTrack: filters.track(state),
selectedCohort: filters.cohort(state),
selectedAssignmentType: filters.assignmentType(state),
selectedAssignment: filters.selectedAssignmentLabel(state),
showBulkManagement: root.showBulkManagement(state, { courseId }),
showSpinner: root.shouldShowSpinner(state),
totalUsersCount: grades.totalUsersCount(state),
uploadSuccess: grades.uploadSuccess(state),
};
};
const mapDispatchToProps = {
downloadBulkGradesReport,

View File

@@ -1,3 +1,4 @@
import filterSelectors from 'data/selectors/filters';
import initialFilters from '../constants/filters';
import {
INITIALIZE_FILTERS,
@@ -7,9 +8,10 @@ import {
UPDATE_COURSE_GRADE_LIMITS,
UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
} from '../constants/actionTypes/filters';
import { getFilters } from '../selectors/filters';
import { fetchGrades } from './grades';
const { allFilters } = filterSelectors;
const initializeFilters = ({
assignment = initialFilters.assignment,
assignmentType = initialFilters.assignmentType,
@@ -69,7 +71,7 @@ const updateIncludeCourseRoleMembersFilter = (includeCourseRoleMembers) => ({
const updateIncludeCourseRoleMembers = includeCourseRoleMembers => (dispatch, getState) => {
dispatch(updateIncludeCourseRoleMembersFilter(includeCourseRoleMembers));
const state = getState();
const { cohort, track, assignmentType } = getFilters(state);
const { cohort, track, assignmentType } = allFilters(state);
dispatch(fetchGrades(state.grades.courseId, cohort, track, assignmentType));
};

View File

@@ -1,4 +1,6 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import gradesSelectors from 'data/selectors/grades';
import filtersSelectors from 'data/selectors/filters';
import {
STARTED_FETCHING_GRADES,
FINISHED_FETCHING_GRADES,
@@ -27,10 +29,14 @@ import {
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors';
import LmsApiService from '../services/LmsApiService';
import { sortAlphaAsc, formatDateForDisplay } from './utils';
import {
formatMaxAssignmentGrade, formatMinAssignmentGrade, formatMaxCourseGrade, formatMinCourseGrade,
} from '../selectors/grades';
import { getFilters } from '../selectors/filters';
const {
formatMaxAssignmentGrade,
formatMinAssignmentGrade,
formatMaxCourseGrade,
formatMinCourseGrade,
} = gradesSelectors;
const { allFilters } = filtersSelectors;
const defaultAssignmentFilter = 'All';
@@ -142,7 +148,7 @@ const fetchGrades = (
courseGradeMin,
courseGradeMax,
includeCourseRoleMembers,
} = getFilters(getState());
} = allFilters(getState());
const { id: assignmentId } = assignment || {};
const assignmentGradeMax = formatMaxAssignmentGrade(assignmentMax, { assignmentId });
const assignmentGradeMin = formatMinAssignmentGrade(assignmentMin, { assignmentId });

View File

@@ -1,3 +1,4 @@
import filtersSelectors from 'data/selectors/filters';
import {
GOT_ROLES,
ERROR_FETCHING_ROLES,
@@ -6,9 +7,10 @@ import { fetchGrades } from './grades';
import { fetchTracks } from './tracks';
import { fetchCohorts } from './cohorts';
import { fetchAssignmentTypes } from './assignmentTypes';
import { getFilters } from '../selectors/filters';
import LmsApiService from '../services/LmsApiService';
const { allFilters } = filtersSelectors;
const allowedRoles = ['staff', 'instructor', 'support'];
const gotRoles = (canUserViewGradebook, courseId) => ({
@@ -26,7 +28,7 @@ const getRoles = courseId => (
|| (response.roles.some(role => (role.course_id === courseId)
&& allowedRoles.includes(role.role)));
dispatch(gotRoles(canUserViewGradebook, courseId));
const { cohort, track, assignmentType } = getFilters(getState());
const { cohort, track, assignmentType } = allFilters(getState());
if (canUserViewGradebook) {
dispatch(fetchGrades(courseId, cohort, track, assignmentType));
dispatch(fetchTracks(courseId));

View File

@@ -1,24 +1,27 @@
/* eslint-disable camelcase */
import tracksSelectors from 'data/selectors/tracks';
import {
STARTED_FETCHING_TRACKS,
GOT_TRACKS,
ERROR_FETCHING_TRACKS,
} from '../constants/actionTypes/tracks';
import { hasMastersTrack } from '../selectors/tracks';
import { fetchBulkUpgradeHistory } from './grades';
import LmsApiService from '../services/LmsApiService';
const { hasMastersTrack } = tracksSelectors;
const startedFetchingTracks = () => ({ type: STARTED_FETCHING_TRACKS });
const errorFetchingTracks = () => ({ type: ERROR_FETCHING_TRACKS });
const gotTracks = tracks => ({ type: GOT_TRACKS, tracks });
const gotTracks = (tracks) => ({ type: GOT_TRACKS, tracks });
const fetchTracks = courseId => (
const fetchTracks = (courseId) => (
(dispatch) => {
dispatch(startedFetchingTracks());
return LmsApiService.fetchTracks(courseId)
.then(response => response.data)
.then((data) => {
dispatch(gotTracks(data.course_modes));
if (hasMastersTrack(data.course_modes)) {
.then(({ data }) => data)
.then(({ course_modes }) => {
dispatch(gotTracks(course_modes));
if (hasMastersTrack(course_modes)) {
dispatch(fetchBulkUpgradeHistory(courseId));
}
})

View File

@@ -1,5 +1,5 @@
import filterSelectors from 'data/selectors/filters';
import { GOT_GRADES, FILTER_BY_ASSIGNMENT_TYPE } from '../constants/actionTypes/grades';
import {
INITIALIZE_FILTERS,
UPDATE_ASSIGNMENT_FILTER,
@@ -8,11 +8,9 @@ import {
RESET_FILTERS,
UPDATE_INCLUDE_COURSE_ROLE_MEMBERS,
} from '../constants/actionTypes/filters';
import initialFilters from '../constants/filters';
import { getAssignmentsFromResultsSubstate, chooseRelevantAssignmentData } from '../selectors/filters';
const { getAssignmentsFromResultsSubstate, chooseRelevantAssignmentData } = filterSelectors;
const initialState = {};
const reducer = (state = initialState, action) => {

View File

@@ -0,0 +1,6 @@
const selectors = {
areGradesFrozen: ({ assignmentTypes }) => assignmentTypes.areGradesFrozen,
allAssignmentTypes: ({ assignmentTypes }) => assignmentTypes.results,
};
export default selectors;

View File

@@ -1,10 +1,16 @@
const getCohorts = state => state.cohorts.results || [];
const allCohorts = state => state.cohorts.results || [];
const getCohortById = (state, selectedCohortId) => {
const cohort = getCohorts(state).find(coh => coh.id === selectedCohortId);
const cohort = allCohorts(state).find(coh => coh.id === selectedCohortId);
return cohort;
};
const getCohortNameById = (state, selectedCohortId) => (getCohortById(state, selectedCohortId) || {}).name;
export { getCohortById, getCohortNameById, getCohorts };
const selectors = {
getCohortById,
getCohortNameById,
allCohorts,
};
export default selectors;

View File

@@ -1,38 +1,73 @@
const getFilters = state => state.filters || {};
import simpleSelectorFactory from '../utils';
const getAssignmentsFromResultsSubstate = results => (results[0] || {}).section_breakdown || [];
const allFilters = (state) => state.filters || {};
const getAssignmentsFromResultsSubstate = (results) => (
(results[0] || {}).section_breakdown || []
);
const selectableAssignments = (state) => {
const selectedAssignmentType = getFilters(state).assignmentType;
const selectedAssignmentType = allFilters(state).assignmentType;
const needToFilter = selectedAssignmentType && selectedAssignmentType !== 'All';
const allAssignments = getAssignmentsFromResultsSubstate(state.grades.results);
if (needToFilter) {
return allAssignments.filter(assignment => assignment.category === selectedAssignmentType);
return allAssignments.filter(
(assignment) => assignment.category === selectedAssignmentType,
);
}
return allAssignments;
};
const chooseRelevantAssignmentData = assignment => ({
label: assignment.label,
subsectionLabel: assignment.subsection_name,
type: assignment.category,
id: assignment.module_id,
const chooseRelevantAssignmentData = ({
label,
subsection_name: subsectionLabel,
category,
module_id: id,
}) => ({
label, subsectionLabel, category, id,
});
const selectableAssignmentLabels = state => selectableAssignments(state).map(chooseRelevantAssignmentData);
const selectableAssignmentLabels = (state) => (
selectableAssignments(state).map(chooseRelevantAssignmentData)
);
const typeOfSelectedAssignment = (state) => {
const selectedAssignmentLabel = getFilters(state).assignment;
const selectedAssignmentLabel = allFilters(state).assignment;
const sectionBreakdown = (state.grades.results[0] || {}).section_breakdown || [];
const selectedAssignment = sectionBreakdown.find(section => section.label === selectedAssignmentLabel);
const selectedAssignment = sectionBreakdown.find(
({ label }) => label === selectedAssignmentLabel,
);
return selectedAssignment && selectedAssignment.category;
};
export {
const simpleSelectors = simpleSelectorFactory(
({ filters }) => filters,
[
'assignment',
'assignmentGradeMax',
'assignmentGradeMin',
'assignmentType',
'cohort',
'courseGradeMax',
'courseGradeMin',
'track',
'includeCourseRoleMembers',
],
);
const selectedAssignmentId = (state) => (simpleSelectors.assignment(state) || {}).id;
const selectedAssignmentLabel = (state) => (simpleSelectors.assignment(state) || {}).label;
const selectors = {
...simpleSelectors,
selectedAssignmentId,
selectedAssignmentLabel,
selectableAssignmentLabels,
selectableAssignments,
getFilters,
allFilters,
typeOfSelectedAssignment,
chooseRelevantAssignmentData,
getAssignmentsFromResultsSubstate,
};
export default selectors;

View File

@@ -1,5 +1,5 @@
import { formatDateForDisplay } from '../actions/utils';
import { getFilters } from './filters';
import simpleSelectorFactory from '../utils';
import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from '../constants/grades';
const getRowsProcessed = (data) => {
@@ -16,26 +16,25 @@ const getRowsProcessed = (data) => {
};
};
const transformHistoryEntry = (historyRow) => {
const {
modified,
original_filename: originalFilename,
data,
...rest
} = historyRow;
const transformHistoryEntry = ({
modified,
original_filename: originalFilename,
data,
...rest
}) => ({
timeUploaded: formatDateForDisplay(new Date(modified)),
originalFilename,
summaryOfRowsProcessed: getRowsProcessed(data),
...rest,
});
const timeUploaded = formatDateForDisplay(new Date(modified));
const summaryOfRowsProcessed = getRowsProcessed(data);
const bulkManagementHistory = ({ grades: { bulkManagement } }) => (
(bulkManagement.history || [])
);
return {
timeUploaded,
originalFilename,
summaryOfRowsProcessed,
...rest,
};
};
const getBulkManagementHistoryFromState = state => state.grades.bulkManagement.history || [];
const getBulkManagementHistory = state => getBulkManagementHistoryFromState(state).map(transformHistoryEntry);
const bulkManagementHistoryEntries = (state) => (
bulkManagementHistory(state).map(transformHistoryEntry)
);
const headingMapper = (category, label = 'All') => {
const filters = {
@@ -67,19 +66,9 @@ const headingMapper = (category, label = 'All') => {
};
};
const getExampleSectionBreakdown = state => (state.grades.results[0] || {}).section_breakdown || [];
const getHeadings = (state) => {
const filters = getFilters(state) || {};
const {
assignmentType: selectedAssignmentType,
assignment: selectedAssignment,
} = filters;
const assignments = getExampleSectionBreakdown(state);
const type = selectedAssignmentType || 'All';
const assignment = (selectedAssignment || {}).label || 'All';
return headingMapper(type, assignment)(assignments);
};
const getExampleSectionBreakdown = ({ grades }) => (
(grades.results[0] || {}).section_breakdown || []
);
const composeFilters = (...predicates) => (percentGrade, options = {}) => predicates.reduce((accum, predicate) => {
if (predicate(percentGrade, options)) {
@@ -102,20 +91,56 @@ const assignmentIdIsDefined = (percentGrade, { assignmentId }) => (
const formatMaxCourseGrade = composeFilters(percentGradeIsMax);
const formatMinCourseGrade = composeFilters(percentGradeIsMin);
const formatMaxAssignmentGrade = composeFilters(
percentGradeIsMax,
assignmentIdIsDefined,
);
const formatMinAssignmentGrade = composeFilters(
percentGradeIsMin,
assignmentIdIsDefined,
);
export {
getBulkManagementHistory,
getHeadings,
const simpleSelectors = simpleSelectorFactory(
({ grades }) => grades,
[
'filteredUsersCount',
'totalUsersCount',
'gradeFormat',
'showSpinner',
'gradeOverrideCurrentEarnedGradedOverride',
'gradeOverrideHistoryError',
'gradeOriginalEarnedGraded',
'gradeOriginalPossibleGraded',
'showSuccess',
],
);
const allGrades = ({ grades: { results } }) => results;
const uploadSuccess = ({ grades: { bulkManagement } }) => (!!bulkManagement && bulkManagement.uploadSuccess);
const bulkImportError = ({ grades: { bulkManagement } }) => (
(!!bulkManagement && bulkManagement.errorMessages)
? `Errors while processing: ${bulkManagement.errorMessages.join(', ')}`
: ''
);
const gradeOverrides = ({ grades }) => grades.gradeOverrideHistoryResults;
const selectors = {
bulkImportError,
formatMinAssignmentGrade,
formatMaxAssignmentGrade,
formatMaxCourseGrade,
formatMinCourseGrade,
getExampleSectionBreakdown,
headingMapper,
...simpleSelectors,
allGrades,
uploadSuccess,
bulkManagementHistoryEntries,
gradeOverrides,
};
export default selectors;

View File

@@ -1,4 +1,4 @@
import { getBulkManagementHistory } from './grades';
import selectors from './grades';
const genericHistoryRow = {
id: 5,
@@ -15,14 +15,14 @@ const genericHistoryRow = {
},
};
describe('getBulkManagementHistory', () => {
describe('bulkManagementHistoryEntries', () => {
it('handles history being as-yet unloaded', () => {
const result = getBulkManagementHistory({ grades: { bulkManagement: {} } });
const result = selectors.bulkManagementHistoryEntries({ grades: { bulkManagement: {} } });
expect(result).toEqual([]);
});
it('formats dates for us', () => {
const result = getBulkManagementHistory({
const result = selectors.bulkManagementHistoryEntries({
grades: {
bulkManagement: {
history: [
@@ -37,7 +37,7 @@ describe('getBulkManagementHistory', () => {
});
const exerciseGetRowsProcessed = (input, expectation) => {
const result = getBulkManagementHistory({
const result = selectors.bulkManagementHistoryEntries({
grades: {
bulkManagement: {
history: [
@@ -50,7 +50,7 @@ describe('getBulkManagementHistory', () => {
expect(summaryOfRowsProcessed).toEqual(expect.objectContaining(expectation));
};
it('calculates skippage', () => {
it('calculates skipped rows', () => {
exerciseGetRowsProcessed({
total_rows: 100,
processed_rows: 10,

View File

@@ -0,0 +1,71 @@
import LmsApiService from 'data/services/LmsApiService';
import assignmentTypes from './assignmentTypes';
import cohorts from './cohorts';
import filters from './filters';
import grades from './grades';
import roles from './roles';
import special from './special';
import tracks from './tracks';
const lmsApiServiceArgs = (state) => ({
cohort: cohorts.getCohortNameById(state, filters.cohort(state)),
assignment: filters.selectedAssignmentId(state),
assignmentType: filters.assignmentType(state),
assignmentGradeMin: grades.formatMinAssignmentGrade(
filters.assignmentGradeMin(state),
{ assignmentId: filters.selectedAssignmentId(state) },
),
assignmentGradeMax: grades.formatMaxAssignmentGrade(
filters.assignmentGradeMax(state),
{ assignmentId: filters.selectedAssignmentId(state) },
),
courseGradeMin: grades.formatMinCourseGrade(filters.courseGradeMin(state)),
courseGradeMax: grades.formatMaxCourseGrade(filters.courseGradeMax(state)),
});
const gradeExportUrl = (state, { courseId }) => (
LmsApiService.getGradeExportCsvUrl(courseId, {
...lmsApiServiceArgs(state),
excludeCourseRoles: filters.includeCourseRoleMembers(state) ? '' : 'all',
})
);
const interventionExportUrl = (state, { courseId }) => (
LmsApiService.getInterventionExportCsvUrl(
courseId,
lmsApiServiceArgs(state),
)
);
const showBulkManagement = (state, { courseId }) => (
special.hasSpecialBulkManagementAccess(courseId)
|| (tracks.stateHasMastersTrack(state) && state.config.bulkManagementAvailable)
);
const shouldShowSpinner = (state) => {
const canView = roles.canUserViewGradebook(state);
return canView && grades.showSpinner(state);
};
const getHeadings = (state) => grades.headingMapper(
filters.assignmentType(state) || 'All',
filters.selectedAssignmentLabel(state) || 'All',
)(grades.getExampleSectionBreakdown(state));
export default {
root: {
getHeadings,
gradeExportUrl,
interventionExportUrl,
showBulkManagement,
shouldShowSpinner,
},
assignmentTypes,
cohorts,
filters,
grades,
roles,
special,
tracks,
};

View File

@@ -0,0 +1,5 @@
const selectors = {
canUserViewGradebook: ({ roles }) => roles.canUserViewGradebook,
};
export default selectors;

View File

@@ -3,10 +3,11 @@
// Note that this does not affect whether or not the backend
// LMS API will permit usage of the tool.
const hasSpecialBulkManagementAccess = courseId => {
const specialIdList = process.env.BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS || '';
return specialIdList.split(',').includes(courseId);
const selectors = {
hasSpecialBulkManagementAccess: (courseId) => {
const specialIdList = process.env.BULK_MANAGEMENT_SPECIAL_ACCESS_COURSE_IDS || '';
return specialIdList.split(',').includes(courseId);
},
};
export { hasSpecialBulkManagementAccess };
export default hasSpecialBulkManagementAccess;
export default selectors;

View File

@@ -3,10 +3,16 @@ const compose = (...fns) => {
return (...args) => rest.reduce((accum, fn) => fn(accum), firstFunc(...args));
};
const getTracks = state => state.tracks.results || [];
const allTracks = state => state.tracks.results || [];
const trackIsMasters = track => track.slug === 'masters';
const hasMastersTrack = tracks => tracks.some(trackIsMasters);
const stateHasMastersTrack = compose(hasMastersTrack, getTracks);
const stateHasMastersTrack = compose(hasMastersTrack, allTracks);
export { hasMastersTrack, trackIsMasters };
export default stateHasMastersTrack;
const selectors = {
allTracks,
hasMastersTrack,
stateHasMastersTrack,
trackIsMasters,
};
export default selectors;

19
src/data/utils.js Normal file
View File

@@ -0,0 +1,19 @@
/**
* Simple selector factory.
* Takes a list of string keys, and returns a simple slector for each.
*
* @function
* @param {Object|string[]} keys - If passed as object, Object.keys(keys) is used.
* @return {Object} - object of `{[key]: ({key}) => key}`
*/
const simpleSelectorFactory = (transformer, keys) => {
const selKeys = Array.isArray(keys) ? keys : Object.keys(keys);
return selKeys.reduce(
(obj, key) => ({
...obj, [key]: (state) => transformer(state)[key],
}),
{ root: (state) => transformer(state) },
);
};
export default simpleSelectorFactory;

27
src/data/utils.test.js Normal file
View File

@@ -0,0 +1,27 @@
import simpleSelectorFactory from './utils';
describe('Redux utilities - creators', () => {
describe('simpleSelectors', () => {
const data = { a: 1, b: 2, c: 3 };
const state = {
testGroup: data,
other: 'stuff',
};
const transformer = ({ testGroup }) => testGroup;
test('given a list of strings, returns a dict w/ a simple selector per string', () => {
const keys = ['a', 'b'];
const selectors = simpleSelectorFactory(transformer, keys);
expect(Object.keys(selectors)).toEqual(['root', ...keys]);
expect(selectors.a(state)).toEqual(data.a);
expect(selectors.b(state)).toEqual(data.b);
});
test('given an object for keys, returns a dict w/ simple selector per key', () => {
const selectors = simpleSelectorFactory(transformer, data);
expect(Object.keys(selectors)).toEqual(['root', ...Object.keys(data)]);
expect(selectors.a(state)).toEqual(data.a);
expect(selectors.b(state)).toEqual(data.b);
expect(selectors.c(state)).toEqual(data.c);
});
});
});