refactor: gradebook table tests (#192)

* clean up, test, and docstring Gradebook table

* v1.4.36
This commit is contained in:
Ben Warzeski
2021-06-09 14:34:15 -04:00
committed by GitHub
parent a836cc1b5b
commit 7acefe0468
16 changed files with 785 additions and 124 deletions

View File

@@ -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",

View File

@@ -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 }) => (
<div>
<span className="wrap-text-in-cell">
<div>
<div>{entry.username}</div>
{entry.external_user_key && <div className="student-key">{entry.external_user_key}</div>}
<div>{username}</div>
{userKey && <div className="student-key">{userKey}</div>}
</div>
</span>
</div>
);
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 }) => (
<span className="wrap-text-in-cell">{entry.email}</span>
/**
* Fields.Email
* Simple label field for email value.
* @param {string} email - email for display
*/
const Email = ({ email }) => (
<span className="wrap-text-in-cell">{email}</span>
);
Email.propTypes = {
entry: PropTypes.shape({
email: PropTypes.string,
}).isRequired,
email: PropTypes.string.isRequired,
};
export default StrictDict({

View File

@@ -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(<Fields.Username {...props} />);
});
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(<Fields.Username username={username} />);
});
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(<Fields.Email email={email} />)).toMatchSnapshot();
});
test('wraps entry email', () => {
expect(shallow(<Fields.Email email={email} />).text()).toEqual(email);
});
});
});

View File

@@ -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 (
<Button
variant="link"
className={classNames(
'btn-header',
'grade-button',
)}
onClick={this.onClick}
>
{this.props.label}
</Button>
);
return this.props.areGradesFrozen
? this.label
: (
<Button
variant="link"
className="btn-header grade-button"
onClick={this.onClick}
>
{this.label}
</Button>
);
}
}
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);

View File

@@ -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(<GradeButton {...{ ...props, areGradesFrozen: true }} />);
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(<GradeButton {...props} />);
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(<GradeButton {...props} />);
expect(
el.instance().label,
).toEqual(selectors.grades.subsectionGrade[props.format](props.subsection));
});
});
describe('onClick', () => {
it('calls props.setModalState with userEntry and subsection', () => {
el = shallow(<GradeButton {...props} />);
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);
});
});
});

View File

