diff --git a/src/components/GradebookFilters/AssignmentFilter/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/AssignmentFilter/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..fcd1e748
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentFilter/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,52 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AssignmentFilter component render snapshot 1`] = `
+
+`;
diff --git a/src/components/GradebookFilters/AssignmentFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/AssignmentFilter/__snapshots__/test.jsx.snap
deleted file mode 100644
index da2fc3d7..00000000
--- a/src/components/GradebookFilters/AssignmentFilter/__snapshots__/test.jsx.snap
+++ /dev/null
@@ -1,44 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`AssignmentFilter Component snapshots basic snapshot 1`] = `
-
-`;
diff --git a/src/components/GradebookFilters/AssignmentFilter/hooks.js b/src/components/GradebookFilters/AssignmentFilter/hooks.js
new file mode 100644
index 00000000..e349ed6e
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentFilter/hooks.js
@@ -0,0 +1,33 @@
+import {
+ selectors,
+ actions,
+ thunkActions,
+} from 'data/redux/hooks';
+
+export const useAssignmentFilterData = ({
+ updateQueryParams,
+}) => {
+ const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels();
+ const selectedAssignmentLabel = selectors.filters.useSelectedAssignmentLabel() || '';
+
+ const updateAssignmentFilter = actions.filters.useUpdateAssignment();
+ const conditionalFetch = thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet();
+
+ const handleChange = ({ target: { value: assignment } }) => {
+ const selectedFilterOption = assignmentFilterOptions.find(
+ ({ label }) => label === assignment,
+ );
+ const { type, id } = selectedFilterOption || {};
+ updateAssignmentFilter({ label: assignment, type, id });
+ updateQueryParams({ assignment: id });
+ conditionalFetch();
+ };
+
+ return {
+ handleChange,
+ selectedAssignmentLabel,
+ assignmentFilterOptions,
+ };
+};
+
+export default useAssignmentFilterData;
diff --git a/src/components/GradebookFilters/AssignmentFilter/hooks.test.js b/src/components/GradebookFilters/AssignmentFilter/hooks.test.js
new file mode 100644
index 00000000..e19cd805
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentFilter/hooks.test.js
@@ -0,0 +1,88 @@
+import { selectors, actions, thunkActions } from 'data/redux/hooks';
+
+import useAssignmentFilterData from './hooks';
+
+jest.mock('data/redux/hooks', () => ({
+ selectors: {
+ filters: {
+ useSelectableAssignmentLabels: jest.fn(),
+ useSelectedAssignmentLabel: jest.fn(),
+ },
+ },
+ actions: {
+ filters: { useUpdateAssignment: jest.fn() },
+ },
+ thunkActions: {
+ grades: { useFetchGradesIfAssignmentGradeFiltersSet: jest.fn() },
+ },
+}));
+
+let out;
+const testKey = 'test-key';
+const event = { target: { value: testKey } };
+const testId = 'test-id';
+const testType = 'test-type';
+
+const testLabel = { label: testKey, id: testId, type: testType };
+const selectableAssignmentLabels = [
+ { label: 'some' },
+ { label: 'test' },
+ { label: 'labels' },
+ testLabel,
+];
+const selectedAssignmentLabel = 'test-assignment-label';
+selectors.filters.useSelectableAssignmentLabels.mockReturnValue(selectableAssignmentLabels);
+selectors.filters.useSelectedAssignmentLabel.mockReturnValue(selectedAssignmentLabel);
+
+const updateAssignment = jest.fn();
+const fetch = jest.fn();
+actions.filters.useUpdateAssignment.mockReturnValue(updateAssignment);
+thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet.mockReturnValue(fetch);
+
+const updateQueryParams = jest.fn();
+
+describe('useAssignmentFilterData hook', () => {
+ beforeEach(() => {
+ out = useAssignmentFilterData({ updateQueryParams });
+ });
+ describe('behavior', () => {
+ it('initializes redux hooks', () => {
+ expect(selectors.filters.useSelectableAssignmentLabels).toHaveBeenCalledWith();
+ expect(selectors.filters.useSelectedAssignmentLabel).toHaveBeenCalledWith();
+ expect(actions.filters.useUpdateAssignment).toHaveBeenCalledWith();
+ expect(thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet)
+ .toHaveBeenCalledWith();
+ });
+ });
+ describe('output', () => {
+ describe('handleEvent', () => {
+ beforeEach(() => {
+ out.handleChange(event);
+ });
+ it('updates assignment filter with selected filter', () => {
+ expect(updateAssignment).toHaveBeenCalledWith(testLabel);
+ });
+ it('updates queryParams', () => {
+ expect(updateQueryParams).toHaveBeenCalledWith({ assignment: testId });
+ });
+ it('updates assignment filter with only label if no match', () => {
+ out.handleChange({ target: { value: 'no-match' } });
+ expect(updateAssignment).toHaveBeenCalledWith({ label: 'no-match' });
+ });
+ it('calls conditional fetch', () => {
+ expect(fetch).toHaveBeenCalled();
+ });
+ });
+ it('passes selectedAssignmentLabel from hook', () => {
+ expect(out.selectedAssignmentLabel).toEqual(selectedAssignmentLabel);
+ });
+ test('selectedAssignmentLabel is empty string if not set', () => {
+ selectors.filters.useSelectedAssignmentLabel.mockReturnValue(undefined);
+ out = useAssignmentFilterData({ updateQueryParams });
+ expect(out.selectedAssignmentLabel).toEqual('');
+ });
+ it('passes assignmentFilterOptions from hook', () => {
+ expect(out.assignmentFilterOptions).toEqual(selectableAssignmentLabels);
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/AssignmentFilter/index.jsx b/src/components/GradebookFilters/AssignmentFilter/index.jsx
index 11106247..06dd44d2 100644
--- a/src/components/GradebookFilters/AssignmentFilter/index.jsx
+++ b/src/components/GradebookFilters/AssignmentFilter/index.jsx
@@ -1,98 +1,44 @@
/* 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 selectors from 'data/selectors';
-import actions from 'data/actions';
-import thunkActions from 'data/thunkActions';
+import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import SelectGroup from '../SelectGroup';
-
-const { fetchGradesIfAssignmentGradeFiltersSet } = thunkActions.grades;
-
-export class AssignmentFilter extends React.Component {
- constructor(props) {
- super(props);
- this.handleChange = this.handleChange.bind(this);
- }
-
- handleChange(event) {
- const assignment = event.target.value;
- const selectedFilterOption = this.props.assignmentFilterOptions.find(
- ({ label }) => label === assignment,
- );
- const { type, id } = selectedFilterOption || {};
- const typedValue = { label: assignment, type, id };
- this.props.updateAssignmentFilter(typedValue);
- this.props.updateQueryParams({ assignment: id });
- this.props.fetchGradesIfAssignmentGradeFiltersSet();
- }
-
- get options() {
- const mapper = ({ label, subsectionLabel }) => (
-
,
- ...this.props.assignmentFilterOptions.map(mapper),
- ]);
- }
-
- render() {
- return (
-
- );
- }
-}
-
-AssignmentFilter.defaultProps = {
- assignmentFilterOptions: [],
- selectedAssignment: '',
+import useAssignmentFilterData from './hooks';
+
+const AssignmentFilter = ({ updateQueryParams }) => {
+ const {
+ handleChange,
+ selectedAssignmentLabel,
+ assignmentFilterOptions,
+ } = useAssignmentFilterData({ updateQueryParams });
+ const { formatMessage } = useIntl();
+ const filterOptions = assignmentFilterOptions.map(({ label, subsectionLabel }) => (
+
+ );
};
AssignmentFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
- // redux
- assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
- label: PropTypes.string,
- subsectionLabel: PropTypes.string,
- type: PropTypes.string,
- id: PropTypes.string,
- })),
- selectedAssignment: PropTypes.string,
- fetchGradesIfAssignmentGradeFiltersSet: PropTypes.func.isRequired,
- updateAssignmentFilter: PropTypes.func.isRequired,
-};
-
-export const mapStateToProps = (state) => {
- const { filters } = selectors;
- return {
- assignmentFilterOptions: filters.selectableAssignmentLabels(state),
- selectedAssignment: filters.selectedAssignmentLabel(state),
- selectedAssignmentType: filters.assignmentType(state),
- selectedCohort: filters.cohort(state),
- selectedTrack: filters.track(state),
- };
-};
-
-export const mapDispatchToProps = {
- updateAssignmentFilter: actions.filters.update.assignment,
- fetchGradesIfAssignmentGradeFiltersSet,
};
-export default connect(mapStateToProps, mapDispatchToProps)(AssignmentFilter);
+export default AssignmentFilter;
diff --git a/src/components/GradebookFilters/AssignmentFilter/index.test.jsx b/src/components/GradebookFilters/AssignmentFilter/index.test.jsx
new file mode 100644
index 00000000..ceec6358
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentFilter/index.test.jsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import SelectGroup from '../SelectGroup';
+import useAssignmentFilterData from './hooks';
+import AssignmentFilter from '.';
+
+jest.mock('../SelectGroup', () => 'SelectGroup');
+jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
+
+const handleChange = jest.fn();
+const selectedAssignmentLabel = 'test-label';
+const assignmentFilterOptions = [
+ { label: 'label1', subsectionLabel: 'sLabel1' },
+ { label: 'label2', subsectionLabel: 'sLabel2' },
+ { label: 'label3', subsectionLabel: 'sLabel3' },
+ { label: 'label4', subsectionLabel: 'sLabel4' },
+];
+useAssignmentFilterData.mockReturnValue({
+ handleChange,
+ selectedAssignmentLabel,
+ assignmentFilterOptions,
+});
+
+const updateQueryParams = jest.fn();
+
+let el;
+describe('AssignmentFilter component', () => {
+ beforeAll(() => {
+ el = shallow(
);
+ });
+ describe('behavior', () => {
+ it('initializes hooks', () => {
+ expect(useAssignmentFilterData).toHaveBeenCalledWith({ updateQueryParams });
+ expect(useIntl).toHaveBeenCalledWith();
+ });
+ });
+ describe('render', () => {
+ test('snapshot', () => {
+ expect(el).toMatchSnapshot();
+ });
+ test('filter options', () => {
+ const { options } = el.find(SelectGroup).props();
+ expect(options.length).toEqual(5);
+ const testOption = assignmentFilterOptions[0];
+ const optionProps = options[1].props;
+ expect(optionProps.value).toEqual(testOption.label);
+ expect(optionProps.children.join(''))
+ .toEqual(`${testOption.label}: ${testOption.subsectionLabel}`);
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/AssignmentFilter/test.jsx b/src/components/GradebookFilters/AssignmentFilter/test.jsx
deleted file mode 100644
index b2ac511a..00000000
--- a/src/components/GradebookFilters/AssignmentFilter/test.jsx
+++ /dev/null
@@ -1,166 +0,0 @@
-import React from 'react';
-import { mount, shallow } from 'enzyme';
-
-import selectors from 'data/selectors';
-import actions from 'data/actions';
-import { fetchGradesIfAssignmentGradeFiltersSet } from 'data/thunkActions/grades';
-import {
- AssignmentFilter,
- mapStateToProps,
- mapDispatchToProps,
-} from '.';
-
-jest.mock('data/thunkActions/grades', () => ({
- updateGradesIfAssignmentGradeFiltersSet: jest.fn(),
-}));
-
-jest.mock('data/selectors', () => ({
- /** Mocking to use passed state for validation purposes */
- filters: {
- selectableAssignmentLabels: jest.fn(() => ([{
- label: 'assigNment',
- subsectionLabel: 'subsection',
- type: 'assignMentType',
- id: 'subsectionId',
- }])),
- selectedAssignmentLabel: jest.fn(() => 'assigNment'),
- assignmentType: jest.fn(() => 'assignMentType'),
- cohort: jest.fn(() => 'COhort'),
- track: jest.fn(() => 'traCK'),
- },
-}));
-
-describe('AssignmentFilter', () => {
- let props = {
- assignmentFilterOptions: [
- {
- label: 'assgN1',
- subsectionLabel: 'subLabel1',
- type: 'assgn_Type1',
- id: 'assgn_iD1',
- },
- {
- label: 'assgN2',
- subsectionLabel: 'subLabel2',
- type: 'assgn_Type2',
- id: 'assgn_iD2',
- },
- ],
- selectedAssignment: 'assgN1',
- };
-
- beforeEach(() => {
- props = {
- ...props,
- updateQueryParams: jest.fn(),
- fetchGradesIfAssignmentGradeFiltersSet: jest.fn(),
- updateAssignmentFilter: jest.fn(),
- };
- });
-
- describe('Component', () => {
- describe('behavior', () => {
- describe('handleChange', () => {
- let el;
- const newAssgn = 'assgN1';
- const event = { target: { value: newAssgn } };
- const selected = props.assignmentFilterOptions[0];
- beforeEach(() => {
- el = mount(
);
- el.instance().handleChange(event);
- });
- it('calls props.updateAssignmentFilter with selection', () => {
- expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
- label: newAssgn,
- type: selected.type,
- id: selected.id,
- });
- });
- it(
- 'calls props.updateQueryParams with selected assignment id',
- () => {
- expect(props.updateQueryParams).toHaveBeenCalledWith({
- assignment: selected.id,
- });
- },
- );
- it('calls props.fetchGradesIfAssignmentGradeFiltersSet', () => {
- const method = props.fetchGradesIfAssignmentGradeFiltersSet;
- expect(method).toHaveBeenCalledWith();
- });
- describe('no selected option', () => {
- const value = 'fake';
- beforeEach(() => {
- el = mount(
);
- el.instance().handleChange({ target: { value } });
- });
- it('calls props.updateAssignmentFilter with selection', () => {
- expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
- label: value,
- type: undefined,
- id: undefined,
- });
- });
- it(
- 'calls props.updateQueryParams with selected assignment id',
- () => {
- expect(props.updateQueryParams).toHaveBeenCalledWith({
- assignment: undefined,
- });
- },
- );
- it('calls props.fetchGradesIfAssignmentGradeFiltersSet', () => {
- const method = props.fetchGradesIfAssignmentGradeFiltersSet;
- expect(method).toHaveBeenCalledWith();
- });
- });
- });
- });
- describe('snapshots', () => {
- test('basic snapshot', () => {
- const el = shallow(
);
- el.instance().handleChange = jest.fn().mockName('handleChange');
- expect(el.instance().render()).toMatchSnapshot();
- });
- });
- });
- describe('mapStateToProps', () => {
- const state = {
- filters: {
- assignment: { label: 'assigNment' },
- assignmentType: 'assignMentType',
- cohort: 'COhort',
- track: 'traCK',
- },
- };
- describe('assignmentFilterOptions', () => {
- it('is selected from filters.selectableAssignmentLabels', () => {
- expect(
- mapStateToProps(state).assignmentFilterOptions,
- ).toEqual(
- selectors.filters.selectableAssignmentLabels(state),
- );
- });
- });
- describe('selectedAssignment', () => {
- it('is selected from filters.selectedAssignmentLabel', () => {
- expect(
- mapStateToProps(state).selectedAssignment,
- ).toEqual(
- selectors.filters.selectedAssignmentLabel(state),
- );
- });
- });
- });
- describe('mapDispatchToProps', () => {
- test('updateAssignmentFilter', () => {
- expect(mapDispatchToProps.updateAssignmentFilter).toEqual(
- actions.filters.update.assignment,
- );
- });
- test('fetchGradesIfAsssignmentGradeFiltersSet', () => {
- const prop = mapDispatchToProps.fetchGradesIfAssignmentGradeFiltersSet;
- expect(prop).toEqual(fetchGradesIfAssignmentGradeFiltersSet);
- });
- });
-});
diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..109d1d7d
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,67 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AssignmentFilter component render with selected assignment snapshot 1`] = `
+
+`;
+
+exports[`AssignmentFilter component render without selected assignment snapshot 1`] = `
+
+`;
diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/test.jsx.snap
deleted file mode 100644
index 2ad68a9e..00000000
--- a/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/test.jsx.snap
+++ /dev/null
@@ -1,95 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`AssignmentGradeFilter Component snapshots buttons and groups disabled if no selected assignment 1`] = `
-
-`;
diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/hooks.js b/src/components/GradebookFilters/AssignmentGradeFilter/hooks.js
new file mode 100644
index 00000000..adaf0a6d
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentGradeFilter/hooks.js
@@ -0,0 +1,36 @@
+/* eslint-disable react/sort-comp, react/button-has-type */
+import { selectors, actions, thunkActions } from 'data/redux/hooks';
+
+const useAssignmentGradeFilterData = ({ updateQueryParams }) => {
+ const localAssignmentLimits = selectors.app.useAssignmentGradeLimits();
+ const selectedAssignment = selectors.filters.useSelectedAssignmentLabel();
+ const fetchGrades = thunkActions.grades.useFetchGrades();
+ const setFilter = actions.app.useSetLocalFilter();
+ const updateAssignmentLimits = actions.filters.useUpdateAssignmentLimits();
+
+ const handleSubmit = () => {
+ updateAssignmentLimits(localAssignmentLimits);
+ fetchGrades();
+ updateQueryParams(localAssignmentLimits);
+ };
+
+ const handleSetMax = ({ target: { value } }) => {
+ setFilter({ assignmentGradeMax: value });
+ };
+
+ const handleSetMin = ({ target: { value } }) => {
+ setFilter({ assignmentGradeMin: value });
+ };
+
+ const { assignmentGradeMax, assignmentGradeMin } = localAssignmentLimits;
+ return {
+ assignmentGradeMin,
+ assignmentGradeMax,
+ selectedAssignment,
+ handleSetMax,
+ handleSetMin,
+ handleSubmit,
+ };
+};
+
+export default useAssignmentGradeFilterData;
diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/hooks.test.js b/src/components/GradebookFilters/AssignmentGradeFilter/hooks.test.js
new file mode 100644
index 00000000..817f3769
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentGradeFilter/hooks.test.js
@@ -0,0 +1,81 @@
+import { selectors, actions, thunkActions } from 'data/redux/hooks';
+
+import useAssignmentGradeFilterData from './hooks';
+
+jest.mock('data/redux/hooks', () => ({
+ selectors: {
+ app: { useAssignmentGradeLimits: jest.fn() },
+ filters: { useSelectedAssignmentLabel: jest.fn() },
+ },
+ actions: {
+ app: { useSetLocalFilter: jest.fn() },
+ filters: { useUpdateAssignmentLimits: jest.fn() },
+ },
+ thunkActions: {
+ grades: { useFetchGrades: jest.fn() },
+ },
+}));
+
+let out;
+
+const assignmentGradeLimits = { assignmentGradeMax: 200, assignmentGradeMin: 3 };
+const selectedAssignmentLabel = 'test-assignment-label';
+selectors.app.useAssignmentGradeLimits.mockReturnValue(assignmentGradeLimits);
+selectors.filters.useSelectedAssignmentLabel.mockReturnValue(selectedAssignmentLabel);
+
+const setLocalFilter = jest.fn();
+const updateAssignmentLimits = jest.fn();
+const fetch = jest.fn();
+actions.app.useSetLocalFilter.mockReturnValue(setLocalFilter);
+actions.filters.useUpdateAssignmentLimits.mockReturnValue(updateAssignmentLimits);
+thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
+
+const testValue = 42;
+
+const updateQueryParams = jest.fn();
+
+describe('useAssignmentFilterData hook', () => {
+ beforeEach(() => {
+ out = useAssignmentGradeFilterData({ updateQueryParams });
+ });
+ describe('behavior', () => {
+ it('initializes redux hooks', () => {
+ expect(selectors.app.useAssignmentGradeLimits).toHaveBeenCalledWith();
+ expect(selectors.filters.useSelectedAssignmentLabel).toHaveBeenCalledWith();
+ expect(actions.app.useSetLocalFilter).toHaveBeenCalledWith();
+ expect(actions.filters.useUpdateAssignmentLimits).toHaveBeenCalledWith();
+ expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
+ });
+ });
+ describe('output', () => {
+ describe('handleSubmit', () => {
+ beforeEach(() => {
+ out.handleSubmit();
+ });
+ it('updates assignment limits filter', () => {
+ expect(updateAssignmentLimits).toHaveBeenCalledWith(assignmentGradeLimits);
+ });
+ it('updates queryParams', () => {
+ expect(updateQueryParams).toHaveBeenCalledWith(assignmentGradeLimits);
+ });
+ it('calls conditional fetch', () => {
+ expect(fetch).toHaveBeenCalled();
+ });
+ });
+ test('handleSetMax sets assignmentGradeMax', () => {
+ out.handleSetMax({ target: { value: testValue } });
+ expect(setLocalFilter).toHaveBeenCalledWith({ assignmentGradeMax: testValue });
+ });
+ test('handleSetMin sets assignmentGradeMin', () => {
+ out.handleSetMin({ target: { value: testValue } });
+ expect(setLocalFilter).toHaveBeenCalledWith({ assignmentGradeMin: testValue });
+ });
+ it('passes selectedAssignment from hook', () => {
+ expect(out.selectedAssignment).toEqual(selectedAssignmentLabel);
+ });
+ it('passes assignmentGradeMin and assignmentGradeMax from hook', () => {
+ expect(out.assignmentGradeMax).toEqual(assignmentGradeLimits.assignmentGradeMax);
+ expect(out.assignmentGradeMin).toEqual(assignmentGradeLimits.assignmentGradeMin);
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/index.jsx b/src/components/GradebookFilters/AssignmentGradeFilter/index.jsx
index adf0a31d..9b10d5a7 100644
--- a/src/components/GradebookFilters/AssignmentGradeFilter/index.jsx
+++ b/src/components/GradebookFilters/AssignmentGradeFilter/index.jsx
@@ -1,103 +1,56 @@
-/* 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 { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
-import selectors from 'data/selectors';
-import actions from 'data/actions';
-import thunkActions from 'data/thunkActions';
-
+import useAssignmentGradeFilterData from './hooks';
import messages from '../messages';
import PercentGroup from '../PercentGroup';
-export class AssignmentGradeFilter extends React.Component {
- constructor(props) {
- super(props);
- this.handleSubmit = this.handleSubmit.bind(this);
- this.handleSetMax = this.handleSetMax.bind(this);
- this.handleSetMin = this.handleSetMin.bind(this);
- }
-
- handleSubmit() {
- this.props.updateAssignmentLimits(this.props.localAssignmentLimits);
- this.props.fetchGrades();
- this.props.updateQueryParams(this.props.localAssignmentLimits);
- }
-
- handleSetMax({ target: { value } }) {
- this.props.setFilter({ assignmentGradeMax: value });
- }
-
- handleSetMin({ target: { value } }) {
- this.props.setFilter({ assignmentGradeMin: value });
- }
-
- render() {
- const {
- localAssignmentLimits: { assignmentGradeMax, assignmentGradeMin },
- } = this.props;
- return (
-
-
}
- value={assignmentGradeMin}
- disabled={!this.props.selectedAssignment}
- onChange={this.handleSetMin}
- />
-
}
- value={assignmentGradeMax}
- disabled={!this.props.selectedAssignment}
- onChange={this.handleSetMax}
- />
-
-
-
+export const AssignmentGradeFilter = ({ updateQueryParams }) => {
+ const {
+ assignmentGradeMin,
+ assignmentGradeMax,
+ selectedAssignment,
+ handleSetMax,
+ handleSetMin,
+ handleSubmit,
+ } = useAssignmentGradeFilterData({ updateQueryParams });
+ const { formatMessage } = useIntl();
+ return (
+
+
+
+
+
- );
- }
-}
-
-AssignmentGradeFilter.defaultProps = {
- selectedAssignment: '',
+
+ );
};
AssignmentGradeFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
-
- // redux
- fetchGrades: PropTypes.func.isRequired,
- localAssignmentLimits: PropTypes.shape({
- assignmentGradeMax: PropTypes.string,
- assignmentGradeMin: PropTypes.string,
- }).isRequired,
- selectedAssignment: PropTypes.string,
- setFilter: PropTypes.func.isRequired,
- updateAssignmentLimits: PropTypes.func.isRequired,
-};
-
-export const mapStateToProps = (state) => ({
- localAssignmentLimits: selectors.app.assignmentGradeLimits(state),
- selectedAssignment: selectors.filters.selectedAssignmentLabel(state),
-});
-
-export const mapDispatchToProps = {
- fetchGrades: thunkActions.grades.fetchGrades,
- setFilter: actions.app.setLocalFilter,
- updateAssignmentLimits: actions.filters.update.assignmentLimits,
};
-export default connect(mapStateToProps, mapDispatchToProps)(AssignmentGradeFilter);
+export default AssignmentGradeFilter;
diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/index.test.jsx b/src/components/GradebookFilters/AssignmentGradeFilter/index.test.jsx
new file mode 100644
index 00000000..332da15e
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentGradeFilter/index.test.jsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Button } from '@edx/paragon';
+
+import PercentGroup from '../PercentGroup';
+import useAssignmentGradeFilterData from './hooks';
+import AssignmentFilter from '.';
+
+jest.mock('../PercentGroup', () => 'PercentGroup');
+jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
+
+const hookData = {
+ handleChange: jest.fn(),
+ handleSetMax: jest.fn(),
+ handleSetMin: jest.fn(),
+ selectedAssignment: 'test-assignment',
+ assignmentGradeMax: 300,
+ assignmentGradeMin: 23,
+};
+useAssignmentGradeFilterData.mockReturnValue(hookData);
+
+const updateQueryParams = jest.fn();
+
+let el;
+describe('AssignmentFilter component', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ el = shallow(
);
+ });
+ describe('behavior', () => {
+ it('initializes hooks', () => {
+ expect(useAssignmentGradeFilterData).toHaveBeenCalledWith({ updateQueryParams });
+ expect(useIntl).toHaveBeenCalledWith();
+ });
+ });
+ describe('render', () => {
+ describe('with selected assignment', () => {
+ test('snapshot', () => {
+ expect(el).toMatchSnapshot();
+ });
+ it('renders a PercentGroup for both Max and Min filters', () => {
+ let props = el.find(PercentGroup).at(0).props();
+ expect(props.value).toEqual(hookData.assignmentGradeMin);
+ expect(props.disabled).toEqual(false);
+ expect(props.onChange).toEqual(hookData.handleSetMin);
+ props = el.find(PercentGroup).at(1).props();
+ expect(props.value).toEqual(hookData.assignmentGradeMax);
+ expect(props.disabled).toEqual(false);
+ expect(props.onChange).toEqual(hookData.handleSetMax);
+ });
+ it('renders a submit button', () => {
+ const props = el.find(Button).props();
+ expect(props.disabled).toEqual(false);
+ expect(props.onClick).toEqual(hookData.handleSubmit);
+ });
+ });
+ describe('without selected assignment', () => {
+ beforeEach(() => {
+ useAssignmentGradeFilterData.mockReturnValueOnce({
+ ...hookData,
+ selectedAssignment: null,
+ });
+ el = shallow(
);
+ });
+ test('snapshot', () => {
+ expect(el).toMatchSnapshot();
+ });
+ it('disables controls', () => {
+ let props = el.find(PercentGroup).at(0).props();
+ expect(props.disabled).toEqual(true);
+ props = el.find(PercentGroup).at(1).props();
+ expect(props.disabled).toEqual(true);
+ });
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/AssignmentGradeFilter/test.jsx b/src/components/GradebookFilters/AssignmentGradeFilter/test.jsx
deleted file mode 100644
index cf786178..00000000
--- a/src/components/GradebookFilters/AssignmentGradeFilter/test.jsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import React from 'react';
-import { mount, shallow } from 'enzyme';
-
-import actions from 'data/actions';
-import selectors from 'data/selectors';
-import { fetchGrades } from 'data/thunkActions/grades';
-
-import {
- AssignmentGradeFilter,
- mapStateToProps,
- mapDispatchToProps,
-} from '.';
-
-jest.mock('data/selectors', () => ({
- __esModule: true,
- default: {
- app: {},
- filters: {},
- grades: {},
- },
-}));
-
-jest.mock('data/thunkActions/grades', () => ({
- fetchGrades: jest.fn(),
-}));
-
-describe('AssignmentGradeFilter', () => {
- let props = {};
- beforeEach(() => {
- props = {
- ...props,
- updateQueryParams: jest.fn(),
- fetchGrades: jest.fn(),
- localAssignmentLimits: {
- assignmentGradeMax: '98',
- assignmentGradeMin: '2',
- },
- selectedAssignment: 'Potions 101.5',
- setFilter: jest.fn(),
- updateAssignmentLimits: jest.fn(),
- };
- });
-
- describe('Component', () => {
- describe('behavior', () => {
- let el;
- beforeEach(() => {
- el = mount(
);
- });
- describe('handleSubmit', () => {
- beforeEach(() => {
- el.instance().handleSubmit();
- });
- it('calls props.updateAssignmentLimits with local assignment limits', () => {
- expect(props.updateAssignmentLimits).toHaveBeenCalledWith(props.localAssignmentLimits);
- });
- it('calls fetchUserGrades', () => {
- expect(props.fetchGrades).toHaveBeenCalledWith();
- });
- it('updates queryParams with assignment grade min and max', () => {
- expect(props.updateQueryParams).toHaveBeenCalledWith(props.localAssignmentLimits);
- });
- });
- describe('handleSetMin', () => {
- it('calls setFilters for assignmentGradeMin', () => {
- const testVal = 23;
- el.instance().handleSetMin({ target: { value: testVal } });
- expect(props.setFilter).toHaveBeenCalledWith({
- assignmentGradeMin: testVal,
- });
- });
- });
- describe('handleSetMax', () => {
- it('calls setFilters for assignmentGradeMax', () => {
- const testVal = 92;
- el.instance().handleSetMax({ target: { value: testVal } });
- expect(props.setFilter).toHaveBeenCalledWith({
- assignmentGradeMax: testVal,
- });
- });
- });
- });
- describe('snapshots', () => {
- let el;
- const mockMethods = () => {
- el.instance().handleSubmit = jest.fn().mockName('handleSubmit');
- el.instance().handleSetMax = jest.fn().mockName('handleSetMax');
- el.instance().handleSetMin = jest.fn().mockName('handleSetMin');
- };
- test('smoke test', () => {
- el = shallow(
);
- mockMethods(el);
- expect(el.instance().render()).toMatchSnapshot();
- });
- test('buttons and groups disabled if no selected assignment', () => {
- el = shallow(
);
- mockMethods(el);
- expect(el.instance().render()).toMatchSnapshot();
- });
- });
- });
- describe('mapStateToProps', () => {
- const testState = { belle: 'in', the: 'castle' };
- let mappedProps;
- beforeEach(() => {
- selectors.app.assignmentGradeLimits = jest.fn((state) => ({ gradeLimits: state }));
- selectors.filters.selectedAssignmentLabel = jest.fn((state) => ({ assignmentLabel: state }));
- mappedProps = mapStateToProps(testState);
- });
- describe('localAssignmentLimits', () => {
- it('returns selectors.app.assignmentGradeLimits', () => {
- expect(
- mappedProps.localAssignmentLimits,
- ).toEqual(selectors.app.assignmentGradeLimits(testState));
- });
- });
- describe('selectedAsssignment', () => {
- it('returns selectors.filters.selectedAssignmentLabel', () => {
- expect(
- mappedProps.selectedAssignment,
- ).toEqual(selectors.filters.selectedAssignmentLabel(testState));
- });
- });
- });
- describe('mapDispatchToProps', () => {
- test('fetchGrades', () => {
- expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades);
- });
- test('setFilters', () => {
- expect(mapDispatchToProps.setFilter).toEqual(actions.app.setLocalFilter);
- });
- test('updateAssignmentLimits', () => {
- expect(
- mapDispatchToProps.updateAssignmentLimits,
- ).toEqual(
- actions.filters.update.assignmentLimits,
- );
- });
- });
-});
diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..70382c97
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,44 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AssignmentFilterType component render snapshot 1`] = `
+
+
+ All
+ ,
+ ,
+ ,
+ ,
+ ,
+ ]
+ }
+ value="test-type"
+ />
+
+`;
diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/test.jsx.snap
deleted file mode 100644
index f21b12ad..00000000
--- a/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/test.jsx.snap
+++ /dev/null
@@ -1,79 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`AssignmentTypeFilter Component snapshots SelectGroup disabled if no assignmentFilterOptions 1`] = `
-
-
- }
- onChange={[MockFunction handleChange]}
- options={
- Array [
- ,
- ,
- ,
- ]
- }
- value="assigNmentType2"
- />
-
-`;
-
-exports[`AssignmentTypeFilter Component snapshots smoke test 1`] = `
-
-
- }
- onChange={[MockFunction handleChange]}
- options={
- Array [
- ,
- ,
- ,
- ]
- }
- value="assigNmentType2"
- />
-
-`;
diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/hooks.js b/src/components/GradebookFilters/AssignmentTypeFilter/hooks.js
new file mode 100644
index 00000000..3dd25ae7
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentTypeFilter/hooks.js
@@ -0,0 +1,22 @@
+import { selectors, actions } from 'data/redux/hooks';
+
+export const useAssignmentTypeFilterData = ({ updateQueryParams }) => {
+ const assignmentTypes = selectors.assignmentTypes.useAllAssignmentTypes() || {};
+ const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels();
+ const selectedAssignmentType = selectors.filters.useAssignmentType() || '';
+ const filterAssignmentType = actions.filters.useUpdateAssignmentType();
+
+ const handleChange = (event) => {
+ const assignmentType = event.target.value;
+ filterAssignmentType(assignmentType);
+ updateQueryParams({ assignmentType });
+ };
+
+ return {
+ assignmentTypes,
+ handleChange,
+ isDisabled: assignmentFilterOptions.length === 0,
+ selectedAssignmentType,
+ };
+};
+export default useAssignmentTypeFilterData;
diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/hooks.test.js b/src/components/GradebookFilters/AssignmentTypeFilter/hooks.test.js
new file mode 100644
index 00000000..ff9d05c3
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentTypeFilter/hooks.test.js
@@ -0,0 +1,92 @@
+import { selectors, actions } from 'data/redux/hooks';
+
+import useAssignmentTypeFilterData from './hooks';
+
+jest.mock('data/redux/hooks', () => ({
+ selectors: {
+ assignmentTypes: {
+ useAllAssignmentTypes: jest.fn(),
+ },
+ filters: {
+ useSelectableAssignmentLabels: jest.fn(),
+ useAssignmentType: jest.fn(),
+ },
+ },
+ actions: {
+ filters: { useUpdateAssignmentType: jest.fn() },
+ },
+}));
+
+let out;
+const testId = 'test-id';
+const testKey = 'test-key';
+
+const testType = 'test-type';
+const allTypes = [testType, 'and', 'some', 'other', 'types'];
+selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue(allTypes);
+const event = { target: { value: testType } };
+
+const testLabel = { label: testKey, id: testId, type: testType };
+const selectableAssignmentLabels = [
+ { label: 'some' },
+ { label: 'test' },
+ { label: 'labels' },
+ testLabel,
+];
+selectors.filters.useSelectableAssignmentLabels.mockReturnValue(selectableAssignmentLabels);
+selectors.filters.useAssignmentType.mockReturnValue(testType);
+
+const updateAssignmentType = jest.fn();
+actions.filters.useUpdateAssignmentType.mockReturnValue(updateAssignmentType);
+
+const updateQueryParams = jest.fn();
+
+describe('useAssignmentTypeFilterData hook', () => {
+ beforeEach(() => {
+ out = useAssignmentTypeFilterData({ updateQueryParams });
+ });
+ describe('behavior', () => {
+ it('initializes redux hooks', () => {
+ expect(selectors.assignmentTypes.useAllAssignmentTypes).toHaveBeenCalledWith();
+ expect(selectors.filters.useSelectableAssignmentLabels).toHaveBeenCalledWith();
+ expect(selectors.filters.useAssignmentType).toHaveBeenCalledWith();
+ expect(actions.filters.useUpdateAssignmentType).toHaveBeenCalledWith();
+ });
+ });
+ describe('output', () => {
+ describe('handleEvent', () => {
+ beforeEach(() => {
+ out.handleChange(event);
+ });
+ it('updates assignmentType filter with selected filter', () => {
+ expect(updateAssignmentType).toHaveBeenCalledWith(testType);
+ });
+ it('updates queryParams', () => {
+ expect(updateQueryParams).toHaveBeenCalledWith({ assignmentType: testType });
+ });
+ });
+ describe('selectedAssignmentType', () => {
+ it('returns selected assignmentType', () => {
+ expect(out.selectedAssignmentType).toEqual(testType);
+ });
+ it('returns empty string if no assignmentType is selected', () => {
+ selectors.filters.useAssignmentType.mockReturnValue(undefined);
+ out = useAssignmentTypeFilterData({ updateQueryParams });
+ expect(out.selectedAssignmentType).toEqual('');
+ });
+ });
+ it('passes assignmentTypes from hook', () => {
+ expect(out.assignmentTypes).toEqual(allTypes);
+ });
+ test('assignmentTypes is empty object if hook returns undefined', () => {
+ selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue(undefined);
+ out = useAssignmentTypeFilterData({ updateQueryParams });
+ expect(out.assignmentTypes).toEqual({});
+ });
+ it('returns isDisabled if assigmentFilterOptions is empty', () => {
+ expect(out.isDisabled).toEqual(false);
+ selectors.assignmentTypes.useAllAssignmentTypes.mockReturnValue([]);
+ out = useAssignmentTypeFilterData({ updateQueryParams });
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/index.jsx b/src/components/GradebookFilters/AssignmentTypeFilter/index.jsx
index 989736ca..368bee84 100644
--- a/src/components/GradebookFilters/AssignmentTypeFilter/index.jsx
+++ b/src/components/GradebookFilters/AssignmentTypeFilter/index.jsx
@@ -1,81 +1,42 @@
/* 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 selectors from 'data/selectors';
-import actions from 'data/actions';
+import { useIntl } from '@edx/frontend-platform/i18n';
import SelectGroup from '../SelectGroup';
import messages from '../messages';
-
-export class AssignmentTypeFilter extends React.Component {
- constructor(props) {
- super(props);
- this.handleChange = this.handleChange.bind(this);
- }
-
- handleChange(event) {
- const assignmentType = event.target.value;
- this.props.filterAssignmentType(assignmentType);
- this.props.updateQueryParams({ assignmentType });
- }
-
- get options() {
- const mapper = (entry) => (
-
- );
- return [
-
,
- ...this.props.assignmentTypes.map(mapper),
- ];
- }
-
- render() {
- return (
-
- }
- value={this.props.selectedAssignmentType}
- onChange={this.handleChange}
- disabled={this.props.assignmentFilterOptions.length === 0}
- options={this.options}
- />
-
- );
- }
-}
-
-AssignmentTypeFilter.defaultProps = {
- assignmentTypes: [],
- assignmentFilterOptions: [],
- selectedAssignmentType: '',
+import useAssignmentTypeFilterData from './hooks';
+
+export const AssignmentTypeFilter = ({ updateQueryParams }) => {
+ const {
+ assignmentTypes,
+ handleChange,
+ isDisabled,
+ selectedAssignmentType,
+ } = useAssignmentTypeFilterData({ updateQueryParams });
+ const { formatMessage } = useIntl();
+ return (
+
+ All,
+ ...assignmentTypes.map(entry => (
+
+ )),
+ ]}
+ />
+
+ );
};
AssignmentTypeFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
-
- // redux
- assignmentTypes: PropTypes.arrayOf(PropTypes.string),
- assignmentFilterOptions: PropTypes.arrayOf(PropTypes.shape({
- label: PropTypes.string,
- subsectionLabel: PropTypes.string,
- })),
- filterAssignmentType: PropTypes.func.isRequired,
- selectedAssignmentType: PropTypes.string,
-};
-
-export const mapStateToProps = (state) => ({
- assignmentTypes: selectors.assignmentTypes.allAssignmentTypes(state),
- assignmentFilterOptions: selectors.filters.selectableAssignmentLabels(state),
- selectedAssignmentType: selectors.filters.assignmentType(state),
-});
-
-export const mapDispatchToProps = {
- filterAssignmentType: actions.filters.update.assignmentType,
};
-export default connect(mapStateToProps, mapDispatchToProps)(AssignmentTypeFilter);
+export default AssignmentTypeFilter;
diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/index.test.jsx b/src/components/GradebookFilters/AssignmentTypeFilter/index.test.jsx
new file mode 100644
index 00000000..b1a9cb0a
--- /dev/null
+++ b/src/components/GradebookFilters/AssignmentTypeFilter/index.test.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import SelectGroup from '../SelectGroup';
+import useAssignmentFilterTypeData from './hooks';
+import AssignmentFilterType from '.';
+
+jest.mock('../SelectGroup', () => 'SelectGroup');
+jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
+
+const handleChange = jest.fn();
+const testType = 'test-type';
+const assignmentTypes = [testType, 'type1', 'type2', 'type3'];
+useAssignmentFilterTypeData.mockReturnValue({
+ handleChange,
+ selectedAssignmentType: testType,
+ assignmentTypes,
+ isDisabled: true,
+});
+
+const updateQueryParams = jest.fn();
+
+let el;
+describe('AssignmentFilterType component', () => {
+ beforeAll(() => {
+ el = shallow(
);
+ });
+ describe('behavior', () => {
+ it('initializes hooks', () => {
+ expect(useAssignmentFilterTypeData).toHaveBeenCalledWith({ updateQueryParams });
+ expect(useIntl).toHaveBeenCalledWith();
+ });
+ });
+ describe('render', () => {
+ test('snapshot', () => {
+ expect(el).toMatchSnapshot();
+ });
+ test('filter options', () => {
+ const { options } = el.find(SelectGroup).props();
+ expect(options.length).toEqual(5);
+ const optionProps = options[1].props;
+ expect(optionProps.value).toEqual(assignmentTypes[0]);
+ expect(optionProps.children).toEqual(testType);
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/AssignmentTypeFilter/test.jsx b/src/components/GradebookFilters/AssignmentTypeFilter/test.jsx
deleted file mode 100644
index 88750e08..00000000
--- a/src/components/GradebookFilters/AssignmentTypeFilter/test.jsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-
-import selectors from 'data/selectors';
-import actions from 'data/actions';
-
-import {
- AssignmentTypeFilter,
- mapStateToProps,
- mapDispatchToProps,
-} from '.';
-
-jest.mock('data/selectors', () => ({
- /** Mocking to use passed state for validation purposes */
- assignmentTypes: {
- allAssignmentTypes: jest.fn(() => (['assignment', 'labs'])),
- },
- filters: {
- selectableAssignmentLabels: jest.fn(() => ([{
- label: 'assigNment',
- subsectionLabel: 'subsection',
- type: 'assignMentType',
- id: 'subsectionId',
- }])),
- assignmentType: jest.fn(() => 'assignMentType'),
- },
-}));
-
-describe('AssignmentTypeFilter', () => {
- let props = {
- assignmentTypes: ['assignMentType1', 'AssigNmentType2'],
- assignmentFilterOptions: [
- { label: 'filterLabel1', subsectionLabel: 'filterSubLabel2' },
- { label: 'filterLabel2', subsectionLabel: 'filterSubLabel1' },
- ],
- selectedAssignmentType: 'assigNmentType2',
- };
-
- beforeEach(() => {
- props = {
- ...props,
- filterAssignmentType: jest.fn(),
- updateQueryParams: jest.fn(),
- };
- });
-
- describe('Component', () => {
- describe('behavior', () => {
- describe('handleChange', () => {
- let el;
- const newType = 'new Type';
- const event = { target: { value: newType } };
- beforeEach(() => {
- el = shallow(
);
- el.instance().handleChange(event);
- });
- it('calls props.filterAssignmentType with new type', () => {
- expect(props.filterAssignmentType).toHaveBeenCalledWith(
- newType,
- );
- });
- it('updates queryParams with assignmentType', () => {
- expect(props.updateQueryParams).toHaveBeenCalledWith({
- assignmentType: newType,
- });
- });
- });
- });
- describe('snapshots', () => {
- let el;
- const mockMethods = () => {
- el.instance().handleChange = jest.fn().mockName('handleChange');
- };
- test('smoke test', () => {
- el = shallow(
);
- mockMethods(el);
- expect(el.instance().render()).toMatchSnapshot();
- });
- test('SelectGroup disabled if no assignmentFilterOptions', () => {
- el = shallow(
);
- mockMethods(el);
- expect(el.instance().render()).toMatchSnapshot();
- });
- });
- });
- describe('mapStateToProps', () => {
- const state = {
- assignmentTypes: {
- results: ['assignMentType1', 'assignMentType2'],
- },
- filters: {
- assignmentType: 'selectedAssignMent',
- cohort: 'selectedCOHOrt',
- track: 'SELectedTrack',
- },
- };
- describe('assignmentTypes', () => {
- it('is selected from assignmentTypes.allAssignmentTypes', () => {
- expect(
- mapStateToProps(state).assignmentTypes,
- ).toEqual(
- selectors.assignmentTypes.allAssignmentTypes(state),
- );
- });
- });
- describe('assignmentFilterOptions', () => {
- it('is selected from filters.selectableAssignmentLabels', () => {
- expect(
- mapStateToProps(state).assignmentFilterOptions,
- ).toEqual(
- selectors.filters.selectableAssignmentLabels(state),
- );
- });
- });
- describe('selectedAssignmentType', () => {
- it('is selected from filters.assignmentType', () => {
- expect(
- mapStateToProps(state).selectedAssignmentType,
- ).toEqual(
- selectors.filters.assignmentType(state),
- );
- });
- });
- });
- describe('mapDispatchToProps', () => {
- test('filterAssignmentType', () => {
- expect(mapDispatchToProps.filterAssignmentType).toEqual(
- actions.filters.update.assignmentType,
- );
- });
- });
-});
diff --git a/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..b60179ed
--- /dev/null
+++ b/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,63 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CourseFilter component render if disabled snapshot 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`CourseFilter component render with selected assignment snapshot 1`] = `
+
+
+
+
+
+
+`;
diff --git a/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/test.jsx.snap
deleted file mode 100644
index beb2dc62..00000000
--- a/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/test.jsx.snap
+++ /dev/null
@@ -1,44 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`CourseGradeFilter Component snapshots basic snapshot 1`] = `
-
-
-
- }
- onChange={[MockFunction handleUpdateMin]}
- value="5"
- />
-
- }
- onChange={[MockFunction handleUpdateMax]}
- value="92"
- />
-
-
-
-
-
-`;
diff --git a/src/components/GradebookFilters/CourseGradeFilter/hooks.js b/src/components/GradebookFilters/CourseGradeFilter/hooks.js
new file mode 100644
index 00000000..3ca017d4
--- /dev/null
+++ b/src/components/GradebookFilters/CourseGradeFilter/hooks.js
@@ -0,0 +1,33 @@
+import { actions, selectors, thunkActions } from 'data/redux/hooks';
+
+export const useCourseGradeFilterData = ({
+ updateQueryParams,
+}) => {
+ const isDisabled = !selectors.app.useAreCourseGradeFiltersValid();
+ const localCourseLimits = selectors.app.useCourseGradeLimits();
+ const fetchGrades = thunkActions.grades.useFetchGrades();
+ const setLocalFilter = actions.app.useSetLocalFilter();
+ const updateFilter = actions.filters.useUpdateCourseGradeLimits();
+
+ const handleApplyClick = () => {
+ updateFilter(localCourseLimits);
+ fetchGrades();
+ updateQueryParams(localCourseLimits);
+ };
+
+ const { courseGradeMin, courseGradeMax } = localCourseLimits;
+ return {
+ max: {
+ value: courseGradeMax,
+ onChange: (e) => setLocalFilter({ courseGradeMax: e.target.value }),
+ },
+ min: {
+ value: courseGradeMin,
+ onChange: (e) => setLocalFilter({ courseGradeMin: e.target.value }),
+ },
+ handleApplyClick,
+ isDisabled,
+ };
+};
+
+export default useCourseGradeFilterData;
diff --git a/src/components/GradebookFilters/CourseGradeFilter/hooks.test.js b/src/components/GradebookFilters/CourseGradeFilter/hooks.test.js
new file mode 100644
index 00000000..545ead91
--- /dev/null
+++ b/src/components/GradebookFilters/CourseGradeFilter/hooks.test.js
@@ -0,0 +1,78 @@
+import { selectors, actions, thunkActions } from 'data/redux/hooks';
+
+import useCourseTypeFilterData from './hooks';
+
+jest.mock('data/redux/hooks', () => ({
+ selectors: {
+ app: {
+ useAreCourseGradeFiltersValid: jest.fn(),
+ useCourseGradeLimits: jest.fn(),
+ },
+ },
+ actions: {
+ app: { useSetLocalFilter: jest.fn() },
+ filters: { useUpdateCourseGradeLimits: jest.fn() },
+ },
+ thunkActions: {
+ grades: { useFetchGrades: jest.fn() },
+ },
+}));
+
+let out;
+
+const courseGradeLimits = { courseGradeMax: 120, courseGradeMin: 32 };
+selectors.app.useAreCourseGradeFiltersValid.mockReturnValue(true);
+selectors.app.useCourseGradeLimits.mockReturnValue(courseGradeLimits);
+
+const setLocalFilter = jest.fn();
+actions.app.useSetLocalFilter.mockReturnValue(setLocalFilter);
+const updateCourseGradeLimits = jest.fn();
+actions.filters.useUpdateCourseGradeLimits.mockReturnValue(updateCourseGradeLimits);
+const fetch = jest.fn();
+thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
+
+const testValue = 55;
+
+const updateQueryParams = jest.fn();
+
+describe('useCourseTypeFilterData hook', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ out = useCourseTypeFilterData({ updateQueryParams });
+ });
+ describe('behavior', () => {
+ it('initializes redux hooks', () => {
+ expect(selectors.app.useAreCourseGradeFiltersValid).toHaveBeenCalledWith();
+ expect(selectors.app.useCourseGradeLimits).toHaveBeenCalledWith();
+ expect(actions.app.useSetLocalFilter).toHaveBeenCalledWith();
+ expect(actions.filters.useUpdateCourseGradeLimits).toHaveBeenCalledWith();
+ expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
+ });
+ });
+ describe('output', () => {
+ it('returns isDisabled if assigmentFilterOptions is empty', () => {
+ expect(out.isDisabled).toEqual(false);
+ selectors.app.useAreCourseGradeFiltersValid.mockReturnValue(false);
+ out = useCourseTypeFilterData({ updateQueryParams });
+ expect(out.isDisabled).toEqual(true);
+ });
+ test('min value and onChange', () => {
+ const { courseGradeMin } = courseGradeLimits;
+ expect(out.min.value).toEqual(courseGradeMin);
+ out.min.onChange({ target: { value: testValue } });
+ expect(setLocalFilter).toHaveBeenCalledWith({ courseGradeMin: testValue });
+ });
+ test('max value and onChange', () => {
+ const { courseGradeMax } = courseGradeLimits;
+ expect(out.max.value).toEqual(courseGradeMax);
+ out.max.onChange({ target: { value: testValue } });
+ expect(setLocalFilter).toHaveBeenCalledWith({ courseGradeMax: testValue });
+ });
+ it('updates filter, fetches grades, and updates query params on apply click', () => {
+ out.handleApplyClick();
+ expect(updateCourseGradeLimits).toHaveBeenCalledWith(courseGradeLimits);
+ expect(fetch).toHaveBeenCalledWith();
+ expect(updateQueryParams).toHaveBeenCalledWith(courseGradeLimits);
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/CourseGradeFilter/index.jsx b/src/components/GradebookFilters/CourseGradeFilter/index.jsx
index ad0117f1..36995b99 100644
--- a/src/components/GradebookFilters/CourseGradeFilter/index.jsx
+++ b/src/components/GradebookFilters/CourseGradeFilter/index.jsx
@@ -1,103 +1,52 @@
-/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
-import React from 'react';
-import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
-import { FormattedMessage } from '@edx/frontend-platform/i18n';
-
-import selectors from 'data/selectors';
-import actions from 'data/actions';
-import thunkActions from 'data/thunkActions';
+import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import PercentGroup from '../PercentGroup';
-
-export class CourseGradeFilter extends React.Component {
- constructor(props) {
- super(props);
- this.handleApplyClick = this.handleApplyClick.bind(this);
- this.handleUpdateMin = this.handleUpdateMin.bind(this);
- this.handleUpdateMax = this.handleUpdateMax.bind(this);
- this.updateCourseGradeFilters = this.updateCourseGradeFilters.bind(this);
- }
-
- handleApplyClick() {
- if (this.props.areLimitsValid) {
- this.updateCourseGradeFilters();
- }
- }
-
- handleUpdateMin({ target: { value } }) {
- this.props.setLocalFilter({ courseGradeMin: value });
- }
-
- handleUpdateMax({ target: { value } }) {
- this.props.setLocalFilter({ courseGradeMax: value });
- }
-
- updateCourseGradeFilters() {
- this.props.updateFilter(this.props.localCourseLimits);
- this.props.fetchGrades();
- this.props.updateQueryParams(this.props.localCourseLimits);
- }
-
- render() {
- const {
- localCourseLimits: { courseGradeMin, courseGradeMax },
- } = this.props;
- return (
- <>
-
-
}
- value={courseGradeMin}
- onChange={this.handleUpdateMin}
- />
-
}
- value={courseGradeMax}
- onChange={this.handleUpdateMax}
- />
-
-
-
-
- >
- );
- }
-}
+import useCourseGradeFilterData from './hooks';
+
+export const CourseGradeFilter = ({ updateQueryParams }) => {
+ const {
+ max,
+ min,
+ isDisabled,
+ handleApplyClick,
+ } = useCourseGradeFilterData({ updateQueryParams });
+ const { formatMessage } = useIntl();
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
CourseGradeFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
-
- // Redux
- areLimitsValid: PropTypes.bool.isRequired,
- fetchGrades: PropTypes.func.isRequired,
- localCourseLimits: PropTypes.shape({
- courseGradeMin: PropTypes.string.isRequired,
- courseGradeMax: PropTypes.string.isRequired,
- }).isRequired,
- setLocalFilter: PropTypes.func.isRequired,
- updateFilter: PropTypes.func.isRequired,
-};
-
-export const mapStateToProps = (state) => ({
- areLimitsValid: selectors.app.areCourseGradeFiltersValid(state),
- localCourseLimits: selectors.app.courseGradeLimits(state),
-});
-
-export const mapDispatchToProps = {
- fetchGrades: thunkActions.grades.fetchGrades,
- setLocalFilter: actions.app.setLocalFilter,
- updateFilter: actions.filters.update.courseGradeLimits,
};
-export default connect(mapStateToProps, mapDispatchToProps)(CourseGradeFilter);
+export default CourseGradeFilter;
diff --git a/src/components/GradebookFilters/CourseGradeFilter/index.test.jsx b/src/components/GradebookFilters/CourseGradeFilter/index.test.jsx
new file mode 100644
index 00000000..881cb218
--- /dev/null
+++ b/src/components/GradebookFilters/CourseGradeFilter/index.test.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Button } from '@edx/paragon';
+
+import PercentGroup from '../PercentGroup';
+import useCourseGradeFilterData from './hooks';
+import CourseFilter from '.';
+
+jest.mock('../PercentGroup', () => 'PercentGroup');
+jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
+
+const hookData = {
+ handleChange: jest.fn(),
+ max: {
+ value: 300,
+ onChange: jest.fn(),
+ },
+ min: {
+ value: 23,
+ onChange: jest.fn(),
+ },
+ selectedCourse: 'test-assignment',
+ isDisabled: false,
+};
+useCourseGradeFilterData.mockReturnValue(hookData);
+
+const updateQueryParams = jest.fn();
+
+let el;
+describe('CourseFilter component', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ el = shallow(
);
+ });
+ describe('behavior', () => {
+ it('initializes hooks', () => {
+ expect(useCourseGradeFilterData).toHaveBeenCalledWith({ updateQueryParams });
+ expect(useIntl).toHaveBeenCalledWith();
+ });
+ });
+ describe('render', () => {
+ describe('with selected assignment', () => {
+ test('snapshot', () => {
+ expect(el).toMatchSnapshot();
+ });
+ it('renders a PercentGroup for both Max and Min filters', () => {
+ let props = el.find(PercentGroup).at(0).props();
+ expect(props.value).toEqual(hookData.min.value);
+ expect(props.onChange).toEqual(hookData.min.onChange);
+ props = el.find(PercentGroup).at(1).props();
+ expect(props.value).toEqual(hookData.max.value);
+ expect(props.onChange).toEqual(hookData.max.onChange);
+ });
+ it('renders a submit button', () => {
+ const props = el.find(Button).props();
+ expect(props.disabled).toEqual(false);
+ expect(props.onClick).toEqual(hookData.handleApplyClick);
+ });
+ });
+ describe('if disabled', () => {
+ beforeEach(() => {
+ useCourseGradeFilterData.mockReturnValueOnce({ ...hookData, isDisabled: true });
+ el = shallow(
);
+ });
+ test('snapshot', () => {
+ expect(el).toMatchSnapshot();
+ });
+ it('disables submit', () => {
+ const props = el.find(Button).props();
+ expect(props.disabled).toEqual(true);
+ });
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/CourseGradeFilter/test.jsx b/src/components/GradebookFilters/CourseGradeFilter/test.jsx
deleted file mode 100644
index af136e22..00000000
--- a/src/components/GradebookFilters/CourseGradeFilter/test.jsx
+++ /dev/null
@@ -1,150 +0,0 @@
-/* eslint-disable import/no-named-as-default */
-
-import React from 'react';
-import { shallow } from 'enzyme';
-
-import actions from 'data/actions';
-import selectors from 'data/selectors';
-import { fetchGrades } from 'data/thunkActions/grades';
-import {
- CourseGradeFilter,
- mapStateToProps,
- mapDispatchToProps,
-} from '.';
-
-jest.mock('@edx/paragon', () => ({
- Button: () => 'Button',
-}));
-jest.mock('../PercentGroup', () => 'PercentGroup');
-
-jest.mock('data/thunkActions/grades', () => ({
- fetchGrades: jest.fn(),
-}));
-
-jest.mock('data/selectors', () => ({
- __esModule: true,
- default: {
- app: {
- areCourseGradeFiltersValid: jest.fn(state => ({ areCourseGradeFiltersValid: state })),
- courseGradeLimits: jest.fn(state => ({ courseGradeLimits: state })),
- },
- },
-}));
-
-describe('CourseGradeFilter', () => {
- let props = {
- localCourseLimits: {
- courseGradeMin: '5',
- courseGradeMax: '92',
- },
- areLimitsValid: true,
- };
-
- beforeEach(() => {
- props = {
- ...props,
- fetchGrades: jest.fn(),
- setLocalFilter: jest.fn(),
- updateQueryParams: jest.fn(),
- updateFilter: jest.fn(),
- };
- });
-
- describe('Component', () => {
- describe('snapshots', () => {
- test('basic snapshot', () => {
- const el = shallow(
);
- el.instance().handleUpdateMin = jest.fn().mockName(
- 'handleUpdateMin',
- );
- el.instance().handleUpdateMax = jest.fn().mockName(
- 'handleUpdateMax',
- );
- el.instance().handleApplyClick = jest.fn().mockName(
- 'handleApplyClick',
- );
- expect(el.instance().render()).toMatchSnapshot();
- });
- });
-
- describe('behavior', () => {
- let el;
- const testVal = 'TESTvalue';
- beforeEach(() => {
- el = shallow(
);
- });
- describe('handleApplyClick', () => {
- beforeEach(() => {
- el.instance().updateCourseGradeFilters = jest.fn();
- });
- it('calls updateCourseGradeFilters is limits are valid', () => {
- el.instance().handleApplyClick();
- expect(el.instance().updateCourseGradeFilters).toHaveBeenCalledWith();
- });
- it('does not call updateCourseGradeFilters if limits are not valid', () => {
- el.setProps({ areLimitsValid: false });
- el.instance().handleApplyClick();
- expect(el.instance().updateCourseGradeFilters).not.toHaveBeenCalled();
- });
- });
- describe('updateCourseGradeFilters', () => {
- beforeEach(() => {
- el.instance().updateCourseGradeFilters();
- });
- it('calls props.updateFilter with selection', () => {
- expect(props.updateFilter).toHaveBeenCalledWith(props.localCourseLimits);
- });
- it('calls props.getUserGrades with selection', () => {
- expect(props.fetchGrades).toHaveBeenCalledWith();
- });
- it('updates query params with courseGradeMin and courseGradeMax', () => {
- expect(props.updateQueryParams).toHaveBeenCalledWith(props.localCourseLimits);
- });
- });
- describe('handleUpdateMin', () => {
- it('calls props.setCourseGradeMin with event value', () => {
- el.instance().handleUpdateMin(
- { target: { value: testVal } },
- );
- expect(props.setLocalFilter).toHaveBeenCalledWith({
- courseGradeMin: testVal,
- });
- });
- });
- describe('handleUpdateMax', () => {
- it('calls props.setCourseGradeMax with event value', () => {
- el.instance().handleUpdateMax(
- { target: { value: testVal } },
- );
- expect(props.setLocalFilter).toHaveBeenCalledWith({
- courseGradeMax: testVal,
- });
- });
- });
- });
- });
- describe('mapStateToProps', () => {
- const testState = { peanut: 'butter', jelly: 'time' };
- let mapped;
- beforeEach(() => {
- mapped = mapStateToProps(testState);
- });
- test('areLimitsValid from app.areCourseGradeFiltersValid', () => {
- expect(mapped.areLimitsValid).toEqual(selectors.app.areCourseGradeFiltersValid(testState));
- });
- test('localCourseLimits from app.courseGradeLimits', () => {
- expect(mapped.localCourseLimits).toEqual(selectors.app.courseGradeLimits(testState));
- });
- });
- describe('mapDispatchToProps', () => {
- test('fetchGrades from thunkActions.grades.fetchGrades', () => {
- expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades);
- });
- test('setLocalFilter from actions.app.setLocalFilter', () => {
- expect(mapDispatchToProps.setLocalFilter).toEqual(actions.app.setLocalFilter);
- });
- test('updateFilter from actions.filters.update.courseGradeLimits', () => {
- expect(mapDispatchToProps.updateFilter).toEqual(actions.filters.update.courseGradeLimits);
- });
- });
-});
diff --git a/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..d1fae6f2
--- /dev/null
+++ b/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,72 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`StudentGroupsFilter component render snapshot 1`] = `
+
+
+ Track-All
+ ,
+ ,
+ ,
+ ,
+ ,
+ ]
+ }
+ value="test-track"
+ />
+
+ Cohort-All
+ ,
+ ,
+ ,
+ ,
+ ]
+ }
+ value="test-cohort"
+ />
+
+`;
diff --git a/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/test.jsx.snap
deleted file mode 100644
index 0e07561c..00000000
--- a/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/test.jsx.snap
+++ /dev/null
@@ -1,190 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`StudentGroupsFilter Component snapshots Cohorts group disabled if no cohorts 1`] = `
-
-
- Track-All
- ,
- ,
- ,
- ,
- ]
- }
- value="TracK2"
- />
-
- Cohort-All
- ,
- ]
- }
- value="cohorT3"
- />
-
-`;
-
-exports[`StudentGroupsFilter Component snapshots basic snapshot 1`] = `
-
-
- Track-All
- ,
- ,
- ,
- ,
- ]
- }
- value="TracK2"
- />
-
- Cohort-All
- ,
- ,
- ,
- ,
- ]
- }
- value="cohorT3"
- />
-
-`;
-
-exports[`StudentGroupsFilter Component snapshots mapCohortsEntries cohort options: [Cohort-All, <{slug, name}...>] 1`] = `
-Array [
-
,
-
,
-
,
-
,
-]
-`;
-
-exports[`StudentGroupsFilter Component snapshots mapTracksEntries cohort options: [Track-All, <{id, name}...>] 1`] = `
-Array [
-
,
-
,
-
,
-
,
-]
-`;
-
-exports[`StudentGroupsFilter optionFactory returns a list of options with a default first entry 1`] = `
-Array [
-
,
-
,
-
,
-]
-`;
diff --git a/src/components/GradebookFilters/StudentGroupsFilter/hooks.js b/src/components/GradebookFilters/StudentGroupsFilter/hooks.js
new file mode 100644
index 00000000..d304b56c
--- /dev/null
+++ b/src/components/GradebookFilters/StudentGroupsFilter/hooks.js
@@ -0,0 +1,45 @@
+import { actions, selectors, thunkActions } from 'data/redux/hooks';
+
+export const useStudentGroupsFilterData = ({ updateQueryParams }) => {
+ const selectedCohortEntry = selectors.root.useSelectedCohortEntry();
+ const selectedTrackEntry = selectors.root.useSelectedTrackEntry();
+
+ const cohorts = selectors.cohorts.useAllCohorts();
+ const tracks = selectors.tracks.useAllTracks();
+
+ const updateCohort = actions.filters.useUpdateCohort();
+ const updateTrack = actions.filters.useUpdateTrack();
+
+ const fetchGrades = thunkActions.grades.useFetchGrades();
+
+ const handleUpdateTrack = (event) => {
+ const selectedTrackItem = tracks.find(track => track.name === event.target.value);
+ const track = selectedTrackItem ? selectedTrackItem.slug.toString() : null;
+ updateQueryParams({ track });
+ updateTrack(track);
+ fetchGrades();
+ };
+
+ const handleUpdateCohort = (event) => {
+ const selectedCohortItem = cohorts.find(cohort => cohort.name === event.target.value);
+ const cohort = selectedCohortItem ? selectedCohortItem.id.toString() : null;
+ updateQueryParams({ cohort });
+ updateCohort(cohort);
+ fetchGrades();
+ };
+ return {
+ cohorts: {
+ value: selectedCohortEntry?.name || '',
+ isDisabled: cohorts.length === 0,
+ handleChange: handleUpdateCohort,
+ entries: cohorts.map(({ id: value, name }) => ({ value, name })),
+ },
+ tracks: {
+ value: selectedTrackEntry?.name || '',
+ handleChange: handleUpdateTrack,
+ entries: tracks.map(({ slug: value, name }) => ({ value, name })),
+ },
+ };
+};
+
+export default useStudentGroupsFilterData;
diff --git a/src/components/GradebookFilters/StudentGroupsFilter/hooks.test.js b/src/components/GradebookFilters/StudentGroupsFilter/hooks.test.js
new file mode 100644
index 00000000..6759f8e5
--- /dev/null
+++ b/src/components/GradebookFilters/StudentGroupsFilter/hooks.test.js
@@ -0,0 +1,141 @@
+import { selectors, actions, thunkActions } from 'data/redux/hooks';
+
+import useAssignmentFilterData from './hooks';
+
+jest.mock('data/redux/hooks', () => ({
+ selectors: {
+ root: {
+ useSelectedCohortEntry: jest.fn(),
+ useSelectedTrackEntry: jest.fn(),
+ },
+ cohorts: { useAllCohorts: jest.fn() },
+ tracks: { useAllTracks: jest.fn() },
+ },
+ actions: {
+ filters: {
+ useUpdateCohort: jest.fn(),
+ useUpdateTrack: jest.fn(),
+ },
+ },
+ thunkActions: {
+ grades: { useFetchGrades: jest.fn() },
+ },
+}));
+
+let out;
+
+const testCohort = { name: 'cohort-name', id: 999 };
+selectors.root.useSelectedCohortEntry.mockReturnValue(testCohort);
+const testTrack = { name: 'track-name', slug: 8080 };
+selectors.root.useSelectedTrackEntry.mockReturnValue(testTrack);
+const allCohorts = [
+ testCohort,
+ { name: 'cohort1', id: 11 },
+ { name: 'cohort2', id: 22 },
+ { name: 'cohort3', id: 33 },
+];
+selectors.cohorts.useAllCohorts.mockReturnValue(allCohorts);
+const allTracks = [
+ testTrack,
+ { name: 'track1', slug: 111 },
+ { name: 'track2', slug: 222 },
+ { name: 'track3', slug: 333 },
+];
+selectors.tracks.useAllTracks.mockReturnValue(allTracks);
+
+const updateCohort = jest.fn();
+actions.filters.useUpdateCohort.mockReturnValue(updateCohort);
+const updateTrack = jest.fn();
+actions.filters.useUpdateTrack.mockReturnValue(updateTrack);
+const fetch = jest.fn();
+thunkActions.grades.useFetchGrades.mockReturnValue(fetch);
+
+const updateQueryParams = jest.fn();
+
+describe('useAssignmentFilterData hook', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ out = useAssignmentFilterData({ updateQueryParams });
+ });
+ describe('behavior', () => {
+ it('initializes redux hooks', () => {
+ expect(selectors.root.useSelectedCohortEntry).toHaveBeenCalledWith();
+ expect(selectors.root.useSelectedTrackEntry).toHaveBeenCalledWith();
+ expect(selectors.cohorts.useAllCohorts).toHaveBeenCalledWith();
+ expect(selectors.tracks.useAllTracks).toHaveBeenCalledWith();
+ expect(actions.filters.useUpdateCohort).toHaveBeenCalledWith();
+ expect(actions.filters.useUpdateTrack).toHaveBeenCalledWith();
+ expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
+ });
+ });
+ describe('output', () => {
+ describe('cohorts', () => {
+ test('value from hook', () => {
+ expect(out.cohorts.value).toEqual(testCohort.name);
+ });
+ test('disabled iff no cohorts found', () => {
+ expect(out.cohorts.isDisabled).toEqual(false);
+ selectors.cohorts.useAllCohorts.mockReturnValueOnce([]);
+ out = useAssignmentFilterData({ updateQueryParams });
+ expect(out.cohorts.isDisabled).toEqual(true);
+ });
+ test('entries map id to value', () => {
+ const { entries } = out.cohorts;
+ expect(entries[0]).toEqual({ value: testCohort.id, name: testCohort.name });
+ expect(entries[1]).toEqual({ value: allCohorts[1].id, name: allCohorts[1].name });
+ expect(entries[2]).toEqual({ value: allCohorts[2].id, name: allCohorts[2].name });
+ expect(entries[3]).toEqual({ value: allCohorts[3].id, name: allCohorts[3].name });
+ });
+ test('value defaults to empty string', () => {
+ selectors.root.useSelectedCohortEntry.mockReturnValueOnce(null);
+ out = useAssignmentFilterData({ updateQueryParams });
+ expect(out.cohorts.value).toEqual('');
+ });
+ describe('handleEvent', () => {
+ it('updates filter and query params and fetches grades', () => {
+ out.cohorts.handleChange({ target: { value: testCohort.name } });
+ expect(updateCohort).toHaveBeenCalledWith(testCohort.id.toString());
+ expect(updateQueryParams).toHaveBeenCalledWith({ cohort: testCohort.id.toString() });
+ expect(fetch).toHaveBeenCalled();
+ });
+ it('passes null if no matching track is found', () => {
+ out.cohorts.handleChange({ target: { value: 'fake-name' } });
+ expect(updateCohort).toHaveBeenCalledWith(null);
+ expect(updateQueryParams).toHaveBeenCalledWith({ cohort: null });
+ expect(fetch).toHaveBeenCalled();
+ });
+ });
+ });
+ describe('tracks', () => {
+ test('value from hook', () => {
+ expect(out.tracks.value).toEqual(testTrack.name);
+ });
+ test('entries map slug to value', () => {
+ const { entries } = out.tracks;
+ expect(entries[0]).toEqual({ value: testTrack.slug, name: testTrack.name });
+ expect(entries[1]).toEqual({ value: allTracks[1].slug, name: allTracks[1].name });
+ expect(entries[2]).toEqual({ value: allTracks[2].slug, name: allTracks[2].name });
+ expect(entries[3]).toEqual({ value: allTracks[3].slug, name: allTracks[3].name });
+ });
+ test('value defaults to empty string', () => {
+ selectors.root.useSelectedTrackEntry.mockReturnValueOnce(null);
+ out = useAssignmentFilterData({ updateQueryParams });
+ expect(out.tracks.value).toEqual('');
+ });
+ describe('handleEvent', () => {
+ it('updates filter and query params and fetches grades', () => {
+ out.tracks.handleChange({ target: { value: testTrack.name } });
+ expect(updateTrack).toHaveBeenCalledWith(testTrack.slug.toString());
+ expect(updateQueryParams).toHaveBeenCalledWith({ track: testTrack.slug.toString() });
+ expect(fetch).toHaveBeenCalled();
+ });
+ it('passes null if no matching track is found', () => {
+ out.tracks.handleChange({ target: { value: 'fake-name' } });
+ expect(updateTrack).toHaveBeenCalledWith(null);
+ expect(updateQueryParams).toHaveBeenCalledWith({ track: null });
+ expect(fetch).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/StudentGroupsFilter/index.jsx b/src/components/GradebookFilters/StudentGroupsFilter/index.jsx
index e1fd0c70..8ae31112 100644
--- a/src/components/GradebookFilters/StudentGroupsFilter/index.jsx
+++ b/src/components/GradebookFilters/StudentGroupsFilter/index.jsx
@@ -1,152 +1,53 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
-import { connect } from 'react-redux';
import PropTypes from 'prop-types';
-import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-
-import actions from 'data/actions';
-import selectors from 'data/selectors';
-import thunkActions from 'data/thunkActions';
+import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import SelectGroup from '../SelectGroup';
-
-export const optionFactory = ({ data, defaultOption, key }) => [
-
,
- ...data.map(
- entry => (
),
- ),
-];
-
-export class StudentGroupsFilter extends React.Component {
- constructor(props) {
- super(props);
- this.mapCohortsEntries = this.mapCohortsEntries.bind(this);
- this.mapTracksEntries = this.mapTracksEntries.bind(this);
- this.updateCohorts = this.updateCohorts.bind(this);
- this.updateTracks = this.updateTracks.bind(this);
- }
-
- mapCohortsEntries() {
- return optionFactory({
- data: this.props.cohorts,
- defaultOption: this.translate(messages.cohortAll),
- key: 'id',
- });
- }
-
- mapTracksEntries() {
- return optionFactory({
- data: this.props.tracks,
- defaultOption: this.translate(messages.trackAll),
- key: 'slug',
- });
- }
-
- selectedTrackSlugFromEvent({ target: { value } }) {
- const selectedTrackItem = this.props.tracksByName[value];
- return selectedTrackItem ? selectedTrackItem.slug : null;
- }
-
- selectedCohortIdFromEvent({ target: { value } }) {
- const selectedCohortItem = this.props.cohortsByName[value];
- return selectedCohortItem ? selectedCohortItem.id.toString() : null;
- }
-
- updateTracks(event) {
- const track = this.selectedTrackSlugFromEvent(event);
- this.props.updateQueryParams({ track });
- this.props.updateTrack(track);
- this.props.fetchGrades();
- }
-
- updateCohorts(event) {
- const cohort = this.selectedCohortIdFromEvent(event);
- this.props.updateQueryParams({ cohort });
- this.props.updateCohort(cohort);
- this.props.fetchGrades();
- }
-
- translate(message) {
- return this.props.intl.formatMessage(message);
- }
-
- render() {
- return (
- <>
-
-
- >
- );
- }
-}
-
-StudentGroupsFilter.defaultProps = {
- cohorts: [],
- cohortsByName: {},
- selectedCohortEntry: { name: '' },
- selectedTrackEntry: { name: '' },
- tracks: [],
- tracksByName: {},
+import useStudentGroupsFilterData from './hooks';
+
+const mapOptions = ({ value, name }) => (
+
+);
+
+export const StudentGroupsFilter = ({ updateQueryParams }) => {
+ const { tracks, cohorts } = useStudentGroupsFilterData({ updateQueryParams });
+ const { formatMessage } = useIntl();
+ return (
+ <>
+
+ {formatMessage(messages.trackAll)}
+ ,
+ ...tracks.entries.map(mapOptions),
+ ]}
+ />
+
+ {formatMessage(messages.cohortAll)}
+ ,
+ ...cohorts.entries.map(mapOptions),
+ ]}
+ />
+ >
+ );
};
StudentGroupsFilter.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
-
- // injected
- intl: intlShape.isRequired,
-
- // redux
- cohorts: PropTypes.arrayOf(PropTypes.shape({
- name: PropTypes.string,
- id: PropTypes.number,
- })),
- cohortsByName: PropTypes.objectOf(PropTypes.shape({
- name: PropTypes.string,
- id: PropTypes.number,
- })),
- fetchGrades: PropTypes.func.isRequired,
- selectedTrackEntry: PropTypes.shape({ name: PropTypes.string }),
- selectedCohortEntry: PropTypes.shape({ name: PropTypes.string }),
- tracks: PropTypes.arrayOf(PropTypes.shape({
- name: PropTypes.string,
- slug: PropTypes.string,
- })),
- tracksByName: PropTypes.objectOf(PropTypes.shape({
- name: PropTypes.string,
- slug: PropTypes.string,
- })),
- updateCohort: PropTypes.func.isRequired,
- updateTrack: PropTypes.func.isRequired,
-};
-
-export const mapStateToProps = (state) => ({
- cohorts: selectors.cohorts.allCohorts(state),
- cohortsByName: selectors.cohorts.cohortsByName(state),
- selectedCohortEntry: selectors.root.selectedCohortEntry(state),
- selectedTrackEntry: selectors.root.selectedTrackEntry(state),
- tracks: selectors.tracks.allTracks(state),
- tracksByName: selectors.tracks.tracksByName(state),
-});
-
-export const mapDispatchToProps = {
- fetchGrades: thunkActions.grades.fetchGrades,
- updateCohort: actions.filters.update.cohort,
- updateTrack: actions.filters.update.track,
};
-export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(StudentGroupsFilter));
+export default StudentGroupsFilter;
diff --git a/src/components/GradebookFilters/StudentGroupsFilter/index.test.jsx b/src/components/GradebookFilters/StudentGroupsFilter/index.test.jsx
new file mode 100644
index 00000000..ee339d4a
--- /dev/null
+++ b/src/components/GradebookFilters/StudentGroupsFilter/index.test.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import SelectGroup from '../SelectGroup';
+import useStudentGroupsFilterData from './hooks';
+import StudentGroupsFilter from '.';
+
+jest.mock('../SelectGroup', () => 'SelectGroup');
+jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
+
+const props = {
+ cohorts: {
+ value: 'test-cohort',
+ entries: [
+ { value: 'v1', name: 'n1' },
+ { value: 'v2', name: 'n2' },
+ { value: 'v3', name: 'n3' },
+ ],
+ handleChange: jest.fn(),
+ isDisabled: false,
+ },
+ tracks: {
+ value: 'test-track',
+ entries: [
+ { value: 'v1', name: 'n1' },
+ { value: 'v2', name: 'n2' },
+ { value: 'v3', name: 'n3' },
+ { value: 'v4', name: 'n4' },
+ ],
+ handleChange: jest.fn(),
+ },
+};
+useStudentGroupsFilterData.mockReturnValue(props);
+const updateQueryParams = jest.fn();
+
+let el;
+describe('StudentGroupsFilter component', () => {
+ beforeAll(() => {
+ jest.clearAllMocks();
+ el = shallow();
+ });
+ describe('behavior', () => {
+ it('initializes hooks', () => {
+ expect(useStudentGroupsFilterData).toHaveBeenCalledWith({ updateQueryParams });
+ expect(useIntl).toHaveBeenCalledWith();
+ });
+ });
+ describe('render', () => {
+ test('snapshot', () => {
+ expect(el).toMatchSnapshot();
+ });
+ test('track options', () => {
+ const {
+ options,
+ onChange,
+ value,
+ } = el.find(SelectGroup).at(0).props();
+ expect(value).toEqual(props.tracks.value);
+ expect(onChange).toEqual(props.tracks.handleChange);
+ expect(options.length).toEqual(5);
+ const testEntry = props.tracks.entries[0];
+ const optionProps = options[1].props;
+ expect(optionProps.value).toEqual(testEntry.value);
+ expect(optionProps.children).toEqual(testEntry.name);
+ });
+ test('cohort options', () => {
+ const {
+ options,
+ onChange,
+ disabled,
+ value,
+ } = el.find(SelectGroup).at(1).props();
+ expect(value).toEqual(props.cohorts.value);
+ expect(disabled).toEqual(false);
+ expect(onChange).toEqual(props.cohorts.handleChange);
+ expect(options.length).toEqual(4);
+ const testEntry = props.cohorts.entries[0];
+ const optionProps = options[1].props;
+ expect(optionProps.value).toEqual(testEntry.value);
+ expect(optionProps.children).toEqual(testEntry.name);
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/StudentGroupsFilter/test.jsx b/src/components/GradebookFilters/StudentGroupsFilter/test.jsx
deleted file mode 100644
index 38f5c62c..00000000
--- a/src/components/GradebookFilters/StudentGroupsFilter/test.jsx
+++ /dev/null
@@ -1,239 +0,0 @@
-/* eslint-disable import/no-named-as-default */
-
-import React from 'react';
-import { shallow } from 'enzyme';
-
-import { fetchGrades } from 'data/thunkActions/grades';
-import actions from 'data/actions';
-import selectors from 'data/selectors';
-import {
- optionFactory,
- StudentGroupsFilter,
- mapStateToProps,
- mapDispatchToProps,
-} from '.';
-
-jest.mock('data/selectors', () => ({
- __esModule: true,
- default: {
- root: {
- selectedCohortEntry: jest.fn(state => ({ selectedCohortEntry: state })),
- selectedTrackEntry: jest.fn(state => ({ selectedTrackEntry: state })),
- },
- cohorts: {
- allCohorts: jest.fn(state => ({ allCohorts: state })),
- cohortsByName: jest.fn(state => ({ cohortsByName: state })),
- },
- tracks: {
- allTracks: jest.fn(state => ({ allTracks: state })),
- tracksByName: jest.fn(state => ({ tracksByName: state })),
- },
- },
-}));
-
-jest.mock('data/thunkActions/grades', () => ({
- fetchGrades: jest.fn(),
-}));
-
-describe('StudentGroupsFilter', () => {
- let props = {
- cohorts: [
- { name: 'cohorT1', id: 8001 },
- { name: 'cohorT2', id: 8002 },
- { name: 'cohorT3', id: 8003 },
- ],
- tracks: [
- { name: 'TracK1', slug: 'TracK1_slug' },
- { name: 'TracK2', slug: 'TracK2_slug' },
- { name: 'TRACK3', slug: 'TRACK3_slug' },
- ],
- };
-
- describe('optionFactory', () => {
- it('returns a list of options with a default first entry', () => {
- const data = [{ cMark: 'rainbow', name: 'RDash' }, { cMark: 'balloons', name: 'PPie' }];
- const defaultOption = 'All-Ponies';
- const key = 'cMark';
- const options = optionFactory({ data, defaultOption, key });
- expect(options).toMatchSnapshot();
- });
- });
-
- describe('Component', () => {
- beforeEach(() => {
- props = {
- ...props,
- intl: { formatMessage: (msg) => msg.defaultMessage },
- cohortsByName: {
- [props.cohorts[0].name]: props.cohorts[0],
- [props.cohorts[1].name]: props.cohorts[1],
- [props.cohorts[2].name]: props.cohorts[2],
- },
- tracksByName: {
- [props.tracks[0].name]: props.tracks[0],
- [props.tracks[1].name]: props.tracks[1],
- [props.tracks[2].name]: props.tracks[2],
- },
- fetchGrades: jest.fn(),
- selectedCohortEntry: props.cohorts[2],
- selectedTrackEntry: props.tracks[1],
- updateQueryParams: jest.fn(),
- updateCohort: jest.fn().mockName('updateCohort'),
- updateTrack: jest.fn().mockName('updateTrack'),
- };
- });
-
- describe('snapshots', () => {
- let el;
- beforeEach(() => {
- el = shallow();
- });
- test('basic snapshot', () => {
- el.instance().updateTracks = jest.fn().mockName(
- 'updateTracks',
- );
- el.instance().updateCohorts = jest.fn().mockName(
- 'updateCohorts',
- );
- expect(el.instance().render()).toMatchSnapshot();
- });
- test('Cohorts group disabled if no cohorts', () => {
- el.setProps({ cohorts: [] });
- expect(el.instance().render()).toMatchSnapshot();
- });
- describe('mapCohortsEntries', () => {
- test('cohort options: [Cohort-All, <{slug, name}...>]', () => {
- expect(el.instance().mapCohortsEntries()).toMatchSnapshot();
- });
- });
- describe('mapTracksEntries', () => {
- test('cohort options: [Track-All, <{id, name}...>]', () => {
- expect(el.instance().mapTracksEntries()).toMatchSnapshot();
- });
- });
- });
-
- describe('behavior', () => {
- let el;
- beforeEach(() => {
- el = shallow();
- });
- describe('selectedCohortIdFromEvent', () => {
- it('returns the id of the cohort with the name matching the event', () => {
- expect(
- el.instance().selectedCohortIdFromEvent(
- { target: { value: props.cohorts[1].name } },
- ),
- ).toEqual(props.cohorts[1].id.toString());
- });
- it('returns null if no matching cohort is found', () => {
- expect(
- el.instance().selectedCohortIdFromEvent(
- { target: { value: 'FAKE' } },
- ),
- ).toEqual(null);
- });
- });
- describe('selectedTrackSlugFromEvent', () => {
- it('returns the slug of the track with the name matching the event', () => {
- expect(
- el.instance().selectedTrackSlugFromEvent(
- { target: { value: props.tracks[1].name } },
- ),
- ).toEqual(props.tracks[1].slug);
- });
- it('returns null if no matching track is found', () => {
- expect(
- el.instance().selectedTrackSlugFromEvent(
- { target: { value: 'FAKE' } },
- ),
- ).toEqual(null);
- });
- });
- describe('updateTracks', () => {
- const selectedSlug = 'SLUG';
- beforeEach(() => {
- el = shallow();
- jest.spyOn(
- el.instance(),
- 'selectedTrackSlugFromEvent',
- ).mockReturnValue(selectedSlug);
- el.instance().updateTracks({ target: {} });
- });
- it('calls updateTrack with new value', () => {
- expect(props.updateTrack).toHaveBeenCalledWith(selectedSlug);
- });
- it('calls fetchGrades', () => {
- expect(props.fetchGrades).toHaveBeenCalledWith();
- });
- it('updates queryParams with track value', () => {
- expect(props.updateQueryParams).toHaveBeenCalledWith({
- track: selectedSlug,
- });
- });
- });
- describe('updateCohorts', () => {
- const selectedId = 23;
- beforeEach(() => {
- el = shallow();
- jest.spyOn(
- el.instance(),
- 'selectedCohortIdFromEvent',
- ).mockReturnValue(selectedId);
- el.instance().updateCohorts({ target: {} });
- });
- it('calls updateCohort with new value', () => {
- expect(props.updateCohort).toHaveBeenCalledWith(selectedId);
- });
- it('calls fetchGrades', () => {
- expect(props.fetchGrades).toHaveBeenCalledWith();
- });
- it('updates queryParams with cohort value', () => {
- expect(props.updateQueryParams).toHaveBeenCalledWith({
- cohort: selectedId,
- });
- });
- });
- });
- });
- describe('mapStateToProps', () => {
- const testState = { h: 'e', l: 'l', o: 'oooooooooo' };
- let mappedProps;
- beforeAll(() => {
- mappedProps = mapStateToProps(testState);
- });
- test('cohorts from selectors.cohorts.allCohorts', () => {
- expect(mappedProps.cohorts).toEqual(selectors.cohorts.allCohorts(testState));
- });
- test('cohortsByName from selectors.cohorts.cohortsByName', () => {
- expect(mappedProps.cohortsByName).toEqual(selectors.cohorts.cohortsByName(testState));
- });
- test('selectedCohortEntry from selectors.root.selectedCohortEntry', () => {
- expect(
- mappedProps.selectedCohortEntry,
- ).toEqual(selectors.root.selectedCohortEntry(testState));
- });
- test('selectedTrackEntry from selectors.root.selectedTrackEntry', () => {
- expect(
- mappedProps.selectedTrackEntry,
- ).toEqual(selectors.root.selectedTrackEntry(testState));
- });
- test('tracks from selectors.tracks.allTracks', () => {
- expect(mappedProps.tracks).toEqual(selectors.tracks.allTracks(testState));
- });
- test('tracksByName from selectors.tracks.tracksByName', () => {
- expect(mappedProps.tracksByName).toEqual(selectors.tracks.tracksByName(testState));
- });
- });
- describe('mapDispatchToProps', () => {
- test('fetchGrades from thunkActions.grades.fetchGrades', () => {
- expect(mapDispatchToProps.fetchGrades).toEqual(fetchGrades);
- });
- test('updateCohort from actions.filters.update.cohort', () => {
- expect(mapDispatchToProps.updateCohort).toEqual(actions.filters.update.cohort);
- });
- test('updateTrack from actions.filters.update.track', () => {
- expect(mapDispatchToProps.updateTrack).toEqual(actions.filters.update.track);
- });
- });
-});
diff --git a/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap b/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap
index b22762b4..c0958d27 100644
--- a/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap
+++ b/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap
@@ -4,29 +4,22 @@ exports[`PercentGroup Component snapshots basic snapshot 1`] = `
-
-
+
Group Label
-
-
+
-
+
@@ -39,29 +32,22 @@ exports[`PercentGroup Component snapshots disabled 1`] = `
-
-
+
Group Label
-
-
+
-
+
diff --git a/src/components/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap b/src/components/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap
index 7aab3c2d..07a5fc0f 100644
--- a/src/components/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap
+++ b/src/components/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap
@@ -4,22 +4,16 @@ exports[`SelectGroup Component snapshots basic snapshot 1`] = `
-
-
+
Group Label
-
-
+
-
-
+
+
`;
@@ -49,22 +43,16 @@ exports[`SelectGroup Component snapshots disabled 1`] = `
-
-
+
Group Label
-
-
+
-
-
+
+
`;
diff --git a/src/components/GradebookFilters/__snapshots__/index.test.jsx.snap b/src/components/GradebookFilters/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..4ee3e950
--- /dev/null
+++ b/src/components/GradebookFilters/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,70 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`GradebookFilters render snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Include Course Team Members
+
+
+
+`;
diff --git a/src/components/GradebookFilters/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/__snapshots__/test.jsx.snap
deleted file mode 100644
index 50b4834a..00000000
--- a/src/components/GradebookFilters/__snapshots__/test.jsx.snap
+++ /dev/null
@@ -1,98 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`GradebookFilters Component snapshots basic snapshot 1`] = `
-
-
-
-
-
-
-
-
- }
- >
-
-
-
- }
- >
-
-
-
- }
- >
-
-
-
- }
- >
-
-
-
-
-
-`;
diff --git a/src/components/GradebookFilters/hooks.js b/src/components/GradebookFilters/hooks.js
new file mode 100644
index 00000000..e55a7180
--- /dev/null
+++ b/src/components/GradebookFilters/hooks.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import { StrictDict } from 'utils';
+import { actions, selectors, thunkActions } from 'data/redux/hooks';
+import * as module from './hooks';
+
+export const state = StrictDict({
+ includeCourseRoleMembers: (val) => React.useState(val), // eslint-disable-line
+});
+
+export const useGradebookFiltersData = ({ updateQueryParams }) => {
+ const includeCourseRoleMembers = selectors.filters.useIncludeCourseRoleMembers();
+ const updateIncludeCourseRoleMembers = actions.filters.useUpdateIncludeCourseRoleMembers();
+ const closeMenu = thunkActions.app.useCloseFilterMenu();
+ const fetchGrades = thunkActions.grades.useFetchGrades();
+
+ const [
+ localIncludeCourseRoleMembers,
+ setLocalIncludeCourseRoleMembers,
+ ] = module.state.includeCourseRoleMembers(includeCourseRoleMembers);
+
+ const handleIncludeTeamMembersChange = ({ target: { checked } }) => {
+ setLocalIncludeCourseRoleMembers(checked);
+ updateIncludeCourseRoleMembers(checked);
+ fetchGrades();
+ updateQueryParams({ includeCourseRoleMembers: checked });
+ };
+ return {
+ closeMenu,
+ includeCourseTeamMembers: {
+ handleChange: handleIncludeTeamMembersChange,
+ value: localIncludeCourseRoleMembers,
+ },
+ };
+};
+
+export default useGradebookFiltersData;
diff --git a/src/components/GradebookFilters/hooks.test.jsx b/src/components/GradebookFilters/hooks.test.jsx
new file mode 100644
index 00000000..de44b0fa
--- /dev/null
+++ b/src/components/GradebookFilters/hooks.test.jsx
@@ -0,0 +1,65 @@
+import { MockUseState } from 'testUtils';
+import { actions, selectors, thunkActions } from 'data/redux/hooks';
+import * as hooks from './hooks';
+
+jest.mock('data/redux/hooks', () => ({
+ actions: {
+ filters: { useUpdateIncludeCourseRoleMembers: jest.fn() },
+ },
+ selectors: {
+ filters: { useIncludeCourseRoleMembers: jest.fn() },
+ },
+ thunkActions: {
+ app: { useCloseFilterMenu: jest.fn() },
+ grades: { useFetchGrades: jest.fn() },
+ },
+}));
+
+const state = new MockUseState(hooks);
+
+selectors.filters.useIncludeCourseRoleMembers.mockReturnValue(true);
+const updateIncludeCourseRoleMembers = jest.fn();
+actions.filters.useUpdateIncludeCourseRoleMembers.mockReturnValue(updateIncludeCourseRoleMembers);
+const closeFilterMenu = jest.fn();
+thunkActions.app.useCloseFilterMenu.mockReturnValue(closeFilterMenu);
+const fetchGrades = jest.fn();
+thunkActions.grades.useFetchGrades.mockReturnValue(fetchGrades);
+
+const updateQueryParams = jest.fn();
+
+let out;
+describe('GradebookFiltersData component hooks', () => {
+ describe('state', () => {
+ state.testGetter(state.keys.includeCourseRoleMembers);
+ });
+ describe('useGradebookFiltersData', () => {
+ beforeEach(() => {
+ state.mock();
+ out = hooks.useGradebookFiltersData({ updateQueryParams });
+ });
+ describe('behavior', () => {
+ it('initializes hooks', () => {
+ expect(actions.filters.useUpdateIncludeCourseRoleMembers).toHaveBeenCalledWith();
+ expect(selectors.filters.useIncludeCourseRoleMembers).toHaveBeenCalledWith();
+ expect(thunkActions.app.useCloseFilterMenu).toHaveBeenCalledWith();
+ expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
+ });
+ });
+ describe('output', () => {
+ test('closeMenu', () => {
+ expect(out.closeMenu).toEqual(closeFilterMenu);
+ });
+ test('includeCourseTeamMembers value', () => {
+ expect(out.includeCourseTeamMembers.value).toEqual(true);
+ });
+ test('includeCourseTeamMembers handleChange', () => {
+ const event = { target: { checked: false } };
+ out.includeCourseTeamMembers.handleChange(event);
+ expect(state.setState.includeCourseRoleMembers).toHaveBeenCalledWith(false);
+ expect(updateIncludeCourseRoleMembers).toHaveBeenCalledWith(false);
+ expect(fetchGrades).toHaveBeenCalledWith();
+ expect(updateQueryParams).toHaveBeenCalledWith({ includeCourseRoleMembers: false });
+ });
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/index.jsx b/src/components/GradebookFilters/index.jsx
index f0ef59fe..74de63d9 100644
--- a/src/components/GradebookFilters/index.jsx
+++ b/src/components/GradebookFilters/index.jsx
@@ -1,7 +1,5 @@
-/* eslint-disable react/sort-comp, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
import {
Collapsible,
@@ -10,11 +8,7 @@ import {
Form,
} from '@edx/paragon';
import { Close } from '@edx/paragon/icons';
-import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-
-import actions from 'data/actions';
-import selectors from 'data/selectors';
-import thunkActions from 'data/thunkActions';
+import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import AssignmentTypeFilter from './AssignmentTypeFilter';
@@ -22,111 +16,74 @@ import AssignmentFilter from './AssignmentFilter';
import AssignmentGradeFilter from './AssignmentGradeFilter';
import CourseGradeFilter from './CourseGradeFilter';
import StudentGroupsFilter from './StudentGroupsFilter';
+import useGradebookFiltersData from './hooks';
-export class GradebookFilters extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- includeCourseRoleMembers: this.props.includeCourseRoleMembers,
- };
- this.handleIncludeTeamMembersChange = this.handleIncludeTeamMembersChange.bind(this);
- }
-
- handleIncludeTeamMembersChange(event) {
- const includeCourseRoleMembers = event.target.checked;
- this.setState({ includeCourseRoleMembers });
- this.props.updateIncludeCourseRoleMembers(includeCourseRoleMembers);
- this.props.fetchGrades();
- this.props.updateQueryParams({ includeCourseRoleMembers });
- }
+export const GradebookFilters = ({ updateQueryParams }) => {
+ const {
+ closeMenu,
+ includeCourseTeamMembers,
+ } = useGradebookFiltersData({ updateQueryParams });
+ const { formatMessage } = useIntl();
+ const collapsibleClassName = 'filter-group mb-3';
+ return (
+ <>
+
+
+
+
- collapsibleGroup = (title, content) => (
- }
- defaultOpen
- className="filter-group mb-3"
- >
- {content}
-
- );
-
- render() {
- const {
- intl,
- updateQueryParams,
- } = this.props;
- return (
- <>
-
-
-
+
+
+
- {this.collapsibleGroup(
- messages.assignments, (
-
- ),
- )}
-
- {this.collapsibleGroup(
- messages.overallGrade, (
-
- ),
- )}
+
+
+
- {this.collapsibleGroup(
- messages.studentGroups, (
-
- ),
- )}
+
+
+
- {this.collapsibleGroup(
- messages.includeCourseTeamMembers, (
-
-
-
- ),
- )}
- >
- );
- }
-}
-GradebookFilters.defaultProps = {
- includeCourseRoleMembers: false,
+
+
+ {formatMessage(messages.includeCourseTeamMembers)}
+
+
+ >
+ );
};
GradebookFilters.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
- // injected
- intl: intlShape.isRequired,
- // redux
- closeMenu: PropTypes.func.isRequired,
- fetchGrades: PropTypes.func.isRequired,
- includeCourseRoleMembers: PropTypes.bool,
- updateIncludeCourseRoleMembers: PropTypes.func.isRequired,
-};
-
-export const mapStateToProps = (state) => ({
- includeCourseRoleMembers: selectors.filters.includeCourseRoleMembers(state),
-});
-
-export const mapDispatchToProps = {
- closeMenu: thunkActions.app.filterMenu.close,
- fetchGrades: thunkActions.grades.fetchGrades,
- updateIncludeCourseRoleMembers: actions.filters.update.includeCourseRoleMembers,
};
-export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(GradebookFilters));
+export default GradebookFilters;
diff --git a/src/components/GradebookFilters/index.test.jsx b/src/components/GradebookFilters/index.test.jsx
new file mode 100644
index 00000000..be5235a4
--- /dev/null
+++ b/src/components/GradebookFilters/index.test.jsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Collapsible } from '@edx/paragon';
+
+import { formatMessage } from 'testUtils';
+
+import AssignmentTypeFilter from './AssignmentTypeFilter';
+import AssignmentFilter from './AssignmentFilter';
+import AssignmentGradeFilter from './AssignmentGradeFilter';
+import CourseGradeFilter from './CourseGradeFilter';
+import StudentGroupsFilter from './StudentGroupsFilter';
+import messages from './messages';
+
+import useGradebookFiltersData from './hooks';
+import GradebookFilters from '.';
+
+jest.mock('./AssignmentTypeFilter', () => 'AssignmentTypeFilter');
+jest.mock('./AssignmentFilter', () => 'AssignmentFilter');
+jest.mock('./AssignmentGradeFilter', () => 'AssignmentGradeFilter');
+jest.mock('./CourseGradeFilter', () => 'CourseGradeFilter');
+jest.mock('./StudentGroupsFilter', () => 'StudentGroupsFilter');
+
+jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
+
+const hookProps = {
+ closeMenu: jest.fn().mockName('hook.closeMenu'),
+ includeCourseTeamMembers: {
+ value: true,
+ handleChange: jest.fn().mockName('hook.handleChange'),
+ },
+};
+useGradebookFiltersData.mockReturnValue(hookProps);
+
+let el;
+const updateQueryParams = jest.fn();
+
+describe('GradebookFilters', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ el = shallow(
);
+ });
+ describe('behavior', () => {
+ it('initializes hooks', () => {
+ expect(useGradebookFiltersData).toHaveBeenCalledWith({ updateQueryParams });
+ expect(useIntl).toHaveBeenCalledWith();
+ });
+ });
+ describe('render', () => {
+ test('snapshot', () => {
+ expect(el).toMatchSnapshot();
+ });
+ test('Assignment filters', () => {
+ expect(el.find(Collapsible).at(0).children()).toMatchObject(shallow(
+
,
+ ));
+ });
+ test('CourseGrade filters', () => {
+ expect(el.find(Collapsible).at(1).children()).toMatchObject(shallow(
+
,
+ ));
+ });
+ test('StudentGroups filters', () => {
+ expect(el.find(Collapsible).at(2).children()).toMatchObject(shallow(
+
,
+ ));
+ });
+ test('includeCourseTeamMembers', () => {
+ const checkbox = el.find(Collapsible).at(3).children();
+ expect(checkbox.props()).toEqual({
+ checked: true,
+ onChange: hookProps.includeCourseTeamMembers.handleChange,
+ children: formatMessage(messages.includeCourseTeamMembers),
+ });
+ });
+ });
+});
diff --git a/src/components/GradebookFilters/messages.js b/src/components/GradebookFilters/messages.js
index 317d8005..adbd5a5e 100644
--- a/src/components/GradebookFilters/messages.js
+++ b/src/components/GradebookFilters/messages.js
@@ -66,6 +66,11 @@ const messages = defineMessages({
defaultMessage: 'Close Filters',
description: 'Button label for Close button in Gradebook Filters',
},
+ apply: {
+ id: 'gradebook.GradebookFilters.apply',
+ defaultMessage: 'Apply',
+ description: 'Apply filter button text',
+ },
});
export default messages;
diff --git a/src/components/GradebookFilters/test.jsx b/src/components/GradebookFilters/test.jsx
deleted file mode 100644
index ba98358a..00000000
--- a/src/components/GradebookFilters/test.jsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-
-import actions from 'data/actions';
-import selectors from 'data/selectors';
-import thunkActions from 'data/thunkActions';
-
-import {
- GradebookFilters,
- mapStateToProps,
- mapDispatchToProps,
-} from '.';
-
-jest.mock('@edx/paragon', () => ({
- Collapsible: 'Collapsible',
- Form: {
- Checkbox: 'Checkbox',
- },
- Icon: 'Icon',
- IconButton: 'IconButton',
-}));
-jest.mock('@edx/paragon/icons', () => ({
- Close: 'paragon.icons.Close',
-}));
-jest.mock('./AssignmentTypeFilter', () => 'AssignmentTypeFilter');
-jest.mock('./AssignmentFilter', () => 'AssignmentFilter');
-jest.mock('./AssignmentGradeFilter', () => 'AssignmentGradeFilter');
-jest.mock('./CourseGradeFilter', () => 'CourseGradeFilter');
-jest.mock('./StudentGroupsFilter', () => 'StudentGroupsFilter');
-jest.mock('data/selectors', () => ({
- __esModule: true,
- default: {
- filters: {
- includeCourseRoleMembers: jest.fn((state) => ({ includeCourseRoleMembers: state })),
- },
- },
-}));
-jest.mock('data/thunkActions', () => ({
- __esModule: true,
- default: {
- app: { filterMenu: { close: jest.fn() } },
- grades: { fetchGrades: jest.fn() },
- },
-}));
-
-describe('GradebookFilters', () => {
- let props = {
- includeCourseRoleMembers: true,
- };
-
- beforeEach(() => {
- props = {
- ...props,
- intl: { formatMessage: (msg) => msg.defaultMessage },
- closeMenu: jest.fn().mockName('this.props.closeMenu'),
- fetchGrades: jest.fn(),
- updateIncludeCourseRoleMembers: jest.fn(),
- updateQueryParams: jest.fn().mockName('this.props.updateQueryParams'),
- };
- });
-
- describe('Component', () => {
- describe('behavior', () => {
- describe('handleIncludeTeamMembersChange', () => {
- let el;
- beforeEach(() => {
- el = shallow(
);
- el.instance().setState = jest.fn();
- });
- it('calls setState with newVal', () => {
- el.instance().handleIncludeTeamMembersChange(
- { target: { checked: true } },
- );
- expect(
- el.instance().setState,
- ).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
- });
- it('calls props.updateIncludeCourseRoleMembers with newVal', () => {
- el.instance().handleIncludeTeamMembersChange(
- { target: { checked: false } },
- );
- expect(
- props.updateIncludeCourseRoleMembers,
- ).toHaveBeenCalledWith(false);
- });
- it('calls props.updateQueryParams with newVal', () => {
- el.instance().handleIncludeTeamMembersChange(
- { target: { checked: true } },
- );
- expect(
- props.updateQueryParams,
- ).toHaveBeenCalledWith({ includeCourseRoleMembers: true });
- });
- });
- });
- describe('snapshots', () => {
- test('basic snapshot', () => {
- const el = shallow(
);
- el.instance().handleIncludeTeamMembersChange = jest.fn().mockName(
- 'handleIncludeTeamMembersChange',
- );
- expect(el.instance().render()).toMatchSnapshot();
- });
- });
- });
- describe('mapStateToProps', () => {
- const testState = { A: 'laska' };
- test('includeCourseRoleMembers from filters.includeCourseRoleMembers', () => {
- expect(
- mapStateToProps(testState).includeCourseRoleMembers,
- ).toEqual(selectors.filters.includeCourseRoleMembers(testState));
- });
- });
- describe('mapDispatchToProps', () => {
- test('fetchGrades from thunkActions.grades.fetchGrades', () => {
- expect(mapDispatchToProps.fetchGrades).toEqual(thunkActions.grades.fetchGrades);
- });
- describe('updateIncludeCourseRoleMembers', () => {
- test('from actions.filters.update.includeCourseRoleMembers', () => {
- expect(mapDispatchToProps.updateIncludeCourseRoleMembers).toEqual(
- actions.filters.update.includeCourseRoleMembers,
- );
- });
- });
- });
-});
diff --git a/src/components/GradesView/EditModal/__snapshots__/test.jsx.snap b/src/components/GradesView/EditModal/__snapshots__/test.jsx.snap
index 7ba9de08..3bcdeb7f 100644
--- a/src/components/GradesView/EditModal/__snapshots__/test.jsx.snap
+++ b/src/components/GradesView/EditModal/__snapshots__/test.jsx.snap
@@ -2,46 +2,23 @@
exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty and open is true modal open and StatusAlert showing 1`] = `
-
+
-
Weve been trying to contact you regarding...
-
+
-
-
-
-
+
+
+
-
-
+
+
-
+
`;
exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and open is false modal closed and StatusAlert closed 1`] = `
-
+
-
-
-
-
+
+
+
-
-
+
+
-
+
`;
diff --git a/src/components/GradesView/ImportGradesButton.jsx b/src/components/GradesView/ImportGradesButton.jsx
deleted file mode 100644
index 220f3ba8..00000000
--- a/src/components/GradesView/ImportGradesButton.jsx
+++ /dev/null
@@ -1,98 +0,0 @@
-/* eslint-disable 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 {
- Form,
- FormControl,
- FormGroup,
-} from '@edx/paragon';
-
-import selectors from 'data/selectors';
-import thunkActions from 'data/thunkActions';
-import NetworkButton from 'components/NetworkButton';
-import messages from './ImportGradesButton.messages';
-
-/**
- *
- * File-type input wrapped with hidden control such that when a valid file is
- * added, it is automattically uploaded.
- */
-export class ImportGradesButton extends React.Component {
- constructor(props) {
- super(props);
- this.fileInputRef = React.createRef();
- this.handleClickImportGrades = this.handleClickImportGrades.bind(this);
- this.handleFileInputChange = this.handleFileInputChange.bind(this);
- }
-
- handleClickImportGrades() {
- if (this.fileInput) { this.fileInput.click(); }
- }
-
- handleFileInputChange() {
- return this.hasFile && (
- this.props.submitImportGradesButtonData(this.formData).then(
- () => { this.fileInput.value = null; },
- )
- );
- }
-
- get fileInput() {
- return this.fileInputRef.current;
- }
-
- get formData() {
- const data = new FormData();
- data.append('csv', this.fileInput.files[0]);
- return data;
- }
-
- get hasFile() {
- return this.fileInput && this.fileInput.files[0];
- }
-
- render() {
- const { gradeExportUrl } = this.props;
- return (
- <>
-
-
-
- >
- );
- }
-}
-ImportGradesButton.propTypes = {
- // redux
- gradeExportUrl: PropTypes.string.isRequired,
- submitImportGradesButtonData: PropTypes.func.isRequired,
-};
-
-export const mapStateToProps = (state) => ({
- gradeExportUrl: selectors.root.gradeExportUrl(state),
-});
-
-export const mapDispatchToProps = {
- submitImportGradesButtonData: thunkActions.grades.submitImportGradesButtonData,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(ImportGradesButton);
diff --git a/src/components/GradesView/ImportGradesButton.test.jsx b/src/components/GradesView/ImportGradesButton.test.jsx
deleted file mode 100644
index 0a672056..00000000
--- a/src/components/GradesView/ImportGradesButton.test.jsx
+++ /dev/null
@@ -1,212 +0,0 @@
-/* eslint-disable import/no-named-as-default */
-import React from 'react';
-import { shallow } from 'enzyme';
-import TestRenderer from 'react-test-renderer';
-import {
- Form,
- FormControl,
- FormGroup,
-} from '@edx/paragon';
-
-import NetworkButton from 'components/NetworkButton';
-
-import selectors from 'data/selectors';
-import thunkActions from 'data/thunkActions';
-import { ImportGradesButton, mapStateToProps, mapDispatchToProps } from './ImportGradesButton';
-
-import messages from './ImportGradesButton.messages';
-
-jest.mock('@edx/frontend-platform/i18n', () => ({
- defineMessages: m => m,
- FormattedMessage: () => 'FormattedMessage',
-}));
-jest.mock('components/NetworkButton', () => 'NetworkButton');
-jest.mock('data/selectors', () => ({
- __esModule: true,
- default: {
- grades: {
- bulkImportError: jest.fn(state => ({ bulkImportError: state })),
- },
- root: {
- gradeExportUrl: jest.fn(state => ({ gradeExportUrl: state })),
- },
- },
-}));
-jest.mock('data/thunkActions', () => ({
- __esModule: true,
- default: {
- grades: { submitImportGradesButtonData: jest.fn() },
- },
-
-}));
-
-const mockRef = { click: jest.fn(), files: [] };
-
-describe('ImportGradesButton', () => {
- beforeEach(() => {
- mockRef.click.mockClear();
- });
- describe('component', () => {
- let props;
- let el;
- let inst;
- beforeEach(() => {
- props = {
- gradeExportUrl: 'fakeUrl',
- submitImportGradesButtonData: jest.fn(),
- };
- });
- describe('snapshot', () => {
- const snapshotSegments = [
- 'export form w/ alerts and file input',
- 'import btn',
- ];
- test(`snapshot - loads ${snapshotSegments.join(', ')}`, () => {
- jest.mock('@edx/paragon', () => ({
- Form: () => 'Form',
- FormControl: () => 'FormControl',
- FormGroup: () => 'FormGroup',
- }));
- el = shallow(
);
- el.instance().handleFileInputChange = jest.fn().mockName('this.handleFileInputChange');
- el.instance().fileInputRef = jest.fn().mockName('this.fileInputRef');
- el.instance().handleClickImportGrades = jest.fn().mockName('this.handleClickImportGrades');
- el.instance().formatHistoryRow = jest.fn(entry => entry.originalFilename);
- expect(el.instance().render()).toMatchSnapshot();
- });
- });
- describe('render', () => {
- beforeEach(() => {
- el = TestRenderer.create(
-
,
- { createNodeMock: () => mockRef },
- );
- inst = el.root;
- });
- describe('alert form', () => {
- let form;
- beforeEach(() => {
- form = inst.findByType(Form);
- });
- test('post action points to gradeExportUrl', () => {
- expect(form.props.action).toEqual(props.gradeExportUrl);
- expect(form.props.method).toEqual('post');
- });
- describe('file input', () => {
- let formGroup;
- beforeEach(() => {
- formGroup = inst.findByType(FormGroup);
- });
- test('group with controlId="csv"', () => {
- expect(formGroup.props.controlId).toEqual('csv');
- });
- test('file control with onChange from handleFileInputChange', () => {
- const control = inst.findByType(FormControl);
- expect(
- control.props.onChange,
- ).toEqual(el.getInstance().handleFileInputChange);
- });
- test('fileInputRef points to control', () => {
- expect(
- // eslint-disable-next-line no-underscore-dangle
- inst.findByType(FormControl)._fiber.ref,
- ).toEqual(el.getInstance().fileInputRef);
- });
- });
- });
- describe('import button', () => {
- let btn;
- beforeEach(() => {
- btn = inst.findByType(NetworkButton);
- });
- test('handleClickImportGrade on click', () => {
- expect(btn.props.onClick).toEqual(el.getInstance().handleClickImportGrades);
- });
- test('label from messages.importGradesBtnText and import true', () => {
- expect(btn.props.label).toEqual(messages.importGradesBtnText);
- expect(btn.props.import).toEqual(true);
- });
- });
- });
- describe('fileInput helper', () => {
- test('links to fileInputRef.current', () => {
- el = TestRenderer.create(
-
,
- { createNodeMock: () => mockRef },
- );
- expect(el.getInstance().fileInput).not.toEqual(undefined);
- expect(el.getInstance().fileInput).toEqual(el.getInstance().fileInputRef.current);
- });
- });
- describe('behavior', () => {
- let fileInput;
- beforeEach(() => {
- el = TestRenderer.create(
-
,
- { createNodeMock: () => mockRef },
- );
- fileInput = jest.spyOn(el.getInstance(), 'fileInput', 'get');
- });
- describe('handleFileInputChange', () => {
- it('does nothing (does not fail) if fileInput has not loaded', () => {
- fileInput.mockReturnValue(null);
- el.getInstance().handleClickImportGrades();
- expect(mockRef.click).not.toHaveBeenCalled();
- });
- it('calls fileInput.click if is loaded', () => {
- el.getInstance().handleClickImportGrades();
- expect(mockRef.click).toHaveBeenCalled();
- });
- });
- describe('handleClickImportGrades', () => {
- it('does nothing if file input has not loaded with files', () => {
- fileInput.mockReturnValue(null);
- el.getInstance().handleFileInputChange();
- expect(props.submitImportGradesButtonData).not.toHaveBeenCalled();
- fileInput.mockReturnValue({ files: [] });
- el.getInstance().handleFileInputChange();
- expect(props.submitImportGradesButtonData).not.toHaveBeenCalled();
- });
- it('calls submitImportGradesButtonData and then clears fileInput if has files', () => {
- fileInput.mockReturnValue({ files: ['some', 'files'], value: 'a value' });
- const formData = { fake: 'form data' };
- jest.spyOn(el.getInstance(), 'formData', 'get').mockReturnValue(formData);
- const submit = jest.fn(() => ({ then: (thenCB) => { thenCB(); } }));
- el.update(
);
- el.getInstance().handleFileInputChange();
- expect(submit).toHaveBeenCalledWith(formData);
- expect(el.getInstance().fileInput.value).toEqual(null);
- });
- });
- describe('formData', () => {
- test('returns FormData object with csv value from fileInput.files[0]', () => {
- const file = { a: 'fake file' };
- const value = 'aValue';
- fileInput.mockReturnValue({ files: [file], value });
- const expected = new FormData();
- expected.append('csv', file);
- expect([...el.getInstance().formData.entries()]).toEqual([...expected.entries()]);
- });
- });
- });
- });
-
- describe('mapStateToProps', () => {
- const testState = { a: 'simple', test: 'state' };
- let mapped;
- beforeEach(() => {
- mapped = mapStateToProps(testState);
- });
- test('gradeExportUrl from root.gradeExportUrl', () => {
- expect(mapped.gradeExportUrl).toEqual(selectors.root.gradeExportUrl(testState));
- });
- });
-
- describe('mapDispatchToProps', () => {
- test('submitImportGradesButtonData from thunkActions.grades', () => {
- expect(
- mapDispatchToProps.submitImportGradesButtonData,
- ).toEqual(thunkActions.grades.submitImportGradesButtonData);
- });
- });
-});
diff --git a/src/components/GradesView/ImportGradesButton/__snapshots__/index.test.jsx.snap b/src/components/GradesView/ImportGradesButton/__snapshots__/index.test.jsx.snap
new file mode 100644
index 00000000..168bac2b
--- /dev/null
+++ b/src/components/GradesView/ImportGradesButton/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,28 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ImportGradesButton component render snapshot 1`] = `
+
+
+
+
+
+
+
+`;
diff --git a/src/components/GradesView/ImportGradesButton/hooks.js b/src/components/GradesView/ImportGradesButton/hooks.js
new file mode 100644
index 00000000..cbac03b5
--- /dev/null
+++ b/src/components/GradesView/ImportGradesButton/hooks.js
@@ -0,0 +1,31 @@
+import { useRef } from 'react';
+import { selectors, thunkActions } from 'data/redux/hooks';
+
+export const useImportButtonData = () => {
+ const gradeExportUrl = selectors.useGradeExportUrl();
+ const submitImportGradesButtonData = thunkActions.grades.useSubmitImportGradesButtonData();
+
+ const fileInputRef = useRef();
+ const hasFile = fileInputRef.current && fileInputRef.current.files[0];
+
+ const handleClickImportGrades = () => hasFile && fileInputRef.current.click();
+ const handleFileInputChange = () => {
+ if (hasFile) {
+ const clearInput = () => {
+ fileInputRef.current.value = null;
+ };
+ const formData = new FormData();
+ formData.append('csv', fileInputRef.current.files[0]);
+ submitImportGradesButtonData(formData).then(clearInput);
+ }
+ };
+
+ return {
+ fileInputRef,
+ gradeExportUrl,
+ handleClickImportGrades,
+ handleFileInputChange,
+ };
+};
+
+export default useImportButtonData;
diff --git a/src/components/GradesView/ImportGradesButton/hooks.test.js b/src/components/GradesView/ImportGradesButton/hooks.test.js
new file mode 100644
index 00000000..b19df06c
--- /dev/null
+++ b/src/components/GradesView/ImportGradesButton/hooks.test.js
@@ -0,0 +1,81 @@
+import React from 'react';
+import { selectors, thunkActions } from 'data/redux/hooks';
+
+import useImportButtonData from './hooks';
+
+jest.mock('data/redux/hooks', () => ({
+ selectors: {
+ useGradeExportUrl: jest.fn(),
+ },
+ thunkActions: {
+ grades: { useSubmitImportGradesButtonData: jest.fn() },
+ },
+}));
+
+let out;
+
+let submitThen;
+const submit = jest.fn().mockReturnValue(new Promise((resolve) => {
+ submitThen = resolve;
+}));
+const gradeExportUrl = 'test-grade-export-url';
+selectors.useGradeExportUrl.mockReturnValue(gradeExportUrl);
+thunkActions.grades.useSubmitImportGradesButtonData.mockReturnValue(submit);
+
+const testFile = 'test-file';
+const testFormData = new FormData();
+testFormData.append('csv', testFile);
+
+const ref = {
+ current: { click: jest.fn(), files: [testFile], value: 'test-value' },
+};
+describe('useAssignmentFilterData hook', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ React.useRef.mockReturnValue(ref);
+ out = useImportButtonData();
+ });
+ describe('behavior', () => {
+ it('initializes redux hooks', () => {
+ expect(selectors.useGradeExportUrl).toHaveBeenCalledWith();
+ expect(thunkActions.grades.useSubmitImportGradesButtonData).toHaveBeenCalledWith();
+ });
+ it('initializes react ref', () => {
+ expect(React.useRef).toHaveBeenCalled();
+ });
+ });
+ describe('output', () => {
+ describe('handleClickImportGrades', () => {
+ it('clicks the file input if populated', () => {
+ out.handleClickImportGrades();
+ expect(ref.current.click).toHaveBeenCalled();
+ });
+ it('does not crash if no file input available', () => {
+ React.useRef.mockReturnValueOnce({ current: null });
+ out = useImportButtonData();
+ out.handleClickImportGrades();
+ expect(ref.current.click).not.toHaveBeenCalled();
+ });
+ });
+ describe('handleFileInputChange', () => {
+ it('does not crash if no file input available', () => {
+ React.useRef.mockReturnValueOnce({ current: null });
+ out = useImportButtonData();
+ out.handleFileInputChange();
+ });
+ it('submits formData, clearingInput on success', async () => {
+ out.handleFileInputChange();
+ const [[callArg]] = submit.mock.calls;
+ expect([...callArg.entries()]).toEqual([...testFormData.entries()]);
+ await submitThen();
+ expect(out.fileInputRef.current.value).toEqual(null);
+ });
+ });
+ it('passes fileInputRef from hook', () => {
+ expect(out.fileInputRef).toEqual(ref);
+ });
+ it('passes gradeExportUrl from hook', () => {
+ expect(out.gradeExportUrl).toEqual(gradeExportUrl);
+ });
+ });
+});
diff --git a/src/components/GradesView/ImportGradesButton/index.jsx b/src/components/GradesView/ImportGradesButton/index.jsx
new file mode 100644
index 00000000..d398103f
--- /dev/null
+++ b/src/components/GradesView/ImportGradesButton/index.jsx
@@ -0,0 +1,50 @@
+/* eslint-disable react/button-has-type, import/no-named-as-default */
+import React from 'react';
+
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import { Form } from '@edx/paragon';
+
+import NetworkButton from 'components/NetworkButton';
+import messages from './messages';
+import useImportGradesButtonData from './hooks';
+
+/**
+ *
+ * File-type input wrapped with hidden control such that when a valid file is
+ * added, it is automattically uploaded.
+ */
+export const ImportGradesButton = () => {
+ const {
+ fileInputRef,
+ gradeExportUrl,
+ handleClickImportGrades,
+ handleFileInputChange,
+ } = useImportGradesButtonData();
+ const { formatMessage } = useIntl();
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
+ImportGradesButton.propTypes = {};
+
+export default ImportGradesButton;
diff --git a/src/components/GradesView/ImportGradesButton/index.test.jsx b/src/components/GradesView/ImportGradesButton/index.test.jsx
new file mode 100644
index 00000000..6811ad80
--- /dev/null
+++ b/src/components/GradesView/ImportGradesButton/index.test.jsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Form } from '@edx/paragon';
+
+import NetworkButton from 'components/NetworkButton';
+import useImportGradesButtonData from './hooks';
+import ImportGradesButton from '.';
+
+jest.mock('components/NetworkButton', () => 'NetworkButton');
+jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
+
+let el;
+let props;
+describe('ImportGradesButton component', () => {
+ beforeAll(() => {
+ props = {
+ fileInputRef: { current: null },
+ gradeExportUrl: 'test-grade-export-url',
+ handleClickImportGrades: jest.fn(),
+ handleFileInputChange: jest.fn(),
+ };
+ useImportGradesButtonData.mockReturnValue(props);
+ el = shallow(
);
+ });
+ describe('behavior', () => {
+ it('initializes hooks', () => {
+ expect(useImportGradesButtonData).toHaveBeenCalledWith();
+ expect(useIntl).toHaveBeenCalledWith();
+ });
+ });
+ describe('render', () => {
+ test('snapshot', () => {
+ expect(el).toMatchSnapshot();
+ });
+ test('Form', () => {
+ expect(el.find(Form).props().action).toEqual(props.gradeExportUrl);
+ expect(el.find(Form.Control).props().onChange).toEqual(props.handleFileInputChange);
+ });
+ test('import button', () => {
+ expect(el.find(NetworkButton).props().onClick).toEqual(props.handleClickImportGrades);
+ });
+ });
+});
diff --git a/src/components/GradesView/ImportGradesButton.messages.js b/src/components/GradesView/ImportGradesButton/messages.js
similarity index 100%
rename from src/components/GradesView/ImportGradesButton.messages.js
rename to src/components/GradesView/ImportGradesButton/messages.js
diff --git a/src/components/GradesView/ImportGradesButton/ref.test.jsx b/src/components/GradesView/ImportGradesButton/ref.test.jsx
new file mode 100644
index 00000000..57b0e530
--- /dev/null
+++ b/src/components/GradesView/ImportGradesButton/ref.test.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+
+import useImportGradesButtonData from './hooks';
+import ImportGradesButton from '.';
+
+jest.unmock('react');
+jest.unmock('@edx/paragon');
+jest.mock('components/NetworkButton', () => 'network-button');
+jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
+
+const props = {
+ fileInputRef: { current: { click: jest.fn() }, useRef: jest.fn() },
+ gradeExportUrl: 'test-grade-export-utl',
+ handleClickImportGrades: jest.fn(),
+ handleFileInputChange: jest.fn(),
+};
+useImportGradesButtonData.mockReturnValue(props);
+
+let el;
+describe('ImportGradesButton ref test', () => {
+ it('loads ref from hook', () => {
+ el = render(
);
+ const input = el.getByTestId('file-control');
+ expect(input).toEqual(props.fileInputRef.current);
+ });
+});
diff --git a/src/components/GradesView/__snapshots__/ImportGradesButton.test.jsx.snap b/src/components/GradesView/__snapshots__/ImportGradesButton.test.jsx.snap
deleted file mode 100644
index 79973a9f..00000000
--- a/src/components/GradesView/__snapshots__/ImportGradesButton.test.jsx.snap
+++ /dev/null
@@ -1,45 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`ImportGradesButton component snapshot snapshot - loads export form w/ alerts and file input, import btn 1`] = `
-
-
-
-
-`;
diff --git a/src/data/actions/utils.test.js b/src/data/actions/utils.test.js
index f31e69a9..2b31b4c0 100644
--- a/src/data/actions/utils.test.js
+++ b/src/data/actions/utils.test.js
@@ -8,8 +8,10 @@ jest.mock('@reduxjs/toolkit', () => ({
describe('redux action utils', () => {
describe('formatDateForDisplay', () => {
it('returns the datetime as a formatted string', () => {
- expect(utils.formatDateForDisplay(new Date('Jun 3 2021 11:59 AM EDT'))).toEqual(
- 'June 3, 2021 at 03:59 PM UTC',
+ // using toLocaleTimeString because mac/linux seems to generate strings
+ const date = new Date('Jun 3 2021 11:59 AM EDT');
+ expect(utils.formatDateForDisplay(date)).toEqual(
+ `June 3, 2021 at ${date.toLocaleTimeString('en-US', utils.timeOptions)}`,
);
});
});
diff --git a/src/data/redux/hooks/actions.js b/src/data/redux/hooks/actions.js
new file mode 100644
index 00000000..bfe11490
--- /dev/null
+++ b/src/data/redux/hooks/actions.js
@@ -0,0 +1,22 @@
+import { StrictDict } from 'utils';
+import actions from 'data/actions';
+import { actionHook } from './utils';
+
+const app = StrictDict({
+ useSetLocalFilter: actionHook(actions.app.setLocalFilter),
+});
+
+const filters = StrictDict({
+ useUpdateAssignment: actionHook(actions.filters.update.assignment),
+ useUpdateAssignmentLimits: actionHook(actions.filters.update.assignmentLimits),
+ useUpdateAssignmentType: actionHook(actions.filters.update.assignmentType),
+ useUpdateCohort: actionHook(actions.filters.update.cohort),
+ useUpdateCourseGradeLimits: actionHook(actions.filters.update.courseGradeLimits),
+ useUpdateIncludeCourseRoleMembers: actionHook(actions.filters.update.includeCourseRoleMembers),
+ useUpdateTrack: actionHook(actions.filters.update.track),
+});
+
+export default StrictDict({
+ app,
+ filters,
+});
diff --git a/src/data/redux/hooks/actions.test.js b/src/data/redux/hooks/actions.test.js
new file mode 100644
index 00000000..95cf0157
--- /dev/null
+++ b/src/data/redux/hooks/actions.test.js
@@ -0,0 +1,50 @@
+import { keyStore } from 'utils';
+import actions from 'data/actions';
+
+import { actionHook } from './utils';
+import actionHooks from './actions';
+
+jest.mock('data/actions', () => ({
+ app: {
+ setLocalFilter: jest.fn(),
+ },
+ filters: {
+ update: {
+ assignment: jest.fn(),
+ assignmentLimits: jest.fn(),
+ },
+ },
+}));
+jest.mock('./utils', () => ({
+ actionHook: (action) => ({ actionHook: action }),
+}));
+
+let hooks;
+
+const testActionHook = (hookKey, action) => {
+ test(hookKey, () => {
+ expect(hooks[hookKey]).toEqual(actionHook(action));
+ });
+};
+
+describe('action hooks', () => {
+ describe('app', () => {
+ const hookKeys = keyStore(actionHooks.app);
+ beforeEach(() => { hooks = actionHooks.app; });
+ testActionHook(hookKeys.useSetLocalFilter, actions.app.setLocalFilter);
+ });
+ describe('filters', () => {
+ const hookKeys = keyStore(actionHooks.filters);
+ const actionGroup = actions.filters.update;
+ beforeEach(() => { hooks = actionHooks.filters; });
+ testActionHook(hookKeys.useUpdateAssignment, actionGroup.assignment);
+ testActionHook(hookKeys.useUpdateAssignmentLimits, actionGroup.assignmentLimits);
+ testActionHook(hookKeys.useUpdateCohort, actionGroup.updateCohort);
+ testActionHook(hookKeys.useUpdateCourseGradeLimits, actionGroup.courseGradeLimits);
+ testActionHook(
+ hookKeys.useUpdateIncludeCourseRoleMembers,
+ actionGroup.updateIncludeCourseRoleMembers,
+ );
+ testActionHook(hookKeys.useUpdateTrack, actionGroup.updateTrack);
+ });
+});
diff --git a/src/data/redux/hooks/index.js b/src/data/redux/hooks/index.js
new file mode 100644
index 00000000..f83ebd69
--- /dev/null
+++ b/src/data/redux/hooks/index.js
@@ -0,0 +1,15 @@
+import { StrictDict } from 'utils';
+
+import selectorHooks from './selectors';
+import actionHooks from './actions';
+import thunkActionHooks from './thunkActions';
+
+export const selectors = selectorHooks;
+export const actions = actionHooks;
+export const thunkActions = thunkActionHooks;
+
+export default StrictDict({
+ selectors,
+ actions,
+ thunkActions,
+});
diff --git a/src/data/redux/hooks/index.test.js b/src/data/redux/hooks/index.test.js
new file mode 100644
index 00000000..2adbc5ce
--- /dev/null
+++ b/src/data/redux/hooks/index.test.js
@@ -0,0 +1,17 @@
+import hooks from '.';
+
+import selectors from './selectors';
+import actions from './actions';
+import thunkActions from './thunkActions';
+
+jest.mock('./selectors', () => jest.fn());
+jest.mock('./actions', () => jest.fn());
+jest.mock('./thunkActions', () => jest.fn());
+
+describe('redux hooks', () => {
+ it('exports selectors, actions, and thunkActions', () => {
+ expect(hooks.actions).toEqual(actions);
+ expect(hooks.selectors).toEqual(selectors);
+ expect(hooks.thunkActions).toEqual(thunkActions);
+ });
+});
diff --git a/src/data/redux/hooks/selectors.js b/src/data/redux/hooks/selectors.js
new file mode 100644
index 00000000..4b865a65
--- /dev/null
+++ b/src/data/redux/hooks/selectors.js
@@ -0,0 +1,49 @@
+import { useSelector } from 'react-redux';
+
+import { StrictDict } from 'utils';
+import selectors from 'data/selectors';
+
+export const root = StrictDict({
+ useGradeExportUrl: () => useSelector(selectors.root.gradeExportUrl),
+ useSelectedCohortEntry: () => useSelector(selectors.root.selectedCohortEntry),
+ useSelectedTrackEntry: () => useSelector(selectors.root.selectedTrackEntry),
+});
+
+export const app = StrictDict({
+ useAssignmentGradeLimits: () => useSelector(selectors.app.assignmentGradeLimits),
+ useAreCourseGradeFiltersValid: () => useSelector(selectors.app.areCourseGradeFiltersValid),
+ useCourseGradeLimits: () => useSelector(selectors.app.courseGradeLimits),
+});
+
+export const assignmentTypes = StrictDict({
+ useAllAssignmentTypes: () => useSelector(selectors.assignmentTypes.allAssignmentTypes),
+});
+
+export const cohorts = StrictDict({
+ useAllCohorts: () => useSelector(selectors.cohorts.allCohorts),
+ // maybe not needed?
+ useCohortsByName: () => useSelector(selectors.cohorts.cohortsByName),
+});
+
+export const filters = StrictDict({
+ useData: () => useSelector(selectors.filters.allFilters),
+ useIncludeCourseRoleMembers: () => useSelector(selectors.filters.includeCourseRoleMembers),
+ useSelectableAssignmentLabels: () => useSelector(selectors.filters.selectableAssignmentLabels),
+ useSelectedAssignmentLabel: () => useSelector(selectors.filters.selectedAssignmentLabel),
+ useAssignmentType: () => useSelector(selectors.filters.assignmentType),
+});
+
+export const tracks = StrictDict({
+ useAllTracks: () => useSelector(selectors.tracks.allTracks),
+ // maybe not needed?
+ useTracksByName: () => useSelector(selectors.tracks.tracksByName),
+});
+
+export default StrictDict({
+ app,
+ assignmentTypes,
+ cohorts,
+ filters,
+ tracks,
+ root,
+});
diff --git a/src/data/redux/hooks/selectors.test.js b/src/data/redux/hooks/selectors.test.js
new file mode 100644
index 00000000..c19ad9a3
--- /dev/null
+++ b/src/data/redux/hooks/selectors.test.js
@@ -0,0 +1,91 @@
+import { keyStore } from 'utils';
+import { useSelector } from 'react-redux';
+import selectors from 'data/selectors';
+import selectorHooks from './selectors';
+
+jest.mock('react-redux', () => ({
+ useSelector: (selector) => ({ useSelector: selector }),
+}));
+
+jest.mock('data/selectors', () => ({
+ app: {
+ assignmentGradeLimits: jest.fn(),
+ areCourseGradeFiltersValid: jest.fn(),
+ courseGradelimits: jest.fn(),
+ },
+ assignmentTypes: { allAssignmentTypes: jest.fn() },
+ cohorts: {
+ allCohorts: jest.fn(),
+ cohortsByName: jest.fn(),
+ },
+ filters: {
+ allFilters: jest.fn(),
+ includeCourseRoleMembers: jest.fn(),
+ selectableAssignmentLabels: jest.fn(),
+ selectedAssignmentLabel: jest.fn(),
+ assignmentType: jest.fn(),
+ },
+ tracks: {
+ allTracks: jest.fn(),
+ tracksByName: jest.fn(),
+ },
+ root: {
+ gradeExportUrl: jest.fn(),
+ selectedCohortEntry: jest.fn(),
+ selectedTrackEntry: jest.fn(),
+ },
+}));
+
+let hooks;
+const testHook = (hookKey, selector) => {
+ test(hookKey, () => {
+ expect(hooks[hookKey]()).toEqual(useSelector(selector));
+ });
+};
+describe('selector hooks', () => {
+ describe('root selectors', () => {
+ const hookKeys = keyStore(selectorHooks.root);
+ beforeEach(() => { hooks = selectorHooks.root; });
+ testHook(hookKeys.useGradeExportUrl, selectors.root.gradeExportUrl);
+ testHook(hookKeys.useSelectedCohortEntry, selectors.root.selectedCohortEntry);
+ testHook(hookKeys.useSelectedTrackEntry, selectors.root.selectedTrackEntry);
+ });
+ describe('app', () => {
+ const hookKeys = keyStore(selectorHooks.app);
+ const selGroup = selectors.app;
+ beforeEach(() => { hooks = selectorHooks.app; });
+ testHook(hookKeys.useAssignmentGradeLimits, selGroup.assignmentGradeLimits);
+ testHook(hookKeys.useAreCourseGradeFiltersValid, selGroup.areCourseGradeFiltersValid);
+ testHook(hookKeys.useCourseGradeLimits, selGroup.courseGradeLimits);
+ });
+ describe('assignmentTypes', () => {
+ const hookKeys = keyStore(selectorHooks.assignmentTypes);
+ const selGroup = selectors.assignmentTypes;
+ beforeEach(() => { hooks = selectorHooks.assignmentTypes; });
+ testHook(hookKeys.useAllAssignmentTypes, selGroup.allAssignmentTypes);
+ });
+ describe('cohorts', () => {
+ const hookKeys = keyStore(selectorHooks.cohorts);
+ const selGroup = selectors.cohorts;
+ beforeEach(() => { hooks = selectorHooks.cohorts; });
+ testHook(hookKeys.useAllCohorts, selGroup.allCohorts);
+ testHook(hookKeys.useCohortsByName, selGroup.cohortsByName);
+ });
+ describe('filters', () => {
+ const hookKeys = keyStore(selectorHooks.filters);
+ const selGroup = selectors.filters;
+ beforeEach(() => { hooks = selectorHooks.filters; });
+ testHook(hookKeys.useData, selGroup.allFilters);
+ testHook(hookKeys.useIncludeCourseRoleMembers, selGroup.includeCourseRoleMembers);
+ testHook(hookKeys.useSelectableAssignmentLabels, selGroup.selectableAssignmentLabels);
+ testHook(hookKeys.useSelectedAssignmentLabel, selGroup.selectedAssignmentLabel);
+ testHook(hookKeys.useAssignmentType, selGroup.assignmentType);
+ });
+ describe('tracks', () => {
+ const hookKeys = keyStore(selectorHooks.tracks);
+ const selGroup = selectors.tracks;
+ beforeEach(() => { hooks = selectorHooks.tracks; });
+ testHook(hookKeys.useAllTracks, selGroup.allTracks);
+ testHook(hookKeys.useTracksByName, selGroup.tracksByName);
+ });
+});
diff --git a/src/data/redux/hooks/thunkActions.js b/src/data/redux/hooks/thunkActions.js
new file mode 100644
index 00000000..6b5bc5bc
--- /dev/null
+++ b/src/data/redux/hooks/thunkActions.js
@@ -0,0 +1,20 @@
+import { StrictDict } from 'utils';
+import thunkActions from 'data/thunkActions';
+import { actionHook } from './utils';
+
+const app = StrictDict({
+ useCloseFilterMenu: actionHook(thunkActions.app.filterMenu.close),
+});
+
+const grades = StrictDict({
+ useFetchGradesIfAssignmentGradeFiltersSet: actionHook(
+ thunkActions.grades.fetchGradesIfAssignmentGradeFiltersSet,
+ ),
+ useFetchGrades: actionHook(thunkActions.grades.fetchGrades),
+ useSubmitImportGradesButtonData: actionHook(thunkActions.grades.submitImportGradesButtonData),
+});
+
+export default StrictDict({
+ app,
+ grades,
+});
diff --git a/src/data/redux/hooks/thunkActions.test.js b/src/data/redux/hooks/thunkActions.test.js
new file mode 100644
index 00000000..7ec8ebb3
--- /dev/null
+++ b/src/data/redux/hooks/thunkActions.test.js
@@ -0,0 +1,48 @@
+import { keyStore } from 'utils';
+import thunkActions from 'data/thunkActions';
+import { actionHook } from './utils';
+import thunkActionHooks from './thunkActions';
+
+jest.mock('data/thunkActions', () => ({
+ app: {
+ filterMenu: { close: jest.fn() },
+ },
+ grades: {
+ fetchGrades: jest.fn(),
+ fetchGradesIfAssignmentGradeFiltersSet: jest.fn(),
+ submitImportGradesButtonData: jest.fn(),
+ },
+}));
+
+jest.mock('./utils', () => ({
+ actionHook: (action) => ({ actionHook: action }),
+}));
+
+let hooks;
+
+const testActionHook = (hookKey, action) => {
+ test(hookKey, () => {
+ expect(hooks[hookKey]).toEqual(actionHook(action));
+ });
+};
+describe('thunkAction hooks', () => {
+ describe('app', () => {
+ const hookKeys = keyStore(thunkActionHooks.app);
+ beforeEach(() => { hooks = thunkActionHooks.app; });
+ testActionHook(hookKeys.useCloseFilterMenu, thunkActions.app.filterMenu.close);
+ });
+ describe('grades', () => {
+ const hookKeys = keyStore(thunkActionHooks.grades);
+ const actionGroup = thunkActions.grades;
+ beforeEach(() => { hooks = thunkActionHooks.grades; });
+ testActionHook(hookKeys.useFetchGrades, actionGroup.fetchGrades);
+ testActionHook(
+ hookKeys.useFetchGradesIfAssignmentGradeFiltersSet,
+ actionGroup.fetchGradesIfAssignmentGradeFiltersSet,
+ );
+ testActionHook(
+ hookKeys.useSubmitImportGradesButtonData,
+ actionGroup.submitImportGradesButtonData,
+ );
+ });
+});
diff --git a/src/data/redux/hooks/utils.js b/src/data/redux/hooks/utils.js
new file mode 100644
index 00000000..5bcbd15b
--- /dev/null
+++ b/src/data/redux/hooks/utils.js
@@ -0,0 +1,9 @@
+import { StrictDict } from 'utils';
+// useDispatch hook wouldn't work here because it is out of scope of the component
+import store from 'data/store';
+
+export const actionHook = (action) => () => (...args) => store.dispatch(action(...args));
+
+export default StrictDict({
+ actionHook,
+});
diff --git a/src/data/redux/hooks/utils.test.js b/src/data/redux/hooks/utils.test.js
new file mode 100644
index 00000000..6459c326
--- /dev/null
+++ b/src/data/redux/hooks/utils.test.js
@@ -0,0 +1,18 @@
+import store from 'data/store';
+import { actionHook } from './utils';
+
+jest.mock('data/store', () => ({
+ dispatch: jest.fn(),
+}));
+
+describe('actionHook', () => {
+ it('returns a function that dispatches the action', () => {
+ const action = jest.fn();
+ const useHook = actionHook(action);
+ const args = [1, 2, 3];
+ const hook = useHook();
+ hook(...args);
+ expect(action).toHaveBeenCalledWith(...args);
+ expect(store.dispatch).toHaveBeenCalledWith(action(...args));
+ });
+});
diff --git a/src/head/Head.jsx b/src/head/Head.jsx
index f7513d4b..cfbb08eb 100644
--- a/src/head/Head.jsx
+++ b/src/head/Head.jsx
@@ -1,21 +1,23 @@
import React from 'react';
import { Helmet } from 'react-helmet';
-import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
-const Head = ({ intl }) => (
-
-
- {intl.formatMessage(messages['gradebook.page.title'], { siteName: getConfig().SITE_NAME })}
-
-
-
-);
+const Head = () => {
+ const { formatMessage } = useIntl();
+ return (
+
+
+ {formatMessage(messages['gradebook.page.title'], { siteName: getConfig().SITE_NAME })}
+
+
+
+ );
+};
Head.propTypes = {
- intl: intlShape.isRequired,
};
-export default injectIntl(Head);
+export default Head;
diff --git a/src/head/Head.test.jsx b/src/head/Head.test.jsx
index c0d9e5f7..72b2c15b 100644
--- a/src/head/Head.test.jsx
+++ b/src/head/Head.test.jsx
@@ -1,17 +1,31 @@
import React from 'react';
-import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { shallow } from 'enzyme';
import { Helmet } from 'react-helmet';
-import { mount } from 'enzyme';
import { getConfig } from '@edx/frontend-platform';
import Head from './Head';
+jest.mock('react-helmet', () => ({
+ Helmet: () => 'Helmet',
+}));
+jest.mock('@edx/frontend-platform', () => ({
+ getConfig: jest.fn(),
+}));
+
+const config = {
+ SITE_NAME: 'test-site-name',
+ FAVICON_URL: 'test-favicon-url',
+};
+
+getConfig.mockReturnValue(config);
+
describe('Head', () => {
- const props = {};
it('should match render title tag and favicon with the site configuration values', () => {
- mount(
);
- const helmet = Helmet.peek();
- expect(helmet.title).toEqual(`Gradebook | ${getConfig().SITE_NAME}`);
- expect(helmet.linkTags[0].rel).toEqual('shortcut icon');
- expect(helmet.linkTags[0].href).toEqual(getConfig().FAVICON_URL);
+ const el = shallow(
);
+ const helmet = el.find(Helmet);
+ const title = helmet.find('title');
+ const link = el.find('link');
+ expect(title.props().children).toEqual(`Gradebook | ${config.SITE_NAME}`);
+ expect(link.props().rel).toEqual('shortcut icon');
+ expect(link.props().href).toEqual(config.FAVICON_URL);
});
});
diff --git a/src/setupTest.js b/src/setupTest.js
index 478a13fd..fddb405b 100755
--- a/src/setupTest.js
+++ b/src/setupTest.js
@@ -14,13 +14,83 @@ process.env.FAVICON_URL = 'http://localhost:18000/favicon.ico';
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const PropTypes = jest.requireActual('prop-types');
+ const { formatMessage } = jest.requireActual('./testUtils');
+ const formatDate = jest.fn(date => new Date(date).toLocaleDateString()).mockName('useIntl.formatDate');
return {
...i18n,
intlShape: PropTypes.shape({
- formatMessage: jest.fn(msg => msg.defaultMessage),
+ formatMessage: PropTypes.func,
}),
+ useIntl: jest.fn(() => ({
+ formatMessage,
+ formatDate,
+ })),
+ IntlProvider: () => 'IntlProvider',
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
getLocale: jest.fn(),
};
});
+
+jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedComponents({
+ Alert: 'Alert',
+ ActionRow: 'ActionRow',
+ Badge: 'Badge',
+ Button: 'Button',
+ Collapsible: {
+ Advanced: 'Collapsible.Advanced',
+ Body: 'Collapsible.Body',
+ Trigger: 'Collapsible.Trigger',
+ Visible: 'Collapsible.Visible',
+ },
+ DataTable: {
+ EmptyTable: 'DataTable.EmptyTable',
+ Table: 'DataTable.Table',
+ TableControlBar: 'DataTable.TableControlBar',
+ TableController: 'DataTable.TableController',
+ TableFooter: 'DataTable.TableFooter',
+ },
+ Form: {
+ Checkbox: 'Form.Checkbox',
+ CheckboxSet: 'Form.CheckboxSet',
+ Control: {
+ Feedback: 'Form.Control.Feedback',
+ },
+ Group: 'Form.Group',
+ Label: 'Form.Label',
+ Radio: 'Form.Radio',
+ RadioSet: 'Form.RadioSet',
+ Switch: 'Form.Switch',
+ },
+ FormControl: 'FormControl',
+ FormGroup: 'FormGroup',
+ FormLabel: 'FormLabel',
+ Hyperlink: 'Hyperlink',
+ Icon: 'Icon',
+ IconButton: 'IconButton',
+ ModalDialog: {
+ Body: 'ModalDialog.Body',
+ CloseButton: 'ModalDialog.CloseButton',
+ Header: 'ModalDialog.Header',
+ Hero: 'ModalDialog.Hero',
+ Footer: 'ModalDialog.Footer',
+ },
+ OverlayTrigger: 'OverlayTrigger',
+ Row: 'Row',
+ StatefulButton: 'StatefulButton',
+ Spinner: 'Spinner',
+
+ useCheckboxSetValues: () => jest.fn().mockImplementation((values) => ([values, {
+ add: jest.fn().mockName('useCheckboxSetValues.add'),
+ remove: jest.fn().mockName('useCheckboxSetValues.remove'),
+ }])),
+}));
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useRef: jest.fn((val) => ({ current: val, useRef: true })),
+ useCallback: jest.fn((cb, prereqs) => ({ useCallback: { cb, prereqs } })),
+ useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
+ useMemo: jest.fn((cb, prereqs) => cb(prereqs)),
+ useContext: jest.fn(context => context),
+}));
diff --git a/src/testUtils.js b/src/testUtils.js
index baf986a5..9e9b4621 100644
--- a/src/testUtils.js
+++ b/src/testUtils.js
@@ -10,9 +10,14 @@ export const formatMessage = (msg, values) => {
if (values === undefined) {
return message;
}
+ // check if value is not a primitive type.
+ if (Object.values(values).filter(value => Object(value) === value).length) {
+ // eslint-disable-next-line react/jsx-filename-extension
+ return ;
+ }
Object.keys(values).forEach((key) => {
// eslint-disable-next-line
- message = message.replace(`{${key}}`, values[key]);
+ message = message.replaceAll(`{${key}}`, values[key]);
});
return message;
};
@@ -160,6 +165,14 @@ export class MockUseState {
);
}
+ expectInitializedWith(key, value) {
+ expect(this.hooks.state[key]).toHaveBeenCalledWith(value);
+ }
+
+ expectSetStateCalledWith(key, value) {
+ expect(this.setState[key]).toHaveBeenCalledWith(value);
+ }
+
/**
* Restore the hook module's state object to the actual code.
*/
@@ -184,4 +197,8 @@ export class MockUseState {
expect(this.hooks.state[key](testValue)).toEqual(useState(testValue));
});
}
+
+ get values() {
+ return StrictDict({ ...this.hooks.state });
+ }
}
diff --git a/src/utils/index.js b/src/utils/index.js
index 5351f65e..678ea705 100644
--- a/src/utils/index.js
+++ b/src/utils/index.js
@@ -1,2 +1,3 @@
/* eslint-disable import/prefer-default-export */
export { default as StrictDict } from './StrictDict';
+export { default as keyStore } from './keyStore';
diff --git a/src/utils/keyStore.js b/src/utils/keyStore.js
new file mode 100644
index 00000000..a670f436
--- /dev/null
+++ b/src/utils/keyStore.js
@@ -0,0 +1,10 @@
+import StrictDict from './StrictDict';
+
+const keyStore = (collection) => StrictDict(
+ Object.keys(collection).reduce(
+ (obj, key) => ({ ...obj, [key]: key }),
+ {},
+ ),
+);
+
+export default keyStore;