refactor: GradesView component modernization

This commit is contained in:
Ben Warzeski
2023-05-11 13:30:35 -04:00
parent 5fcde3b9e8
commit b173681edb
46 changed files with 508 additions and 1681 deletions

View File

@@ -1,37 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button, Icon } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import thunkActions from 'data/thunkActions';
import messages from './FilterMenuToggle.messages';
/**
* Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
* as well as the search box for searching by username/email.
*/
export const FilterMenuToggle = ({ toggleFilterDrawer }) => (
<Button
id="edit-filters-btn"
className="btn-primary align-self-start"
onClick={toggleFilterDrawer}
>
<Icon className="fa fa-filter" /> <FormattedMessage {...messages.editFilters} />
</Button>
);
FilterMenuToggle.propTypes = {
// From Redux
toggleFilterDrawer: PropTypes.func.isRequired,
};
export const mapStateToProps = () => ({});
export const mapDispatchToProps = {
toggleFilterDrawer: thunkActions.app.filterMenu.toggle,
};
export default connect(mapStateToProps, mapDispatchToProps)(FilterMenuToggle);

View File

@@ -1,11 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
editFilters: {
id: 'gradebook.GradesView.editFilterLabel',
defaultMessage: 'Edit Filters',
description: 'A labeled button in the Grades tab that opens/closes the Filters tab, allowing the grades to be filtered',
},
});
export default messages;

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import thunkActions from 'data/thunkActions';
import { FilterMenuToggle, mapDispatchToProps, mapStateToProps } from './FilterMenuToggle';
jest.mock('@edx/paragon', () => ({
Button: () => 'Button',
Icon: () => 'Icon',
}));
jest.mock('data/thunkActions', () => ({
__esModule: true,
default: {
app: {
filterMenu: { toggle: jest.fn() },
},
},
}));
describe('FilterMenuToggle component', () => {
describe('snapshots', () => {
test('basic snapshot', () => {
const toggleFilterDrawer = jest.fn().mockName('this.props.toggleFilterDrawer');
expect(shallow((
<FilterMenuToggle {...{ toggleFilterDrawer }} />
))).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
test('does not connect any selectors', () => {
expect(mapStateToProps({ test: 'state' })).toEqual({});
});
});
describe('mapDispatchToProps', () => {
test('toggleFilterDrawer from thunkActions.app.filterMenu.toggle', () => {
expect(mapDispatchToProps.toggleFilterDrawer).toEqual(
thunkActions.app.filterMenu.toggle,
);
});
});
});

View File

@@ -1,44 +0,0 @@
/* 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 { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
/**
* <FilteredUsersLabel />
* Simple label component displaying the filtered and total users shown
*/
export const FilteredUsersLabel = ({
filteredUsersCount,
totalUsersCount,
}) => {
if (!totalUsersCount) {
return null;
}
const bold = (val) => (<span className="font-weight-bold">{val}</span>);
return (
<FormattedMessage
id="gradebook.GradesTab.usersVisibilityLabel'"
defaultMessage="Showing {filteredUsers} of {totalUsers} total learners"
description="Users visibility label"
values={{
filteredUsers: bold(filteredUsersCount),
totalUsers: bold(totalUsersCount),
}}
/>
);
};
FilteredUsersLabel.propTypes = {
filteredUsersCount: PropTypes.number.isRequired,
totalUsersCount: PropTypes.number.isRequired,
};
export const mapStateToProps = (state) => ({
totalUsersCount: selectors.grades.totalUsersCount(state),
filteredUsersCount: selectors.grades.filteredUsersCount(state),
});
export default connect(mapStateToProps)(FilteredUsersLabel);

View File

@@ -1,46 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import { FilteredUsersLabel, mapStateToProps } from './FilteredUsersLabel';
jest.mock('@edx/paragon', () => ({
Icon: () => 'Icon',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grades: {
filteredUsersCount: state => ({ filteredUsersCount: state }),
totalUsersCount: state => ({ totalUsersCount: state }),
},
},
}));
describe('FilteredUsersLabel', () => {
describe('component', () => {
const props = {
filteredUsersCount: 23,
totalUsersCount: 140,
};
it('does not render if totalUsersCount is falsey', () => {
expect(shallow(<FilteredUsersLabel {...props} totalUsersCount={0} />)).toEqual({});
});
test('snapshot - displays label with number of filtered users out of total', () => {
expect(shallow(<FilteredUsersLabel {...props} />)).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
const testState = { a: 'nice', day: 'for', some: 'rain' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('filteredUsersCount from grades.filteredUsersCount', () => {
expect(mapped.filteredUsersCount).toEqual(selectors.grades.filteredUsersCount(testState));
});
test('totalUsersCount from grades.totalUsersCount', () => {
expect(mapped.totalUsersCount).toEqual(selectors.grades.totalUsersCount(testState));
});
});
});

View File

@@ -29,7 +29,7 @@ testFormData.append('csv', testFile);
const ref = {
current: { click: jest.fn(), files: [testFile], value: 'test-value' },
};
describe('useAssignmentFilterData hook', () => {
describe('useImportButtonData hook', () => {
beforeEach(() => {
jest.clearAllMocks();
React.useRef.mockReturnValue(ref);

View File

@@ -1,72 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Toast } from '@edx/paragon';
import {
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import actions from 'data/actions';
import { views } from 'data/constants/app';
import messages from './ImportSuccessToast.messages';
/**
* <ImportSuccessToast />
* Toast component triggered by successful grade upload.
* Provides a link to view the Bulk Management History tab.
*/
export class ImportSuccessToast extends React.Component {
constructor(props) {
super(props);
this.onClose = this.onClose.bind(this);
this.handleShowHistoryView = this.handleShowHistoryView.bind(this);
}
onClose() {
this.props.setShow(false);
}
handleShowHistoryView() {
this.props.setAppView(views.bulkManagementHistory);
this.onClose();
}
render() {
return (
<Toast
action={{
label: this.props.intl.formatMessage(messages.showHistoryViewBtn),
onClick: this.handleShowHistoryView,
}}
onClose={this.onClose}
show={this.props.show}
>
{this.props.intl.formatMessage(messages.description)}
</Toast>
);
}
}
ImportSuccessToast.propTypes = {
// injected
intl: intlShape.isRequired,
// redux
show: PropTypes.bool.isRequired,
setAppView: PropTypes.func.isRequired,
setShow: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
show: selectors.app.showImportSuccessToast(state),
});
export const mapDispatchToProps = {
setAppView: actions.app.setView,
setShow: actions.app.setShowImportSuccessToast,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ImportSuccessToast));

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
description: {
id: 'gradebook.GradesView.ImportSuccessToast.description',
defaultMessage: 'Import Successful! Grades will be updated momentarily.',
description: 'A message congratulating a successful Import of grades',
},
showHistoryViewBtn: {
id: 'gradebook.GradesView.ImportSuccessToast.showHistoryViewBtn',
defaultMessage: 'View Activity Log',
description: 'The text on a button that loads a view of the Bulk Management Activity Log',
},
});
export default messages;

View File

@@ -1,110 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import actions from 'data/actions';
import { views } from 'data/constants/app';
import {
ImportSuccessToast,
mapStateToProps,
mapDispatchToProps,
} from './ImportSuccessToast';
import messages from './ImportSuccessToast.messages';
jest.mock('@edx/paragon', () => ({
Toast: () => 'Toast',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
showImportSuccessToast: (state) => ({ showImportSuccessToast: state }),
},
},
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
app: {
setView: jest.fn(),
setShow: jest.fn(),
},
},
}));
describe('ImportSuccessToast component', () => {
describe('snapshots', () => {
let el;
let props = {
show: true,
};
beforeEach(() => {
props = {
...props,
intl: { formatMessage: (msg) => msg.defaultMessage },
setAppView: jest.fn(),
setShow: jest.fn(),
};
el = shallow(<ImportSuccessToast {...props} />);
});
test('snapshot', () => {
el.instance().handleShowHistoryView = jest.fn().mockName('handleShowHistoryView');
el.instance().onClose = jest.fn().mockName('onClose');
expect(el).toMatchSnapshot();
});
describe('Toast props', () => {
let toastProps;
beforeEach(() => {
toastProps = el.props();
});
test('action has translated label and onClick from this.handleShowHistoryView', () => {
expect(toastProps.action).toEqual({
label: props.intl.formatMessage(messages.showHistoryViewBtn),
onClick: el.instance().handleShowHistoryView,
});
});
test('onClose from this.onClose method', () => {
expect(toastProps.onClose).toEqual(el.instance().onClose);
});
test('show from show prop', () => {
expect(toastProps.show).toEqual(props.show);
el.setProps({ show: false });
expect(el.props().show).toEqual(false);
});
});
describe('onClose', () => {
it('calls props.setShow(false)', () => {
el.instance().onClose();
expect(props.setShow).toHaveBeenCalledWith(false);
});
});
describe('handleShowHistoryView', () => {
it('calls setAppView with views.bulkManagementHistory and this.onClose', () => {
el.instance().onClose = jest.fn();
el.instance().handleShowHistoryView();
expect(props.setAppView).toHaveBeenCalledWith(views.bulkManagementHistory);
expect(el.instance().onClose).toHaveBeenCalled();
});
});
});
describe('behavior', () => {
});
describe('mapStateToProps', () => {
const testState = { somewhere: 'over', the: 'rainbow' };
const mapped = mapStateToProps(testState);
test('show from app showImportSuccessToast selector', () => {
expect(mapped.show).toEqual(
selectors.app.showImportSuccessToast(testState),
);
});
});
describe('mapDispatchToProps', () => {
test('setAppView from actions.app.setView', () => {
expect(mapDispatchToProps.setAppView).toEqual(actions.app.setView);
});
test('setShow from actions.setShowImportSuccessToast', () => {
expect(mapDispatchToProps.setShow).toEqual(actions.app.setShowImportSuccessToast);
});
});
});

View File

@@ -1,72 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import actions from 'data/actions';
import selectors from 'data/selectors';
import NetworkButton from 'components/NetworkButton';
import messages from './InterventionsReport.messages';
/**
* <InterventionsReport />
* Provides download buttons for Bulk Management and Intervention reports, only if
* showBulkManagement is set in redus.
*/
export class InterventionsReport extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.props.downloadInterventionReport();
window.location.assign(this.props.interventionExportUrl);
}
render() {
return this.props.showBulkManagement && (
<div>
<h4 className="mt-0">
<FormattedMessage {...messages.title} />
</h4>
<div
className="d-flex justify-content-between align-items-center"
>
<div className="intervention-report-description">
<FormattedMessage {...messages.description} />
</div>
<NetworkButton
label={messages.downloadBtn}
onClick={this.handleClick}
/>
</div>
</div>
);
}
}
InterventionsReport.defaultProps = {
showBulkManagement: false,
};
InterventionsReport.propTypes = {
// redux
downloadInterventionReport: PropTypes.func.isRequired,
interventionExportUrl: PropTypes.string.isRequired,
showBulkManagement: PropTypes.bool,
};
export const mapStateToProps = (state) => ({
interventionExportUrl: selectors.root.interventionExportUrl(state),
showBulkManagement: selectors.root.showBulkManagement(state),
});
export const mapDispatchToProps = {
downloadInterventionReport: actions.grades.downloadReport.intervention,
};
export default connect(mapStateToProps, mapDispatchToProps)(InterventionsReport);

View File

@@ -1,21 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'gradebook.GradesView.InterventionsReport.title',
defaultMessage: 'Interventions Report',
description: 'The title for the Intervention report subsection',
},
description: {
id: 'gradebook.GradesView.InterventionsReport.description',
defaultMessage: 'Need to find students who may be falling behind? Download the interventions report to obtain engagement metrics such as section attempts and visits.',
description: 'The description for the Intervention report subsection',
},
downloadBtn: {
id: 'gradebook.GradesView.InterventionsReport.downloadBtn',
defaultMessage: 'Download Interventions',
description: 'The labeled button to download the Intervention report from the Grades View',
},
});
export default messages;