@@ -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 (
<div>
<OverlayTrigger
trigger={['hover', 'focus']}
key="left-basic"
placement="left"
overlay={(<Tooltip id="course-grade-tooltip">{totalGradePercentageMessage}</Tooltip>)}
>
<div>
{TOTAL_COURSE_GRADE_HEADING}
<div id="courseGradeTooltipIcon">
<Icon className="fa fa-info-circle" screenReaderText={totalGradePercentageMessage} />
</div>
export const totalGradePercentageMessage = 'Total Grade values are always displayed as a percentage.';
/**
* <TotalGradeLabelReplacement />
* Total Grade column header.
* displays an overlay tooltip with screen-reader text to indicate total grade percentage
*/
const TotalGradeLabelReplacement = () => (
<div>
<OverlayTrigger
trigger={['hover', 'focus']}
key="left-basic"
placement="left"
overlay={(<Tooltip id="course-grade-tooltip">{totalGradePercentageMessage}</Tooltip>)}
>
<div>
{Headings.totalGrade}
<div id="courseGradeTooltipIcon">
<Icon className="fa fa-info-circle" screenReaderText={totalGradePercentageMessage} />
</div>
</OverlayTrigger>
</div>
);
};
</div>
</OverlayTrigger>
</div>
);
/**
* <UsernameLabelReplacement />
* Username column header. Lists that Student Key is possibly available
*/
const UsernameLabelReplacement = () => (
<div>
<div>Username</div>

View File

@@ -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(<TotalGradeLabelReplacement />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('displays overlay tooltip', () => {
expect(el.find(OverlayTrigger).props().overlay).toMatchSnapshot();
});
});
describe('UsernameLabelReplacement', () => {
test('snapshot', () => {
expect(shallow(<UsernameLabelReplacement />)).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,68 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Gradebook Table Fields Email snapshot 1`] = `
<span
className="wrap-text-in-cell"
>
myTag@place.com
</span>
`;
exports[`Gradebook Table Fields Username with external_user_key snapshot 1`] = `
<div>
<span
className="wrap-text-in-cell"
>
<div>
<div>
MyNameFromHere
</div>
<div
className="student-key"
>
My name from another land
</div>
</div>
</span>
</div>
`;
exports[`Gradebook Table Fields Username with external_user_key wraps external user key and username 1`] = `
<div>
<div>
MyNameFromHere
</div>
<div
className="student-key"
>
My name from another land
</div>
</div>
`;
exports[`Gradebook Table Fields Username with external_user_key wraps external user key and username 2`] = `
<div>
<div>
MyNameFromHere
</div>
<div
className="student-key"
>
My name from another land
</div>
</div>
`;
exports[`Gradebook Table Fields Username without external_user_key snapshot 1`] = `
<div>
<span
className="wrap-text-in-cell"
>
<div>
<div>
MyNameFromHere
</div>
</div>
</span>
</div>
`;

View File

@@ -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`] = `
<Button
className="btn-header grade-button"
onClick={[MockFunction this.onClick]}
variant="link"
>
why you gotta label people?
</Button>
`;

View File

@@ -0,0 +1,56 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LabelReplacements TotalGradeLabelReplacement displays overlay tooltip 1`] = `
<Tooltip
id="course-grade-tooltip"
>
Total Grade values are always displayed as a percentage.
</Tooltip>
`;
exports[`LabelReplacements TotalGradeLabelReplacement snapshot 1`] = `
<div>
<OverlayTrigger
key="left-basic"
overlay={
<Tooltip
id="course-grade-tooltip"
>
Total Grade values are always displayed as a percentage.
</Tooltip>
}
placement="left"
trigger={
Array [
"hover",
"focus",
]
}
>
<div>
Total Grade (%)
<div
id="courseGradeTooltipIcon"
>
<Icon
className="fa fa-info-circle"
screenReaderText="Total Grade values are always displayed as a percentage."
/>
</div>
</div>
</OverlayTrigger>
</div>
`;
exports[`LabelReplacements UsernameLabelReplacement snapshot 1`] = `
<div>
<div>
Username
</div>
<div
className="font-weight-normal student-key"
>
Student Key*
</div>
</div>
`;

View File

@@ -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`] = `
<div
className="gradebook-container"
>
<div
className="gbook"
>
<Table
columns={
Array [
Object {
"key": "Username",
"label": <UsernameLabelReplacement />,
},
Object {
"key": "Email",
"label": "Email",
},
Object {
"key": "field1",
"label": "field1",
},
Object {
"key": "field2",
"label": "field2",
},
Object {
"key": "Total Grade (%)",
"label": <TotalGradeLabelReplacement />,
},
]
}
data={
Array [
"mappedRow: 1",
"mappedRow: 2",
"mappedRow: 3",
]
}
hasFixedColumnWidths={true}
rowHeaderColumnKey="username"
/>
</div>
</div>
`;

View File

@@ -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]: <LabelReplacements.TotalGradeLabelReplacement />,
[USERNAME_HEADING]: <LabelReplacements.UsernameLabelReplacement />,
};
const { roundGrade } = selectors.grades;
/**
* <GraebookTable />
* 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]: <LabelReplacements.TotalGradeLabelReplacement />,
[Headings.username]: <LabelReplacements.UsernameLabelReplacement />,
}[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]: (<Fields.Username entry={entry} />),
[EMAIL_HEADING]: (<Fields.Email entry={entry} />),
...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
: (<GradeButton {...{ entry, subsection, label }} />)
mapRows(entry) {
const dataRow = {
[Headings.username]: (
<Fields.Username username={entry.username} userKey={entry.external_user_key} />
),
[Headings.email]: (<Fields.Email email={entry.email} />),
[Headings.totalGrade]: `${roundGrade(entry.percent * 100)}%`,
};
entry.section_breakdown.forEach(subsection => {
dataRow[subsection.label] = (
<GradeButton {...{ entry, subsection }} />
);
},
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
: (<GradeButton {...{ entry, subsection, label }} />)
);
},
});
return dataRow;
}
render() {
@@ -76,8 +59,8 @@ export class GradebookTable extends React.Component {
<div className="gradebook-container">
<div className="gbook">
<Table
columns={this.formatHeadings()}
data={this.data()}
columns={this.props.headings.map(this.mapHeaders)}
data={this.props.grades.map(this.mapRows)}
rowHeaderColumnKey="username"
hasFixedColumnWidths
/>
@@ -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),
});

View File

@@ -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(<GradebookTable {...props} />);
el.instance().mapRows = (entry) => `mappedRow: ${entry.percent}`;
expect(el.instance().render()).toMatchSnapshot();
});
describe('table columns (mapHeaders)', () => {
let headings;
beforeEach(() => {
el = shallow(<GradebookTable {...props} />);
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(<GradebookTable {...props} />);
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));
});
});
});

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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', () => {