refactor: gradebook table tests (#192)
* clean up, test, and docstring Gradebook table * v1.4.36
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
53
src/components/GradesTab/GradebookTable/Fields.test.jsx
Normal file
53
src/components/GradesTab/GradebookTable/Fields.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
118
src/components/GradesTab/GradebookTable/GradeButton.test.jsx
Normal file
118
src/components/GradesTab/GradebookTable/GradeButton.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
162
src/components/GradesTab/GradebookTable/test.jsx
Normal file
162
src/components/GradesTab/GradebookTable/test.jsx
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user