View File

@@ -1,107 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import actions from 'data/actions';
import {
InterventionsReport,
mapStateToProps,
mapDispatchToProps,
} from './InterventionsReport';
jest.mock('@edx/paragon', () => ({
Toast: () => 'Toast',
}));
jest.mock('components/NetworkButton', () => 'NetworkButton');
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
root: {
interventionExportUrl: (state) => ({ interventionExportUrl: state }),
showBulkManagement: (state) => ({ showBulkManagement: state }),
},
},
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
grades: {
downloadReport: { intervention: jest.fn() },
},
},
}));
describe('InterventionsReport component', () => {
let el;
let props = {
interventionExportUrl: 'url.for.exporting.interventions',
showBulkManagement: true,
};
let location;
beforeAll(() => {
location = window.location;
});
beforeEach(() => {
delete window.location;
window.location = Object.defineProperties(
{},
{
...Object.getOwnPropertyDescriptors(location),
assign: { configurable: true, value: jest.fn() },
},
);
props = {
...props,
downloadInterventionReport: jest.fn(),
};
});
afterAll(() => {
window.location = location;
});
describe('snapshots', () => {
beforeEach(() => {
el = shallow(<InterventionsReport {...props} />);
});
test('snapshot', () => {
el.instance().handleClick = jest.fn().mockName('handleClick');
expect(el.instance().render()).toMatchSnapshot();
});
test('returns empty if props.showBulkManagement is false', () => {
el.setProps({ showBulkManagement: false });
expect(el.instance().render()).toEqual(false);
});
});
describe('behavior', () => {
beforeEach(() => {
el = shallow(<InterventionsReport {...props} />);
});
describe('handleClick', () => {
it('calls props.downloadInterventionReport and navigates to props.interventionExportUrl', () => {
el.instance().handleClick();
expect(props.downloadInterventionReport).toHaveBeenCalled();
});
});
});
describe('mapStateToProps', () => {
const testState = { somewhere: 'over', the: 'rainbow' };
const mapped = mapStateToProps(testState);
test('interventionExportUrl from root interventionExportUrl selector', () => {
expect(mapped.interventionExportUrl).toEqual(
selectors.root.interventionExportUrl(testState),
);
});
test('showBulkManagement from root showBulkManagement selector', () => {
expect(mapped.showBulkManagement).toEqual(
selectors.root.showBulkManagement(testState),
);
});
});
describe('mapDispatchToProps', () => {
test('downloadInterventionReport from actions.grades.downloadReport.intervention', () => {
expect(mapDispatchToProps.downloadInterventionReport).toEqual(
actions.grades.downloadReport.intervention,
);
});
});
});

