diff --git a/package.json b/package.json index dedef87..1ce3dc5 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@edx/frontend-app-gradebook", - "version": "1.4.35", + "version": "1.4.36", "description": "edx editable gradebook-ui to manipulate grade overrides on subsections", "repository": { "type": "git", diff --git a/src/components/GradesTab/GradebookTable/Fields.jsx b/src/components/GradesTab/GradebookTable/Fields.jsx index ad310db..d7b9974 100644 --- a/src/components/GradesTab/GradebookTable/Fields.jsx +++ b/src/components/GradesTab/GradebookTable/Fields.jsx @@ -3,30 +3,41 @@ import PropTypes from 'prop-types'; import { StrictDict } from 'utils'; -const Username = ({ entry }) => ( +/** + * Fields.Username + * simple label field for username, that optionally also displays external_user_key (userKey) + * if it is provided. + * @param {string} username - username for display + * @param {userKey} userKey - external_user_key for display + */ +const Username = ({ username, userKey }) => (
-
{entry.username}
- {entry.external_user_key &&
{entry.external_user_key}
} +
{username}
+ {userKey &&
{userKey}
}
); +Username.defaultProps = { + userKey: null, +}; Username.propTypes = { - entry: PropTypes.shape({ - username: PropTypes.string, - external_user_key: PropTypes.string, - }).isRequired, + username: PropTypes.string.isRequired, + userKey: PropTypes.string, }; -const Email = ({ entry }) => ( - {entry.email} +/** + * Fields.Email + * Simple label field for email value. + * @param {string} email - email for display + */ +const Email = ({ email }) => ( + {email} ); Email.propTypes = { - entry: PropTypes.shape({ - email: PropTypes.string, - }).isRequired, + email: PropTypes.string.isRequired, }; export default StrictDict({ diff --git a/src/components/GradesTab/GradebookTable/Fields.test.jsx b/src/components/GradesTab/GradebookTable/Fields.test.jsx new file mode 100644 index 0000000..53c02fc --- /dev/null +++ b/src/components/GradesTab/GradebookTable/Fields.test.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import Fields from './Fields'; + +describe('Gradebook Table Fields', () => { + describe('Username', () => { + let el; + const username = 'MyNameFromHere'; + describe('with external_user_key', () => { + const props = { + username, + userKey: 'My name from another land', + }; + beforeEach(() => { + el = shallow(); + }); + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + test('wraps external user key and username', () => { + expect(el.find('span').childAt(0)).toMatchSnapshot(); + expect(el.find('span').childAt(0)).toMatchSnapshot(); + const content = el.find('span').childAt(0); + expect(content.childAt(0).text()).toEqual(username); + expect(content.childAt(1).text()).toEqual(props.userKey); + }); + }); + describe('without external_user_key', () => { + beforeEach(() => { + el = shallow(); + }); + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + test('wraps username only', () => { + const content = el.find('span').childAt(0); + expect(content.childAt(0).text()).toEqual(username); + expect(content.children()).toHaveLength(1); + }); + }); + }); + + describe('Email', () => { + const email = 'myTag@place.com'; + test('snapshot', () => { + expect(shallow()).toMatchSnapshot(); + }); + test('wraps entry email', () => { + expect(shallow().text()).toEqual(email); + }); + }); +}); diff --git a/src/components/GradesTab/GradebookTable/GradeButton.jsx b/src/components/GradesTab/GradebookTable/GradeButton.jsx index 66b3245..67be259 100644 --- a/src/components/GradesTab/GradebookTable/GradeButton.jsx +++ b/src/components/GradesTab/GradebookTable/GradeButton.jsx @@ -1,19 +1,33 @@ /* eslint-disable react/sort-comp, react/button-has-type */ import React from 'react'; import PropTypes from 'prop-types'; -import classNames from 'classnames'; import { connect } from 'react-redux'; import { Button } from '@edx/paragon'; +import selectors from 'data/selectors'; import thunkActions from 'data/thunkActions'; -class GradeButton extends React.Component { +const { subsectionGrade } = selectors.grades; + +/** + * GradeButton + * The button link for a user's grade for a given subseciton. + * load formatting based on selected grade format, and on click, opens + * the editModal, loading in the current entry and subsection. + * @param {object} entry - user's grade entry + * @param {object} subsection - user's subsection grade from subsection_breakdown + */ +export class GradeButton extends React.Component { constructor(props) { super(props); this.onClick = this.onClick.bind(this); } + get label() { + return subsectionGrade[this.props.format](this.props.subsection); + } + onClick() { this.props.setModalState({ userEntry: this.props.entry, @@ -22,26 +36,21 @@ class GradeButton extends React.Component { } render() { - return ( - - ); + return this.props.areGradesFrozen + ? this.label + : ( + + ); } } GradeButton.propTypes = { - label: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, - ]).isRequired, subsection: PropTypes.shape({ attempted: PropTypes.bool, percent: PropTypes.number, @@ -54,11 +63,18 @@ GradeButton.propTypes = { username: PropTypes.string, }).isRequired, // redux + areGradesFrozen: PropTypes.bool.isRequired, + format: PropTypes.string.isRequired, setModalState: PropTypes.func.isRequired, }; +export const mapStateToProps = (state) => ({ + areGradesFrozen: selectors.assignmentTypes.areGradesFrozen(state), + format: selectors.grades.gradeFormat(state), +}); + export const mapDispatchToProps = { setModalState: thunkActions.app.setModalStateFromTable, }; -export default connect(() => ({}), mapDispatchToProps)(GradeButton); +export default connect(mapStateToProps, mapDispatchToProps)(GradeButton); diff --git a/src/components/GradesTab/GradebookTable/GradeButton.test.jsx b/src/components/GradesTab/GradebookTable/GradeButton.test.jsx new file mode 100644 index 0000000..36588ff --- /dev/null +++ b/src/components/GradesTab/GradebookTable/GradeButton.test.jsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Button } from '@edx/paragon'; +import selectors from 'data/selectors'; +import thunkActions from 'data/thunkActions'; + +import { + GradeButton, + mapStateToProps, + mapDispatchToProps, +} from './GradeButton'; + +jest.mock('@edx/paragon', () => ({ + Button: () => 'Button', +})); + +jest.mock('data/selectors', () => ({ + __esModule: true, + default: { + assignmentTypes: { + areGradesFrozen: jest.fn(state => ({ areGradesFrozen: state })), + }, + grades: { + subsectionGrade: { + percent: jest.fn(subsection => ({ percent: subsection })), + }, + gradeFormat: jest.fn(state => ({ gradeFormat: state })), + }, + }, +})); + +jest.mock('data/thunkActions', () => ({ + app: { + setModalStateFromTable: jest.fn(), + }, +})); + +describe('GradeButton', () => { + let el; + let props = { + subsection: { + attempted: false, + percent: 23, + score_possible: 32, + subsection_name: 'the things we do', + module_id: 'in potions', + }, + entry: { + user_id: 2, + username: 'Jessie', + }, + areGradesFrozen: false, + format: 'percent', + }; + beforeEach(() => { + props = { ...props, setModalState: jest.fn() }; + }); + describe('component', () => { + describe('snapshots', () => { + test('grades are frozen', () => { + el = shallow(); + const label = 'why you gotta label people?'; + jest.spyOn(el.instance(), 'label', 'get').mockReturnValue(label); + el.instance().onClick = jest.fn().mockName('this.onClick'); + expect(el.instance().render()).toMatchSnapshot(); + expect(el.instance().render()).toEqual(label); + }); + test('grades are not frozen', () => { + el = shallow(); + const label = 'why you gotta label people?'; + jest.spyOn(el.instance(), 'label', 'get').mockReturnValue(label); + el.instance().onClick = jest.fn().mockName('this.onClick'); + expect(el.instance().render()).toMatchSnapshot(); + expect(el.instance().render().props.children).toEqual(label); + expect(el.render().is(Button)).toEqual(true); + }); + }); + describe('label', () => { + it('calls the appropriate formatter with the subsection prop', () => { + el = shallow(); + expect( + el.instance().label, + ).toEqual(selectors.grades.subsectionGrade[props.format](props.subsection)); + }); + }); + describe('onClick', () => { + it('calls props.setModalState with userEntry and subsection', () => { + el = shallow(); + el.instance().onClick(); + expect(props.setModalState).toHaveBeenCalledWith({ + userEntry: props.entry, + subsection: props.subsection, + }); + }); + }); + }); + describe('mapStateToProps', () => { + let mapped; + const testState = { teams: { rocket: ['jesse', 'james'] } }; + beforeEach(() => { + mapped = mapStateToProps(testState); + }); + test('areGradesFrozen form assignmentTypes.areGradesFrozen', () => { + expect( + mapped.areGradesFrozen, + ).toEqual(selectors.assignmentTypes.areGradesFrozen(testState)); + }); + test('format form grades.format', () => { + expect(mapped.format).toEqual(selectors.grades.gradeFormat(testState)); + }); + }); + describe('mapDispatchToProps', () => { + test('setModalState from thunkActions.app.setModalStateFromTable', () => { + expect(mapDispatchToProps.setModalState).toEqual(thunkActions.app.setModalStateFromTable); + }); + }); +}); diff --git a/src/components/GradesTab/GradebookTable/LabelReplacements.jsx b/src/components/GradesTab/GradebookTable/LabelReplacements.jsx index 89fb532..be5585d 100644 --- a/src/components/GradesTab/GradebookTable/LabelReplacements.jsx +++ b/src/components/GradesTab/GradebookTable/LabelReplacements.jsx @@ -8,29 +8,37 @@ import { Tooltip, } from '@edx/paragon'; -import { TOTAL_COURSE_GRADE_HEADING } from 'data/constants/grades'; +import { Headings } from 'data/constants/grades'; -const TotalGradeLabelReplacement = () => { - const totalGradePercentageMessage = 'Total Grade values are always displayed as a percentage.'; - return ( -
- {totalGradePercentageMessage})} - > -
- {TOTAL_COURSE_GRADE_HEADING} -
- -
+export const totalGradePercentageMessage = 'Total Grade values are always displayed as a percentage.'; + +/** + * + * Total Grade column header. + * displays an overlay tooltip with screen-reader text to indicate total grade percentage + */ +const TotalGradeLabelReplacement = () => ( +
+ {totalGradePercentageMessage})} + > +
+ {Headings.totalGrade} +
+
- -
- ); -}; +
+ +
+); +/** + * + * Username column header. Lists that Student Key is possibly available + */ const UsernameLabelReplacement = () => (
Username
diff --git a/src/components/GradesTab/GradebookTable/LabelReplacements.test.jsx b/src/components/GradesTab/GradebookTable/LabelReplacements.test.jsx new file mode 100644 index 0000000..95ff0fe --- /dev/null +++ b/src/components/GradesTab/GradebookTable/LabelReplacements.test.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { OverlayTrigger } from '@edx/paragon'; + +import LabelReplacements from './LabelReplacements'; + +const { + TotalGradeLabelReplacement, + UsernameLabelReplacement, +} = LabelReplacements; + +jest.mock('@edx/paragon', () => ({ + Icon: () => 'Icon', + OverlayTrigger: () => 'OverlayTrigger', + Tooltip: () => 'Tooltip', +})); + +describe('LabelReplacements', () => { + describe('TotalGradeLabelReplacement', () => { + let el; + beforeEach(() => { + el = shallow(); + }); + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + test('displays overlay tooltip', () => { + expect(el.find(OverlayTrigger).props().overlay).toMatchSnapshot(); + }); + }); + describe('UsernameLabelReplacement', () => { + test('snapshot', () => { + expect(shallow()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/components/GradesTab/GradebookTable/__snapshots__/Fields.test.jsx.snap b/src/components/GradesTab/GradebookTable/__snapshots__/Fields.test.jsx.snap new file mode 100644 index 0000000..cb1f257 --- /dev/null +++ b/src/components/GradesTab/GradebookTable/__snapshots__/Fields.test.jsx.snap @@ -0,0 +1,68 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Gradebook Table Fields Email snapshot 1`] = ` + + myTag@place.com + +`; + +exports[`Gradebook Table Fields Username with external_user_key snapshot 1`] = ` +
+ +
+
+ MyNameFromHere +
+
+ My name from another land +
+
+
+
+`; + +exports[`Gradebook Table Fields Username with external_user_key wraps external user key and username 1`] = ` +
+
+ MyNameFromHere +
+
+ My name from another land +
+
+`; + +exports[`Gradebook Table Fields Username with external_user_key wraps external user key and username 2`] = ` +
+
+ MyNameFromHere +
+
+ My name from another land +
+
+`; + +exports[`Gradebook Table Fields Username without external_user_key snapshot 1`] = ` +
+ +
+
+ MyNameFromHere +
+
+
+
+`; diff --git a/src/components/GradesTab/GradebookTable/__snapshots__/GradeButton.test.jsx.snap b/src/components/GradesTab/GradebookTable/__snapshots__/GradeButton.test.jsx.snap new file mode 100644 index 0000000..13fa8f8 --- /dev/null +++ b/src/components/GradesTab/GradebookTable/__snapshots__/GradeButton.test.jsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GradeButton component snapshots grades are frozen 1`] = `"why you gotta label people?"`; + +exports[`GradeButton component snapshots grades are not frozen 1`] = ` + +`; diff --git a/src/components/GradesTab/GradebookTable/__snapshots__/LabelReplacements.test.jsx.snap b/src/components/GradesTab/GradebookTable/__snapshots__/LabelReplacements.test.jsx.snap new file mode 100644 index 0000000..74b6dd5 --- /dev/null +++ b/src/components/GradesTab/GradebookTable/__snapshots__/LabelReplacements.test.jsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LabelReplacements TotalGradeLabelReplacement displays overlay tooltip 1`] = ` + + Total Grade values are always displayed as a percentage. + +`; + +exports[`LabelReplacements TotalGradeLabelReplacement snapshot 1`] = ` +
+ + Total Grade values are always displayed as a percentage. + + } + placement="left" + trigger={ + Array [ + "hover", + "focus", + ] + } + > +
+ Total Grade (%) +
+ +
+
+
+
+`; + +exports[`LabelReplacements UsernameLabelReplacement snapshot 1`] = ` +
+
+ Username +
+
+ Student Key* +
+
+`; diff --git a/src/components/GradesTab/GradebookTable/__snapshots__/test.jsx.snap b/src/components/GradesTab/GradebookTable/__snapshots__/test.jsx.snap new file mode 100644 index 0000000..179a838 --- /dev/null +++ b/src/components/GradesTab/GradebookTable/__snapshots__/test.jsx.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GradebookTable component snapshot - fields1 and 2 between email and totalGrade, mocked rows 1`] = ` +
+
+ , + }, + Object { + "key": "Email", + "label": "Email", + }, + Object { + "key": "field1", + "label": "field1", + }, + Object { + "key": "field2", + "label": "field2", + }, + Object { + "key": "Total Grade (%)", + "label": , + }, + ] + } + data={ + Array [ + "mappedRow: 1", + "mappedRow: 2", + "mappedRow: 3", + ] + } + hasFixedColumnWidths={true} + rowHeaderColumnKey="username" + /> + + +`; diff --git a/src/components/GradesTab/GradebookTable/index.jsx b/src/components/GradesTab/GradebookTable/index.jsx index 6ccf8bf..194d2c7 100644 --- a/src/components/GradesTab/GradebookTable/index.jsx +++ b/src/components/GradesTab/GradebookTable/index.jsx @@ -1,74 +1,57 @@ -/* eslint-disable react/sort-comp, react/button-has-type */ +/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { - Table, -} from '@edx/paragon'; +import { Table } from '@edx/paragon'; -import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from 'data/constants/grades'; +import { Headings } from 'data/constants/grades'; import selectors from 'data/selectors'; import Fields from './Fields'; import LabelReplacements from './LabelReplacements'; import GradeButton from './GradeButton'; -export const DECIMAL_PRECISION = 2; -export const headerLabelReplacements = { - [TOTAL_COURSE_GRADE_HEADING]: , - [USERNAME_HEADING]: , -}; +const { roundGrade } = selectors.grades; +/** + * + * This is the wrapper component for the Grades tab gradebook table, holding + * a row for each user, with a column for their username, email, and total grade, + * along with one for each subsection in their grade entry. + */ export class GradebookTable extends React.Component { - replaceHeader = (heading) => { - const replacement = headerLabelReplacements[heading]; + constructor(props) { + super(props); + this.mapHeaders = this.mapHeaders.bind(this); + this.mapRows = this.mapRows.bind(this); + } + + mapHeaders(heading) { + const replacement = { + [Headings.totalGrade]: , + [Headings.username]: , + }[heading]; return { label: replacement !== undefined ? replacement : heading, key: heading, }; } - formatHeadings = () => ( - this.props.headings.length - ? this.props.headings.map(this.replaceHeader) - : this.props.headings - ) - - roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION)); - - data = () => this.props.grades.map(entry => ({ - [USERNAME_HEADING]: (), - [EMAIL_HEADING]: (), - ...entry.section_breakdown.reduce( - (obj, subsection) => ({ - ...obj, - [subsection.label]: this.formatter[this.props.format](entry, subsection), - }), - {}, - ), - [TOTAL_COURSE_GRADE_HEADING]: `${this.roundGrade(entry.percent * 100)}%`, - })); - - formatter = { - percent: (entry, subsection) => { - const entryGrade = this.roundGrade(subsection.percent * 100); - const label = `${entryGrade}`; - return (this.props.areGradesFrozen - ? label - : () + mapRows(entry) { + const dataRow = { + [Headings.username]: ( + + ), + [Headings.email]: (), + [Headings.totalGrade]: `${roundGrade(entry.percent * 100)}%`, + }; + entry.section_breakdown.forEach(subsection => { + dataRow[subsection.label] = ( + ); - }, - - absolute: (entry, subsection) => { - const earned = this.roundGrade(subsection.score_earned); - const possible = this.roundGrade(subsection.score_possible); - const label = subsection.attempted ? `${earned}/${possible}` : `${earned}`; - return (this.props.areGradesFrozen - ? label - : () - ); - }, + }); + return dataRow; } render() { @@ -76,8 +59,8 @@ export class GradebookTable extends React.Component {
@@ -88,14 +71,11 @@ export class GradebookTable extends React.Component { } GradebookTable.defaultProps = { - areGradesFrozen: false, grades: [], }; GradebookTable.propTypes = { // redux - areGradesFrozen: PropTypes.bool, - format: PropTypes.string.isRequired, grades: PropTypes.arrayOf(PropTypes.shape({ percent: PropTypes.number, section_breakdown: PropTypes.arrayOf(PropTypes.shape({ @@ -115,8 +95,6 @@ GradebookTable.propTypes = { }; export const mapStateToProps = (state) => ({ - areGradesFrozen: selectors.assignmentTypes.areGradesFrozen(state), - format: selectors.grades.gradeFormat(state), grades: selectors.grades.allGrades(state), headings: selectors.root.getHeadings(state), }); diff --git a/src/components/GradesTab/GradebookTable/test.jsx b/src/components/GradesTab/GradebookTable/test.jsx new file mode 100644 index 0000000..95a41ec --- /dev/null +++ b/src/components/GradesTab/GradebookTable/test.jsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Table } from '@edx/paragon'; + +import selectors from 'data/selectors'; +import { Headings } from 'data/constants/grades'; +import LabelReplacements from './LabelReplacements'; +import Fields from './Fields'; +import { GradebookTable, mapStateToProps } from '.'; + +jest.mock('@edx/paragon', () => ({ + Table: () => 'Table', +})); +jest.mock('./Fields', () => ({ + __esModule: true, + default: { + Username: () => 'Fields.Username', + Email: () => 'Fields.Email', + }, +})); +jest.mock('./LabelReplacements', () => ({ + __esModule: true, + default: { + TotalGradeLabelReplacement: () => 'TotalGradeLabelReplacement', + UsernameLabelReplacement: () => 'UsernameLabelReplacement', + }, +})); +jest.mock('./GradeButton', () => 'GradeButton'); +jest.mock('data/selectors', () => ({ + __esModule: true, + default: { + grades: { + roundGrade: jest.fn(grade => `roundedGrade: ${grade}`), + allGrades: jest.fn(state => ({ allGrades: state })), + }, + root: { + getHeadings: jest.fn(state => ({ getHeadings: state })), + }, + }, +})); +describe('GradebookTable', () => { + describe('component', () => { + let el; + const fields = { field1: 'field1', field2: 'field2' }; + const props = { + grades: [ + { + percent: 1, + section_breakdown: [ + { label: fields.field1, percent: 1.2 }, + { label: fields.field2, percent: 2.3 }, + ], + }, + { + percent: 2, + section_breakdown: [ + { label: fields.field1, percent: 1.2 }, + { label: fields.field2, percent: 2.3 }, + ], + }, + { + percent: 3, + section_breakdown: [ + { label: fields.field1, percent: 1.2 }, + { label: fields.field2, percent: 2.3 }, + ], + }, + ], + headings: [ + Headings.username, + Headings.email, + fields.field1, + fields.field2, + Headings.totalGrade, + ], + }; + test('snapshot - fields1 and 2 between email and totalGrade, mocked rows', () => { + el = shallow(); + el.instance().mapRows = (entry) => `mappedRow: ${entry.percent}`; + expect(el.instance().render()).toMatchSnapshot(); + }); + describe('table columns (mapHeaders)', () => { + let headings; + beforeEach(() => { + el = shallow(); + headings = el.find(Table).props().columns; + }); + test('username sets key and replaces label with component', () => { + const heading = headings[0]; + expect(heading.key).toEqual(Headings.username); + expect(heading.label.type).toEqual(LabelReplacements.UsernameLabelReplacement); + }); + test('email sets key and label from header', () => { + const heading = headings[1]; + expect(heading.key).toEqual(Headings.email); + expect(heading.label).toEqual(Headings.email); + }); + test('subsections set key and label from header', () => { + expect(headings[2]).toEqual({ key: fields.field1, label: fields.field1 }); + expect(headings[3]).toEqual({ key: fields.field2, label: fields.field2 }); + }); + test('totalGrade sets key and replaces label with component', () => { + const heading = headings[4]; + expect(heading.key).toEqual(Headings.totalGrade); + expect(heading.label.type).toEqual(LabelReplacements.TotalGradeLabelReplacement); + }); + }); + describe('table data (mapRows)', () => { + let rows; + beforeEach(() => { + el = shallow(); + rows = el.find(Table).props().data; + }); + describe.each([0, 1, 2])('gradeEntry($percent)', (gradeIndex) => { + let row; + const entry = props.grades[gradeIndex]; + beforeEach(() => { + row = rows[gradeIndex]; + }); + test('username set to Username Field', () => { + const field = row[Headings.username]; + expect(field.type).toEqual(Fields.Username); + expect(field.props).toEqual({ + username: entry.username, + userKey: entry.external_user_key, + }); + }); + test('email set to Email Field', () => { + const field = row[Headings.email]; + expect(field.type).toEqual(Fields.Email); + expect(field.props).toEqual({ email: entry.email }); + }); + test('totalGrade set to rounded percent grade * 100', () => { + expect( + row[Headings.totalGrade], + ).toEqual(`${selectors.grades.roundGrade(entry.percent * 100)}%`); + }); + test('subsections loaded as GradeButtons', () => { + }); + }); + }); + }); + describe('mapStateToProps', () => { + let mapped; + const testState = { + where: 'did', + all: 'of', + these: 'bananas', + come: 'from?', + }; + beforeEach(() => { + mapped = mapStateToProps(testState); + }); + test('grades from grades.allGrades', () => { + expect(mapped.grades).toEqual(selectors.grades.allGrades(testState)); + }); + test('headings from root.getHeadings', () => { + expect(mapped.headings).toEqual(selectors.root.getHeadings(testState)); + }); + }); +}); diff --git a/src/data/constants/grades.js b/src/data/constants/grades.js index 1eb0680..5425308 100644 --- a/src/data/constants/grades.js +++ b/src/data/constants/grades.js @@ -1,5 +1,24 @@ +import { StrictDict } from 'utils'; + const EMAIL_HEADING = 'Email'; const TOTAL_COURSE_GRADE_HEADING = 'Total Grade (%)'; const USERNAME_HEADING = 'Username'; -export { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING }; +const GradeFormats = StrictDict({ + absolute: 'absolute', + percent: 'percent', +}); + +const Headings = StrictDict({ + email: 'Email', + totalGrade: 'Total Grade (%)', + username: 'Username', +}); + +export { + EMAIL_HEADING, + TOTAL_COURSE_GRADE_HEADING, + USERNAME_HEADING, + GradeFormats, + Headings, +}; diff --git a/src/data/selectors/grades.js b/src/data/selectors/grades.js index 7cceae7..63a3209 100644 --- a/src/data/selectors/grades.js +++ b/src/data/selectors/grades.js @@ -1,8 +1,9 @@ /* eslint-disable import/no-self-import */ import { StrictDict } from 'utils'; -import { formatDateForDisplay } from '../actions/utils'; + +import { Headings, GradeFormats } from 'data/constants/grades'; +import { formatDateForDisplay } from 'data/actions/utils'; import simpleSelectorFactory from '../utils'; -import { EMAIL_HEADING, TOTAL_COURSE_GRADE_HEADING, USERNAME_HEADING } from '../constants/grades'; import * as module from './grades'; export const getRowsProcessed = ({ @@ -96,18 +97,14 @@ export const headingMapper = (category, label = 'All') => { } else { filter = filters.byLabel; } + const { username, email, totalGrade } = Headings; + const fillerLabels = (entry) => entry.filter(filter).map(s => s.label); - return (entry) => { - if (entry) { - return [ - USERNAME_HEADING, - EMAIL_HEADING, - ...entry.filter(filter).map(s => s.label), - TOTAL_COURSE_GRADE_HEADING, - ]; - } - return []; - }; + return (entry) => ( + entry + ? [username, email, ...fillerLabels(entry), totalGrade] + : [] + ); }; /** @@ -129,6 +126,37 @@ export const transformHistoryEntry = ({ ...rest, }); +/** + * roundGrade(val) + * Takes a number and rounds it to 2 decimal places + * defaults to 0 + * @param {number=0} val - value to round. + * @return {number} - rounded number + */ +export const roundGrade = val => parseFloat((val || 0).toFixed(2)); +export const subsectionGrade = StrictDict({ + /** + * subsectionGrade.absolute(subsection) + * returns rounded {earned}/{possible} if attempted, else ${earned} + * @param {object} subsection - grade subsection entry + * @return {string} - absolute-formatted subsection grade string + */ + [GradeFormats.absolute]: (subsection) => { + const earned = module.roundGrade(subsection.score_earned); + const possible = module.roundGrade(subsection.score_possible); + return subsection.attempted ? `${earned}/${possible}` : `${earned}`; + }, + /** + * subsectionGrade.percent(subsection) + * returns rounded percent times 100 + * @param {object} subsection - grade subsection entry + * @return {string} - percent-formatted subsection grade string + */ + [GradeFormats.percent]: (subsection) => ( + module.roundGrade(subsection.percent * 100) + ), +}); + // Selectors /** * allGrades(state) @@ -236,6 +264,9 @@ export default StrictDict({ hasOverrideErrors, headingMapper, + roundGrade, + subsectionGrade, + ...simpleSelectors, allGrades, bulkManagementHistoryEntries, diff --git a/src/data/selectors/grades.test.js b/src/data/selectors/grades.test.js index c857901..a372031 100644 --- a/src/data/selectors/grades.test.js +++ b/src/data/selectors/grades.test.js @@ -170,6 +170,50 @@ describe('grades selectors', () => { }); }); + describe('roundGrade', () => { + it('rounds values to 2 places', () => { + expect(selectors.roundGrade(23.124)).toEqual(23.12); + }); + it('defaults to 0 if no value is passed', () => { + expect(selectors.roundGrade()).toEqual(0); + }); + }); + + describe('subsectionGrade', () => { + const { roundGrade } = selectors; + beforeEach(() => { + selectors.roundGrade = jest.fn(grade => ({ roundGrade: grade })); + }); + afterEach(() => { + selectors.roundGrade = roundGrade; + }); + describe('absolute', () => { + const subsection = { score_earned: 2, score_possible: 5 }; + describe('attempted', () => { + it('returns rounded {earned}/{possible}', () => { + const earned = selectors.roundGrade(subsection.score_earned); + const possible = selectors.roundGrade(subsection.score_possible); + expect( + selectors.subsectionGrade.absolute({ ...subsection, attempted: true }), + ).toEqual(`${earned}/${possible}`); + }); + }); + describe('not attempted', () => { + it('returns rounded {earned}', () => { + const earned = selectors.roundGrade(subsection.score_earned); + expect(selectors.subsectionGrade.absolute(subsection)).toEqual(`${earned}`); + }); + }); + }); + describe('percent', () => { + it('returns rounded grade.percent * 100', () => { + const percent = 42; + const expected = selectors.roundGrade(percent * 100); + expect(selectors.subsectionGrade.percent({ percent })).toEqual(expected); + }); + }); + }); + // Selectors describe('allGrades', () => { it('returns the grades results from redux state', () => {