View File

@@ -1,48 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormControl, FormGroup, FormLabel } from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import actions from 'data/actions';
import selectors from 'data/selectors';
import messages from './ScoreViewInput.messages';
/**
* <ScoreViewInput />
* redux-connected select control for grade format (percent vs absolute)
*/
export const ScoreViewInput = ({ format, intl, toggleFormat }) => (
<FormGroup controlId="ScoreView">
<FormLabel><FormattedMessage {...messages.scoreView} />:</FormLabel>
<FormControl
as="select"
value={format}
onChange={toggleFormat}
>
<option value="percent">{intl.formatMessage(messages.percent)}</option>
<option value="absolute">{intl.formatMessage(messages.absolute)}</option>
</FormControl>
</FormGroup>
);
ScoreViewInput.defaultProps = {
format: 'percent',
};
ScoreViewInput.propTypes = {
// injected
intl: intlShape.isRequired,
// redux
format: PropTypes.string,
toggleFormat: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
format: selectors.grades.gradeFormat(state),
});
export const mapDispatchToProps = {
toggleFormat: actions.grades.toggleGradeFormat,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ScoreViewInput));

View File

@@ -1,21 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
scoreView: {
id: 'gradebook.GradesView.scoreViewLabel',
defaultMessage: 'Score View',
description: 'The label for the dropdown list that allows a user to select the Score format',
},
absolute: {
id: 'gradebook.GradesView.absoluteOption',
defaultMessage: 'Absolute',
description: 'A label within the Score Format dropdown list for the Absolute Grade Score option',
},
percent: {
id: 'gradebook.GradesView.percentOption',
defaultMessage: 'Percent',
description: 'A label within the Score Format dropdown list for the Percent Grade Score option',
},
});
export default messages;

View File

@@ -1,60 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import selectors from 'data/selectors';
import {
ScoreViewInput,
mapDispatchToProps,
mapStateToProps,
} from './ScoreViewInput';
jest.mock('@edx/paragon', () => ({
FormControl: () => 'FormControl',
FormGroup: () => 'FormGroup',
FormLabel: () => 'FormLabel',
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
grades: { toggleGradeFormat: jest.fn() },
},
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grades: { gradeFormat: (state) => ({ gradeFormat: state }) },
},
}));
describe('ScoreViewInput', () => {
describe('component', () => {
const props = { format: 'percent' };
let el;
beforeEach(() => {
props.toggleFormat = jest.fn();
props.intl = { formatMessage: (msg) => msg.defaultMessage };
el = shallow(<ScoreViewInput {...props} />);
});
const assertions = [
'select box with percent and absolute options',
'onClick from props.toggleFormat',
];
test(`snapshot - ${assertions.join(' and ')}`, () => {
expect(el).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
test('format from grades.gradeFormat', () => {
const testState = { some: 'state' };
expect(mapStateToProps(testState).format).toEqual(selectors.grades.gradeFormat(testState));
});
});
describe('mapDispatchToProps', () => {
test('toggleFormat from actions.grades.toggleGradeFormat', () => {
expect(mapDispatchToProps.toggleFormat).toEqual(actions.grades.toggleGradeFormat);
});
});
});

View File

@@ -1,75 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { SearchField } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import actions from 'data/actions';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import messages from './SearchControls.messages';
/**
* Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
* as well as the search box for searching by username/email.
*/
export class SearchControls extends React.Component {
constructor(props) {
super(props);
this.onBlur = this.onBlur.bind(this);
this.onClear = this.onClear.bind(this);
this.onSubmit = this.onSubmit.bind(this);
}
onBlur(e) {
this.props.setSearchValue(e.target.value);
}
onClear() {
this.props.setSearchValue('');
this.props.fetchGrades();
}
onSubmit(searchValue) {
this.props.setSearchValue(searchValue);
this.props.fetchGrades();
}
render() {
return (
<div>
<SearchField
onSubmit={this.onSubmit}
inputLabel={<FormattedMessage {...messages.label} />}
onBlur={this.onBlur}
onClear={this.onClear}
value={this.props.searchValue}
/>
<small className="form-text text-muted search-help-text">
<FormattedMessage {...messages.hint} />
</small>
</div>
);
}
}
SearchControls.propTypes = {
// From Redux
fetchGrades: PropTypes.func.isRequired,
searchValue: PropTypes.string.isRequired,
setSearchValue: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
searchValue: selectors.app.searchValue(state),
});
export const mapDispatchToProps = {
fetchGrades: thunkActions.grades.fetchGrades,
setSearchValue: actions.app.setSearchValue,
};
export default connect(mapStateToProps, mapDispatchToProps)(SearchControls);

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
label: {
id: 'gradebook.GradesView.search.label',
defaultMessage: 'Search for a learner',
description: 'Text prompting a user to use this functionality to search for a learner',
},
hint: {
id: 'gradebook.GradesView.search.hint',
defaultMessage: 'Search by username, email, or student key',
description: 'A hint explaining the ways a user can search',
},
});
export default messages;

View File

@@ -1,119 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import {
mapDispatchToProps,
mapStateToProps,
SearchControls,
} from './SearchControls';
jest.mock('@edx/paragon', () => ({
Icon: 'Icon',
Button: 'Button',
SearchField: 'SearchField',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
searchValue: jest.fn((state) => ({ searchValue: state })),
},
},
}));
jest.mock('data/thunkActions', () => ({
__esModule: true,
default: {
grades: {
fetchGrades: jest.fn().mockName('thunkActions.grades.fetchGrades'),
},
app: {
filterMenu: { toggle: jest.fn().mockName('thunkActions.app.filterMenu') },
},
},
}));
describe('SearchControls', () => {
let props;
beforeEach(() => {
jest.resetAllMocks();
props = {
searchValue: 'alice',
setSearchValue: jest.fn(),
fetchGrades: jest.fn().mockName('fetchGrades'),
};
});
const searchControls = (overriddenProps) => {
props = { ...props, ...overriddenProps };
return shallow(<SearchControls {...props} />);
};
describe('Component', () => {
describe('Snapshots', () => {
test('basic snapshot', () => {
const wrapper = searchControls();
wrapper.instance().onBlur = jest.fn().mockName('onBlur');
wrapper.instance().onClear = jest.fn().mockName('onClear');
wrapper.instance().onSubmit = jest.fn().mockName('onSubmit');
expect(wrapper.instance().render()).toMatchSnapshot();
});
});
describe('Behavior', () => {
describe('onBlur', () => {
it('saves the search value to Gradebook state but do not fetch grade', () => {
const wrapper = searchControls();
const event = {
target: {
value: 'bob',
},
};
wrapper.instance().onBlur(event);
expect(props.setSearchValue).toHaveBeenCalledWith('bob');
expect(props.fetchGrades).not.toHaveBeenCalled();
});
});
describe('onClear', () => {
it('sets search value to empty string and calls fetchGrades', () => {
const wrapper = searchControls();
wrapper.instance().onClear();
expect(props.setSearchValue).toHaveBeenCalledWith('');
expect(props.fetchGrades).toHaveBeenCalled();
});
});
describe('onSubmit', () => {
it('sets search value to input and calls fetchGrades', () => {
const wrapper = searchControls();
wrapper.instance().onSubmit('John');
expect(props.setSearchValue).toHaveBeenCalledWith('John');
expect(props.fetchGrades).toHaveBeenCalled();
});
});
});
describe('mapStateToProps', () => {
const testState = { never: 'gonna', give: 'you up' };
test('searchValue from app.searchValue', () => {
expect(
mapStateToProps(testState).searchValue,
).toEqual(selectors.app.searchValue(testState));
});
});
describe('mapDispatchToProps', () => {
test('fetchGrades from thunkActions.grades.fetchGrades', () => {
expect(mapDispatchToProps.fetchGrades).toEqual(thunkActions.grades.fetchGrades);
});
test('setSearchValue from actions.app.setSearchValue', () => {
expect(mapDispatchToProps.setSearchValue).toEqual(actions.app.setSearchValue);
});
});
});
});

View File

@@ -1,31 +1,22 @@
/* 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 { Icon } from '@edx/paragon';
import selectors from 'data/selectors';
import { selectors } from 'data/redux/hooks';
/**
* <SpinnerIcon />
* Simmple redux-connected icon component that shows a spinner overlay only if
* redux state says it should.
*/
export const SpinnerIcon = ({ show }) => show && (
<div className="spinner-overlay">
<Icon className="fa fa-spinner fa-spin fa-5x color-black" />
</div>
);
SpinnerIcon.defaultProps = {
show: false,
};
SpinnerIcon.propTypes = {
show: PropTypes.bool,
export const SpinnerIcon = () => {
const show = selectors.root.useShouldShowSpinner();
return show && (
<div className="spinner-overlay">
<Icon className="fa fa-spinner fa-spin fa-5x color-black" />
</div>
);
};
SpinnerIcon.propTypes = {};
export const mapStateToProps = (state) => ({
show: selectors.root.shouldShowSpinner(state),
});
export default connect(mapStateToProps)(SpinnerIcon);
export default SpinnerIcon;

View File

@@ -1,32 +1,35 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import { SpinnerIcon, mapStateToProps } from './SpinnerIcon';
import { selectors } from 'data/redux/hooks';
import SpinnerIcon from './SpinnerIcon';
jest.mock('@edx/paragon', () => ({
Icon: () => 'Icon',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
root: { shouldShowSpinner: state => ({ shouldShowSpinner: state }) },
jest.mock('data/redux/hooks', () => ({
selectors: {
root: { useShouldShowSpinner: jest.fn() },
},
}));
selectors.root.useShouldShowSpinner.mockReturnValue(true);
let el;
describe('SpinnerIcon', () => {
describe('component', () => {
it('snapshot - does not render if show: false', () => {
expect(shallow(<SpinnerIcon />)).toMatchSnapshot();
});
test('snapshot - displays spinner overlay with spinner icon', () => {
expect(shallow(<SpinnerIcon show />)).toMatchSnapshot();
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<SpinnerIcon />);
});
describe('behavior', () => {
it('initializes redux hook', () => {
expect(selectors.root.useShouldShowSpinner).toHaveBeenCalled();
});
});
describe('mapStateToProps', () => {
const testState = { a: 'nice', day: 'for', some: 'sun' };
test('show from root.shouldShowSpinner', () => {
expect(mapStateToProps(testState).show).toEqual(selectors.root.shouldShowSpinner(testState));
describe('component', () => {
it('does not render if show: false', () => {
selectors.root.useShouldShowSpinner.mockReturnValueOnce(false);
el = shallow(<SpinnerIcon />);
expect(el.isEmptyRender()).toEqual(true);
});
test('snapshot - displays spinner overlay with spinner icon', () => {
expect(el).toMatchSnapshot();
});
});
});

View File

@@ -1,84 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Alert } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import actions from 'data/actions';
import messages from './StatusAlerts.messages';
export class StatusAlerts extends React.Component {
get isCourseGradeFilterAlertOpen() {
return (
!this.props.limitValidity.isMinValid
|| !this.props.limitValidity.isMaxValid
);
}
get minValidityMessage() {
return (this.props.limitValidity.isMinValid)
? ''
: <FormattedMessage {...messages.minGradeInvalid} />;
}
get maxValidityMessage() {
return (this.props.limitValidity.isMaxValid)
? ''
: <FormattedMessage {...messages.maxGradeInvalid} />;
}
get courseGradeFilterAlertDialogText() {
return (
<>
{this.minValidityMessage}{this.maxValidityMessage}
</>
);
}
render() {
return (
<>
<Alert
variant="success"
onClose={this.props.handleCloseSuccessBanner}
show={this.props.showSuccessBanner}
>
<FormattedMessage {...messages.editSuccessAlert} />
</Alert>
<Alert
variant="danger"
dismissible={false}
show={this.isCourseGradeFilterAlertOpen}
>
{this.courseGradeFilterAlertDialogText}
</Alert>
</>
);
}
}
StatusAlerts.defaultProps = {
};
StatusAlerts.propTypes = {
// redux
handleCloseSuccessBanner: PropTypes.func.isRequired,
limitValidity: PropTypes.shape({
isMaxValid: PropTypes.bool,
isMinValid: PropTypes.bool,
}).isRequired,
showSuccessBanner: PropTypes.bool.isRequired,
};
export const mapStateToProps = (state) => ({
limitValidity: selectors.app.courseGradeFilterValidity(state),
showSuccessBanner: selectors.grades.showSuccess(state),
});
export const mapDispatchToProps = {
handleCloseSuccessBanner: actions.grades.banner.close,
};
export default connect(mapStateToProps, mapDispatchToProps)(StatusAlerts);

View File

@@ -1,123 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import actions from 'data/actions';
import selectors from 'data/selectors';
import messages from './StatusAlerts.messages';
import {
StatusAlerts,
mapDispatchToProps,
mapStateToProps,
} from './StatusAlerts';
jest.mock('@edx/paragon', () => ({
Alert: 'Alert',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
courseGradeFilterValidity: (state) => ({ courseGradeFilterValidity: state }),
},
grades: {
showSuccess: (state) => ({ showSuccess: state }),
},
},
}));
describe('StatusAlerts', () => {
let props = {
showSuccessBanner: true,
limitValidity: {
isMaxValid: true,
isMinValid: true,
},
};
beforeEach(() => {
props = {
...props,
handleCloseSuccessBanner: jest.fn().mockName('handleCloseSuccessBanner'),
};
});
describe('snapshots', () => {
let el;
it('basic snapshot', () => {
el = shallow(<StatusAlerts {...props} />);
const courseGradeFilterAlertDialogText = 'the quiCk brown does somEthing or other';
jest.spyOn(
el.instance(),
'courseGradeFilterAlertDialogText',
'get',
).mockReturnValue(courseGradeFilterAlertDialogText);
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('behavior', () => {
it.each([
[false, false],
[false, true],
[true, false],
[true, true],
])('min + max course grade validity', (isMinValid, isMaxValid) => {
props = {
...props,
limitValidity: {
isMinValid,
isMaxValid,
},
};
const el = shallow(<StatusAlerts {...props} />);
expect(
el.instance().isCourseGradeFilterAlertOpen,
).toEqual(
!isMinValid || !isMaxValid,
);
if (!isMaxValid) {
if (!isMinValid) {
expect(el.instance().courseGradeFilterAlertDialogText).toEqual(
<>
<FormattedMessage {...messages.minGradeInvalid} />
<FormattedMessage {...messages.maxGradeInvalid} />
</>,
);
} else {
expect(
el.instance().courseGradeFilterAlertDialogText,
// eslint-disable-next-line react/jsx-curly-brace-presence
).toEqual(<>{''}<FormattedMessage {...messages.maxGradeInvalid} /></>);
}
} else if (!isMinValid) {
expect(
el.instance().courseGradeFilterAlertDialogText,
// eslint-disable-next-line react/jsx-curly-brace-presence
).toEqual(<><FormattedMessage {...messages.minGradeInvalid} />{''}</>);
}
});
});
describe('mapStateToProps', () => {
const testState = { A: 'pple', B: 'anana', C: 'ucumber' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('limitValidity from app.courseGradeFitlerValidity', () => {
expect(mapped.limitValidity).toEqual(selectors.app.courseGradeFilterValidity(testState));
});
test('showSuccessBanner from grades.showSuccess', () => {
expect(mapped.showSuccessBanner).toEqual(selectors.grades.showSuccess(testState));
});
});
describe('mapDispatchToProps', () => {
test('handleCloseSuccessBanner from actions.grades.banner.close', () => {
expect(
mapDispatchToProps.handleCloseSuccessBanner,
).toEqual(actions.grades.banner.close);
});
});
});

View File

@@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StatusAlerts component render snapshot 1`] = `
<Fragment>
<Alert
onClose={[MockFunction hooks.successBanner.onClose]}
show="hooks.show-success-banner"
variant="success"
>
hooks.success-banner-text
</Alert>
<Alert
dismissible={false}
show="hooks.show-grade-filter"
variant="danger"
>
hooks.grade-filter-text
</Alert>
</Fragment>
`;

View File

@@ -0,0 +1,32 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { actions, selectors } from 'data/redux/hooks';
import messages from './messages';
export const useStatusAlertsData = () => {
const { formatMessage } = useIntl();
const limitValidity = selectors.app.useCourseGradeFilterValidity();
const showSuccessBanner = selectors.grades.useShowSuccess();
const handleCloseSuccessBanner = actions.grades.useCloseBanner();
const isCourseGradeFilterAlertOpen = !limitValidity.isMinValid || !limitValidity.isMaxValid;
const validityMessages = {
min: limitValidity.isMinValid ? '' : formatMessage(messages.minGradeInvalid),
max: limitValidity.isMaxValid ? '' : formatMessage(messages.maxGradeInvalid),
};
return {
successBanner: {
onClose: handleCloseSuccessBanner,
show: showSuccessBanner,
text: formatMessage(messages.editSuccessAlert),
},
gradeFilter: {
show: isCourseGradeFilterAlertOpen,
text: `${validityMessages.min}${validityMessages.max}`,
},
};
};
export default useStatusAlertsData;

View File

@@ -0,0 +1,110 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { actions, selectors } from 'data/redux/hooks';
import useStatusAlertsData from './hooks';
import messages from './messages';
jest.mock('data/redux/hooks', () => ({
actions: {
grades: { useCloseBanner: jest.fn() },
},
selectors: {
app: { useCourseGradeFilterValidity: jest.fn() },
grades: { useShowSuccess: jest.fn() },
},
}));
const validity = {
isMinValid: true,
isMaxValid: true,
};
selectors.app.useCourseGradeFilterValidity.mockReturnValue(validity);
const showSuccess = 'test-show-success';
selectors.grades.useShowSuccess.mockReturnValue(showSuccess);
const closeBanner = jest.fn().mockName('hooks.closeBanner');
actions.grades.useCloseBanner.mockReturnValue(closeBanner);
let out;
describe('useStatusAlertsData', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useStatusAlertsData();
});
describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes redux hooks', () => {
expect(actions.grades.useCloseBanner).toHaveBeenCalled();
expect(selectors.app.useCourseGradeFilterValidity).toHaveBeenCalled();
expect(selectors.grades.useShowSuccess).toHaveBeenCalled();
});
});
describe('output', () => {
describe('successBanner', () => {
test('onClose and show from redux', () => {
expect(out.successBanner.onClose).toEqual(closeBanner);
expect(out.successBanner.show).toEqual(showSuccess);
});
test('message', () => {
expect(out.successBanner.text).toEqual(formatMessage(messages.editSuccessAlert));
});
});
describe('gradeFilter', () => {
describe('both filters are valid', () => {
test('do not show', () => {
expect(out.gradeFilter.show).toEqual(false);
});
});
describe('min filter is invalid', () => {
beforeEach(() => {
selectors.app.useCourseGradeFilterValidity.mockReturnValue({
isMinValid: false,
isMaxValid: true,
});
out = useStatusAlertsData();
});
test('show grade filter banner', () => {
expect(out.gradeFilter.show).toEqual(true);
});
test('filter message', () => {
expect(out.gradeFilter.text).toEqual(formatMessage(messages.minGradeInvalid));
});
});
describe('max filter is invalid', () => {
beforeEach(() => {
selectors.app.useCourseGradeFilterValidity.mockReturnValue({
isMinValid: true,
isMaxValid: false,
});
out = useStatusAlertsData();
});
test('show grade filter banner', () => {
expect(out.gradeFilter.show).toEqual(true);
});
test('filter message', () => {
expect(out.gradeFilter.text).toEqual(formatMessage(messages.maxGradeInvalid));
});
});
describe('both filters are invalid', () => {
beforeEach(() => {
selectors.app.useCourseGradeFilterValidity.mockReturnValue({
isMinValid: false,
isMaxValid: false,
});
out = useStatusAlertsData();
});
test('show grade filter banner', () => {
expect(out.gradeFilter.show).toEqual(true);
});
test('filter message', () => {
expect(out.gradeFilter.text).toEqual(
`${formatMessage(messages.minGradeInvalid)}${formatMessage(messages.maxGradeInvalid)}`,
);
});
});
});
});
});

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Alert } from '@edx/paragon';
import useStatusAlertsData from './hooks';
export const StatusAlerts = () => {
const {
successBanner,
gradeFilter,
} = useStatusAlertsData();
return (
<>
<Alert
variant="success"
onClose={successBanner.onClose}
show={successBanner.show}
>
{successBanner.text}
</Alert>
<Alert
variant="danger"
dismissible={false}
show={gradeFilter.show}
>
{gradeFilter.text}
</Alert>
</>
);
};
StatusAlerts.propTypes = {};
export default StatusAlerts;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Alert } from '@edx/paragon';
import useStatusAlertsData from './hooks';
import StatusAlerts from '.';
jest.mock('./hooks', () => jest.fn());
const hookProps = {
successBanner: {
onClose: jest.fn().mockName('hooks.successBanner.onClose'),
show: 'hooks.show-success-banner',
text: 'hooks.success-banner-text',
},
gradeFilter: {
show: 'hooks.show-grade-filter',
text: 'hooks.grade-filter-text',
},
};
useStatusAlertsData.mockReturnValue(hookProps);
let el;
describe('StatusAlerts component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<StatusAlerts />);
});
describe('behavior', () => {
it('initializes component hooks', () => {
expect(useStatusAlertsData).toHaveBeenCalled();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('success banner', () => {
const alert = el.find(Alert).at(0);
const props = alert.props();
expect(props.onClose).toEqual(hookProps.successBanner.onClose);
expect(props.show).toEqual(hookProps.successBanner.show);
expect(alert.text()).toEqual(hookProps.successBanner.text);
});
test('grade filter banner', () => {
const alert = el.find(Alert).at(1);
const props = alert.props();
expect(props.show).toEqual(hookProps.gradeFilter.show);
expect(alert.text()).toEqual(hookProps.gradeFilter.text);
});
});
});

View File

@@ -1,44 +0,0 @@
/* 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 { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
/**
* <UsersLabel />
* Simple label component displaying the filtered and total users shown
*/
export const UsersLabel = ({
filteredUsersCount,
totalUsersCount,
}) => {
if (!totalUsersCount) {
return null;
}
const bold = (val) => (<span className="font-weight-bold">{val}</span>);
return (
<FormattedMessage
id="gradebook.GradesTab.usersVisibilityLabel'"
defaultMessage="Showing {filteredUsers} of {totalUsers} total learners"
description="Users visibility label"
values={{
filteredUsers: bold(filteredUsersCount),
totalUsers: bold(totalUsersCount),
}}
/>
);
};
UsersLabel.propTypes = {
filteredUsersCount: PropTypes.number.isRequired,
totalUsersCount: PropTypes.number.isRequired,
};
export const mapStateToProps = (state) => ({
totalUsersCount: selectors.grades.totalUsersCount(state),
filteredUsersCount: selectors.grades.filteredUsersCount(state),
});
export default connect(mapStateToProps)(UsersLabel);

View File

@@ -1,46 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import { UsersLabel, mapStateToProps } from './UsersLabel';
jest.mock('@edx/paragon', () => ({
Icon: () => 'Icon',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grades: {
filteredUsersCount: state => ({ filteredUsersCount: state }),
totalUsersCount: state => ({ totalUsersCount: state }),
},
},
}));
describe('UsersLabel', () => {
describe('component', () => {
const props = {
filteredUsersCount: 23,
totalUsersCount: 140,
};
it('does not render if totalUsersCount is falsey', () => {
expect(shallow(<UsersLabel {...props} totalUsersCount={0} />)).toEqual({});
});
test('snapshot - displays label with number of filtered users out of total', () => {
expect(shallow(<UsersLabel {...props} />)).toMatchSnapshot();
});
});
describe('mapStateToProps', () => {
const testState = { a: 'nice', day: 'for', some: 'rain' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('filteredUsersCount from grades.filteredUsersCount', () => {
expect(mapped.filteredUsersCount).toEqual(selectors.grades.filteredUsersCount(testState));
});
test('totalUsersCount from grades.totalUsersCount', () => {
expect(mapped.totalUsersCount).toEqual(selectors.grades.totalUsersCount(testState));
});
});
});

View File

@@ -1,19 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilterMenuToggle component snapshots basic snapshot 1`] = `
<Button
className="btn-primary align-self-start"
id="edit-filters-btn"
onClick={[MockFunction this.props.toggleFilterDrawer]}
>
<Icon
className="fa fa-filter"
/>
<FormattedMessage
defaultMessage="Edit Filters"
description="A labeled button in the Grades tab that opens/closes the Filters tab, allowing the grades to be filtered"
id="gradebook.GradesView.editFilterLabel"
/>
</Button>
`;

View File

@@ -1,23 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilteredUsersLabel component snapshot - displays label with number of filtered users out of total 1`] = `
<FormattedMessage
defaultMessage="Showing {filteredUsers} of {totalUsers} total learners"
description="Users visibility label"
id="gradebook.GradesTab.usersVisibilityLabel'"
values={
Object {
"filteredUsers": <span
className="font-weight-bold"
>
23
</span>,
"totalUsers": <span
className="font-weight-bold"
>
140
</span>,
}
}
/>
`;

View File

@@ -1,16 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImportSuccessToast component snapshots snapshot 1`] = `
<Toast
action={
Object {
"label": "View Activity Log",
"onClick": [Function],
}
}
onClose={[Function]}
show={true}
>
Import Successful! Grades will be updated momentarily.
</Toast>
`;

View File

@@ -1,38 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InterventionsReport component snapshots snapshot 1`] = `
<div>
<h4
className="mt-0"
>
<FormattedMessage
defaultMessage="Interventions Report"
description="The title for the Intervention report subsection"
id="gradebook.GradesView.InterventionsReport.title"
/>
</h4>
<div
className="d-flex justify-content-between align-items-center"
>
<div
className="intervention-report-description"
>
<FormattedMessage
defaultMessage="Need to find students who may be falling behind? Download the interventions report to obtain engagement metrics such as section attempts and visits."
description="The description for the Intervention report subsection"
id="gradebook.GradesView.InterventionsReport.description"
/>
</div>
<NetworkButton
label={
Object {
"defaultMessage": "Download Interventions",
"description": "The labeled button to download the Intervention report from the Grades View",
"id": "gradebook.GradesView.InterventionsReport.downloadBtn",
}
}
onClick={[MockFunction handleClick]}
/>
</div>
</div>
`;

View File

@@ -1,32 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ScoreViewInput component snapshot - select box with percent and absolute options and onClick from props.toggleFormat 1`] = `
<FormGroup
controlId="ScoreView"
>
<FormLabel>
<FormattedMessage
defaultMessage="Score View"
description="The label for the dropdown list that allows a user to select the Score format"
id="gradebook.GradesView.scoreViewLabel"
/>
:
</FormLabel>
<FormControl
as="select"
onChange={[MockFunction]}
value="percent"
>
<option
value="percent"
>
Percent
</option>
<option
value="absolute"
>
Absolute
</option>
</FormControl>
</FormGroup>
`;

View File

@@ -1,28 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SearchControls Component Snapshots basic snapshot 1`] = `
<div>
<SearchField
inputLabel={
<FormattedMessage
defaultMessage="Search for a learner"
description="Text prompting a user to use this functionality to search for a learner"
id="gradebook.GradesView.search.label"
/>
}
onBlur={[MockFunction onBlur]}
onClear={[MockFunction onClear]}
onSubmit={[MockFunction onSubmit]}
value="alice"
/>
<small
className="form-text text-muted search-help-text"
>
<FormattedMessage
defaultMessage="Search by username, email, or student key"
description="A hint explaining the ways a user can search"
id="gradebook.GradesView.search.hint"
/>
</small>
</div>
`;

View File

@@ -9,5 +9,3 @@ exports[`SpinnerIcon component snapshot - displays spinner overlay with spinner
/>
</div>
`;
exports[`SpinnerIcon component snapshot - does not render if show: false 1`] = `""`;

View File

@@ -1,24 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StatusAlerts snapshots basic snapshot 1`] = `
<React.Fragment>
<Alert
onClose={[MockFunction handleCloseSuccessBanner]}
show={true}
variant="success"
>
<FormattedMessage
defaultMessage="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
description="An alert text for successfully editing a grade"
id="gradebook.GradesView.editSuccessAlert"
/>
</Alert>
<Alert
dismissible={false}
show={false}
variant="danger"
>
the quiCk brown does somEthing or other
</Alert>
</React.Fragment>
`;

View File

@@ -1,23 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UsersLabel component snapshot - displays label with number of filtered users out of total 1`] = `
<FormattedMessage
defaultMessage="Showing {filteredUsers} of {totalUsers} total learners"
description="Users visibility label"
id="gradebook.GradesTab.usersVisibilityLabel'"
values={
Object {
"filteredUsers": <span
className="font-weight-bold"
>
23
</span>,
"totalUsers": <span
className="font-weight-bold"
>
140
</span>,
}
}
/>
`;

View File

@@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradesView component render snapshot 1`] = `
<Fragment>
<SpinnerIcon />
<InterventionsReport />
<h3
className="step-message-1"
>
filter-step-heading
</h3>
<div
className="d-flex justify-content-between"
>
<FilterMenuToggle />
<SearchControls />
</div>
<FilterBadges
handleClose={[MockFunction hooks.handleFilterBadgeClose]}
/>
<StatusAlerts />
<h3>
gradebook-step-heading
</h3>
<div
className="d-flex justify-content-between align-items-center mb-2"
>
<ScoreViewInput />
<BulkManagementControls />
</div>
<FilteredUsersLabel />
<GradebookTable />
<PageButtons />
<p>
*
test-masters-hint
</p>
<EditModal />
<ImportSuccessToast />
</Fragment>
`;

View File

@@ -1,53 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradesView Component snapshots basic snapshot 1`] = `
<React.Fragment>
<SpinnerIcon />
<InterventionsReport />
<h3
className="step-message-1"
>
<FormattedMessage
defaultMessage="Step 1: Filter the Grade Report"
description="Filter controls container heading string"
id="gradebook.GradesView.filterHeading"
/>
</h3>
<div
className="d-flex justify-content-between"
>
<FilterMenuToggle />
<SearchControls />
</div>
<FilterBadges
handleClose={[MockFunction this.handleFilterBadgeClose]}
/>
<StatusAlerts />
<h3>
<FormattedMessage
defaultMessage="Step 2: View or Modify Individual Grades"
description="Alert text for invalid minimum course grade"
id="gradebook.GradesView.gradebookStepHeading"
/>
</h3>
<div
className="d-flex justify-content-between align-items-center mb-2"
>
<ScoreViewInput />
<BulkManagementControls />
</div>
<FilteredUsersLabel />
<GradebookTable />
<PageButtons />
<p>
*
<FormattedMessage
defaultMessage="available for learners in the Master's track only"
description="Masters feature availability hint on Grades Tab"
id="gradebook.GradesView.mastersHint"
/>
</p>
<EditModal />
<ImportSuccessToast />
</React.Fragment>
`;

View File

@@ -0,0 +1,30 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { actions, thunkActions } from 'data/redux/hooks';
import messages from './messages';
export const useGradesViewData = ({ updateQueryParams }) => {
const { formatMessage } = useIntl();
const fetchGrades = thunkActions.grades.useFetchGrades();
const resetFilters = actions.filters.useResetFilters();
const handleFilterBadgeClose = (filterNames) => () => {
resetFilters(filterNames);
updateQueryParams(filterNames.reduce(
(obj, filterName) => ({ ...obj, [filterName]: false }),
{},
));
fetchGrades();
};
return {
stepHeadings: {
filter: formatMessage(messages.filterStepHeading),
gradebook: formatMessage(messages.gradebookStepHeading),
},
handleFilterBadgeClose,
mastersHint: formatMessage(messages.mastersHint),
};
};
export default useGradesViewData;

View File

@@ -0,0 +1,62 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { actions, thunkActions } from 'data/redux/hooks';
import useGradesViewData from './hooks';
import messages from './messages';
jest.mock('data/redux/hooks', () => ({
actions: {
filters: { useResetFilters: jest.fn() },
},
thunkActions: {
grades: { useFetchGrades: jest.fn() },
},
}));
const fetchGrades = jest.fn();
thunkActions.grades.useFetchGrades.mockReturnValue(fetchGrades);
const resetFilters = jest.fn();
actions.filters.useResetFilters.mockReturnValue(resetFilters);
const updateQueryParams = jest.fn();
let out;
describe('useGradesViewData', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useGradesViewData({ updateQueryParams });
});
describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes redux hooks', () => {
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalled();
expect(actions.filters.useResetFilters).toHaveBeenCalled();
});
});
describe('output', () => {
test('stepHeadings', () => {
expect(out.stepHeadings.filter).toEqual(formatMessage(messages.filterStepHeading));
expect(out.stepHeadings.gradebook).toEqual(formatMessage(messages.gradebookStepHeading));
});
test('mastersHint', () => {
expect(out.mastersHint).toEqual(formatMessage(messages.mastersHint));
});
describe('handleFilterBadgeClose', () => {
it('resets filters locally and in query params, and fetches grades', () => {
const filters = ['some', 'filter', 'names'];
out.handleFilterBadgeClose(filters)();
expect(resetFilters).toHaveBeenCalledWith(filters);
expect(updateQueryParams).toHaveBeenCalledWith({
some: false,
filter: false,
names: false,
});
expect(fetchGrades).toHaveBeenCalled();
});
});
});
});

View File

@@ -1,12 +1,6 @@
/* 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 { FormattedMessage } from '@edx/frontend-platform/i18n';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import BulkManagementControls from './BulkManagementControls';
import EditModal from './EditModal';
@@ -21,79 +15,55 @@ import ScoreViewInput from './ScoreViewInput';
import SearchControls from './SearchControls';
import SpinnerIcon from './SpinnerIcon';
import StatusAlerts from './StatusAlerts';
import messages from './messages';
export class GradesView extends React.Component {
constructor(props) {
super(props);
this.handleFilterBadgeClose = this.handleFilterBadgeClose.bind(this);
}
import useGradesViewData from './hooks';
handleFilterBadgeClose(filterNames) {
return () => {
this.props.resetFilters(filterNames);
this.props.updateQueryParams(filterNames.reduce(
(obj, filterName) => ({ ...obj, [filterName]: false }),
{},
));
this.props.fetchGrades();
};
}
export const GradesView = ({ updateQueryParams }) => {
const {
stepHeadings,
handleFilterBadgeClose,
mastersHint,
} = useGradesViewData({ updateQueryParams });
render() {
return (
<>
<SpinnerIcon />
return (
<>
<SpinnerIcon />
<InterventionsReport />
<h3 className="step-message-1">
<FormattedMessage {...messages.filterStepHeading} />
</h3>
<InterventionsReport />
<h3 className="step-message-1">
{stepHeadings.filter}
</h3>
<div className="d-flex justify-content-between">
<FilterMenuToggle />
<SearchControls />
</div>
<div className="d-flex justify-content-between">
<FilterMenuToggle />
<SearchControls />
</div>
<FilterBadges handleClose={this.handleFilterBadgeClose} />
<StatusAlerts />
<FilterBadges handleClose={handleFilterBadgeClose} />
<StatusAlerts />
<h3><FormattedMessage {...messages.gradebookStepHeading} /></h3>
<h3>{stepHeadings.gradebook}</h3>
<div className="d-flex justify-content-between align-items-center mb-2">
<ScoreViewInput />
<BulkManagementControls />
</div>
<div className="d-flex justify-content-between align-items-center mb-2">
<ScoreViewInput />
<BulkManagementControls />
</div>
<FilteredUsersLabel />
<FilteredUsersLabel />
<GradebookTable />
<GradebookTable />
<PageButtons />
<p>* <FormattedMessage {...messages.mastersHint} /></p>
<EditModal />
<PageButtons />
<p>* {mastersHint}</p>
<EditModal />
<ImportSuccessToast />
</>
);
}
}
GradesView.defaultProps = {};
<ImportSuccessToast />
</>
);
};
GradesView.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// redux
fetchGrades: PropTypes.func.isRequired,
resetFilters: PropTypes.func.isRequired,
};
export const mapStateToProps = () => ({});
export const mapDispatchToProps = {
fetchGrades: thunkActions.grades.fetchGrades,
resetFilters: actions.filters.reset,
};
export default connect(mapStateToProps, mapDispatchToProps)(GradesView);
export default GradesView;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { shallow } from 'enzyme';
import FilterBadges from './FilterBadges';
import useGradesViewData from './hooks';
import GradesView from '.';
jest.mock('./BulkManagementControls', () => 'BulkManagementControls');
jest.mock('./EditModal', () => 'EditModal');
jest.mock('./FilterBadges', () => 'FilterBadges');
jest.mock('./FilteredUsersLabel', () => 'FilteredUsersLabel');
jest.mock('./FilterMenuToggle', () => 'FilterMenuToggle');
jest.mock('./GradebookTable', () => 'GradebookTable');
jest.mock('./ImportSuccessToast', () => 'ImportSuccessToast');
jest.mock('./InterventionsReport', () => 'InterventionsReport');
jest.mock('./PageButtons', () => 'PageButtons');
jest.mock('./ScoreViewInput', () => 'ScoreViewInput');
jest.mock('./SearchControls', () => 'SearchControls');
jest.mock('./SpinnerIcon', () => 'SpinnerIcon');
jest.mock('./StatusAlerts', () => 'StatusAlerts');
jest.mock('./hooks', () => jest.fn());
const hookProps = {
stepHeadings: {
filter: 'filter-step-heading',
gradebook: 'gradebook-step-heading',
},
handleFilterBadgeClose: jest.fn().mockName('hooks.handleFilterBadgeClose'),
mastersHint: 'test-masters-hint',
};
useGradesViewData.mockReturnValue(hookProps);
const updateQueryParams = jest.fn().mockName('props.updateQueryParams');
let el;
describe('GradesView component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<GradesView updateQueryParams={updateQueryParams} />);
});
describe('behavior', () => {
it('initializes component hooks', () => {
expect(useGradesViewData).toHaveBeenCalled();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('filterBadges load close behavior from hook', () => {
expect(el.find(FilterBadges).props().handleClose).toEqual(
hookProps.handleFilterBadgeClose,
);
});
});
});

View File

@@ -1,105 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import {
GradesView,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('data/actions', () => ({
__esModule: true,
default: {
app: { setView: jest.fn() },
filters: { resetFilters: jest.fn() },
},
}));
jest.mock('data/thunkActions', () => ({
__esModule: true,
default: {
grades: { fetchGrades: jest.fn() },
},
}));
jest.mock('./BulkManagementControls', () => 'BulkManagementControls');
jest.mock('./EditModal', () => 'EditModal');
jest.mock('./FilterBadges', () => 'FilterBadges');
jest.mock('./FilteredUsersLabel', () => 'FilteredUsersLabel');
jest.mock('./FilterMenuToggle', () => 'FilterMenuToggle');
jest.mock('./GradebookTable', () => 'GradebookTable');
jest.mock('./ImportSuccessToast', () => 'ImportSuccessToast');
jest.mock('./InterventionsReport', () => 'InterventionsReport');
jest.mock('./PageButtons', () => 'PageButtons');
jest.mock('./ScoreViewInput', () => 'ScoreViewInput');
jest.mock('./SearchControls', () => 'SearchControls');
jest.mock('./SpinnerIcon', () => 'SpinnerIcon');
jest.mock('./StatusAlerts', () => 'StatusAlerts');
describe('GradesView', () => {
let props;
beforeEach(() => {
props = {
updateQueryParams: jest.fn(),
fetchGrades: jest.fn(),
resetFilters: jest.fn(),
};
});
describe('Component', () => {
const filterNames = ['duck', 'Duck', 'Duuuuuck', 'GOOOOSE!'];
describe('behavior', () => {
let el;
beforeEach(() => {
el = shallow(<GradesView {...props} />);
});
describe('handleFilterBadgeClose', () => {
beforeEach(() => {
el.instance().handleFilterBadgeClose(filterNames)();
});
it('calls props.resetFilters with the filters', () => {
expect(props.resetFilters).toHaveBeenCalledWith(filterNames);
});
it('calls props.updateQueryParams with a reset-filters obj', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
[filterNames[0]]: false,
[filterNames[1]]: false,
[filterNames[2]]: false,
[filterNames[3]]: false,
});
});
it('calls fetchGrades', () => {
expect(props.fetchGrades).toHaveBeenCalledWith();
});
});
});
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<GradesView {...props} />);
el.instance().handleFilterBadgeClose = jest.fn().mockName('this.handleFilterBadgeClose');
expect(el.instance().render()).toMatchSnapshot();
});
});
});
test('mapStateToProps is empty', () => {
expect(mapStateToProps({ some: 'state' })).toEqual({});
});
describe('mapDispatchToProps', () => {
describe('fetchGrades', () => {
test('from thunkActions.grades.fetchGrades', () => {
expect(mapDispatchToProps.fetchGrades).toEqual(
thunkActions.grades.fetchGrades,
);
});
});
describe('resetFilters', () => {
test('from actions.filters.reset', () => {
expect(mapDispatchToProps.resetFilters).toEqual(
actions.filters.reset,
);
});
});
});
});