Compare commits

...

23 Commits

Author SHA1 Message Date
Ben Warzeski
8e19b5774c fix: merge cruft leftover file 2023-05-15 11:32:42 -04:00
Ben Warzeski
f7a4309888 fix: table tests 2023-05-15 11:16:48 -04:00
Omar Al-Ithawi
3e3a73e2bb feat: use atlas in make pull_translations (#325)
Changes
-------
 - Bump frontend-platform to bring `intl-imports.js` script
 - Move all i18n imports into `src/i18n/index.js` so `intl-imports.js` can
   override it with latest translations
 - Add `atlas` into `make pull_translations` when `OPENEDX_ATLAS_PULL`
   environment variable is set.
 - Refactored i18n utils into own file to avoid overwriting them by
   atlas

Refs: [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) implementing Translation Infrastructure OEP-58.
2023-05-15 10:58:11 -04:00
Ben Warzeski
af8d7182ef fix: date test 2023-05-15 10:57:48 -04:00
Ben Warzeski
d898a9cc2f fix: null renders 2023-05-15 10:57:25 -04:00
Ben Warzeski
f4b839f4d8 fix: lint 2023-05-15 10:57:24 -04:00
Ben Warzeski
f41b237d08 fix: redux transform tests 2023-05-15 10:57:24 -04:00
Ben Warzeski
6dd2fb3dd6 refactor: GradebookFilters component modernization 2023-05-15 10:57:23 -04:00
Ben Warzeski
b173681edb refactor: GradesView component modernization 2023-05-15 10:57:23 -04:00
Ben Warzeski
5fcde3b9e8 refactor: ImportSuccessToast component modernization 2023-05-15 10:57:22 -04:00
Ben Warzeski
35ee68ea9d refactor: SearchControls component modernization 2023-05-15 10:57:22 -04:00
Ben Warzeski
dde8e759b6 refactor: ScoreViewInput component modernization 2023-05-15 10:57:22 -04:00
Ben Warzeski
6b149e9ce0 refactor: PageButtons component modernization 2023-05-15 10:57:21 -04:00
Ben Warzeski
4cf5ba7a07 refactor: FilteredUsersLabel component modernization 2023-05-15 10:57:21 -04:00
Ben Warzeski
7a506324a8 refactor: FilterMenuToggle component modernization 2023-05-15 10:57:20 -04:00
Ben Warzeski
7f54cc4917 refactor: FilterBadges component modernization 2023-05-15 10:57:20 -04:00
Ben Warzeski
134ace9483 refactor: GradebookTable component modernization 2023-05-15 10:57:18 -04:00
Ben Warzeski
0e6f52fca9 refactor: EditModal component updates 2023-05-15 10:50:31 -04:00
Ben Warzeski
ca64cc614a feat: redux hooks 2023-05-15 10:50:19 -04:00
Ben Warzeski
1ad297c46c refactor: testing architecture updates 2023-05-15 10:50:17 -04:00
Ben Warzeski
f2bb0d7c2a refactor: interventsions report component 2023-05-15 10:49:45 -04:00
Ben Warzeski
f76f3d64c9 refactor: bulk management controls component 2023-05-15 10:49:45 -04:00
Ben Warzeski
db56d76d37 refactor: gradebook header component refactor 2023-05-15 10:49:44 -04:00
161 changed files with 7821 additions and 16126 deletions

15189
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -76,7 +76,7 @@
"fetch-mock": "^6.5.2",
"husky": "2.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "29.3.1",
"jest": "^26.6.3",
"react-dev-utils": "^12.0.1",
"react-test-renderer": "^16.10.1",
"reactifex": "1.1.1",

View File

@@ -16,7 +16,7 @@ exports[`GradebookFilters render snapshot 1`] = `
className="p-1"
iconAs="Icon"
onClick={[MockFunction hook.closeMenu]}
src={[Function]}
src="Close"
/>
</div>
<Collapsible

View File

@@ -3,7 +3,7 @@ import { actions, selectors, thunkActions } from 'data/redux/hooks';
export const useGradebookFiltersData = ({ updateQueryParams }) => {
const includeCourseRoleMembers = selectors.filters.useIncludeCourseRoleMembers();
const updateIncludeCourseRoleMembers = actions.filters.useUpdateIncludeCourseRoleMembers();
const closeMenu = thunkActions.app.useCloseFilterMenu();
const closeMenu = thunkActions.app.filterMenu.useCloseMenu();
const fetchGrades = thunkActions.grades.useFetchGrades();
const handleIncludeTeamMembersChange = ({ target: { checked } }) => {

View File

@@ -9,7 +9,9 @@ jest.mock('data/redux/hooks', () => ({
filters: { useIncludeCourseRoleMembers: jest.fn() },
},
thunkActions: {
app: { useCloseFilterMenu: jest.fn() },
app: {
filterMenu: { useCloseMenu: jest.fn() },
},
grades: { useFetchGrades: jest.fn() },
},
}));
@@ -18,7 +20,7 @@ selectors.filters.useIncludeCourseRoleMembers.mockReturnValue(true);
const updateIncludeCourseRoleMembers = jest.fn();
actions.filters.useUpdateIncludeCourseRoleMembers.mockReturnValue(updateIncludeCourseRoleMembers);
const closeFilterMenu = jest.fn();
thunkActions.app.useCloseFilterMenu.mockReturnValue(closeFilterMenu);
thunkActions.app.filterMenu.useCloseMenu.mockReturnValue(closeFilterMenu);
const fetchGrades = jest.fn();
thunkActions.grades.useFetchGrades.mockReturnValue(fetchGrades);
@@ -34,7 +36,7 @@ describe('GradebookFiltersData component hooks', () => {
it('initializes hooks', () => {
expect(actions.filters.useUpdateIncludeCourseRoleMembers).toHaveBeenCalledWith();
expect(selectors.filters.useIncludeCourseRoleMembers).toHaveBeenCalledWith();
expect(thunkActions.app.useCloseFilterMenu).toHaveBeenCalledWith();
expect(thunkActions.app.filterMenu.useCloseMenu).toHaveBeenCalledWith();
expect(thunkActions.grades.useFetchGrades).toHaveBeenCalledWith();
});
});

View File

@@ -0,0 +1,131 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookHeader component render default view shapshot 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="test-dashboard-url"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
Back to Dashboard
</a>
<h1>
Gradebook
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2>
test-course-id
</h2>
</div>
</div>
`;
exports[`GradebookHeader component render frozen grades snapshot: show frozen warning 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="test-dashboard-url"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
Back to Dashboard
</a>
<h1>
Gradebook
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2>
test-course-id
</h2>
</div>
<div
className="alert alert-warning"
role="alert"
>
The grades for this course are now frozen. Editing of grades is no longer allowed.
</div>
</div>
`;
exports[`GradebookHeader component render show bulk management snapshot: show toggle view message button with handleToggleViewClick method 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="test-dashboard-url"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
Back to Dashboard
</a>
<h1>
Gradebook
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2>
test-course-id
</h2>
<Button
onClick={[MockFunction hooks.handleToggleViewClick]}
variant="tertiary"
>
toggle-view-message
</Button>
</div>
</div>
`;
exports[`GradebookHeader component render user cannot view gradebook snapshot: show unauthorized warning 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="test-dashboard-url"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
Back to Dashboard
</a>
<h1>
Gradebook
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2>
test-course-id
</h2>
</div>
<div
className="alert alert-warning"
role="alert"
>
You are not authorized to view the gradebook for this course.
</div>
</div>
`;

View File

@@ -1,261 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookHeader component snapshots default values (grades frozen, cannot view). unauthorized warning, but no grades frozen warning 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="http://localhost:18000/courses/fakeID/instructor"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
<FormattedMessage
defaultMessage="Back to Dashboard"
description="Button text to take user back to LMS dashboard in Gradebook Header"
id="gradebook.GradebookHeader.backButton"
/>
</a>
<h1>
<FormattedMessage
defaultMessage="Gradebook"
description="Top-level app title in Gradebook Header component"
id="gradebook.GradebookHeader.appLabel"
/>
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2>
fakeID
</h2>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
defaultMessage="You are not authorized to view the gradebook for this course."
description="Warning message in Gradebook Header when user is not allowed to view the app"
id="gradebook.GradebookHeader.unauthorizedWarning"
/>
</div>
</div>
`;
exports[`GradebookHeader component snapshots grades frozen, can view. grades frozen warning but no unauthorized warning 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="http://localhost:18000/courses/fakeID/instructor"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
<FormattedMessage
defaultMessage="Back to Dashboard"
description="Button text to take user back to LMS dashboard in Gradebook Header"
id="gradebook.GradebookHeader.backButton"
/>
</a>
<h1>
<FormattedMessage
defaultMessage="Gradebook"
description="Top-level app title in Gradebook Header component"
id="gradebook.GradebookHeader.appLabel"
/>
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2>
fakeID
</h2>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
defaultMessage="The grades for this course are now frozen. Editing of grades is no longer allowed."
description="Warning message in Gradebook Header for frozen messages"
id="gradebook.GradebookHeader.frozenWarning"
/>
</div>
</div>
`;
exports[`GradebookHeader component snapshots grades frozen, cannot view unauthorized warning, and grades frozen warning. 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="http://localhost:18000/courses/fakeID/instructor"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
<FormattedMessage
defaultMessage="Back to Dashboard"
description="Button text to take user back to LMS dashboard in Gradebook Header"
id="gradebook.GradebookHeader.backButton"
/>
</a>
<h1>
<FormattedMessage
defaultMessage="Gradebook"
description="Top-level app title in Gradebook Header component"
id="gradebook.GradebookHeader.appLabel"
/>
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2>
fakeID
</h2>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
defaultMessage="The grades for this course are now frozen. Editing of grades is no longer allowed."
description="Warning message in Gradebook Header for frozen messages"
id="gradebook.GradebookHeader.frozenWarning"
/>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
defaultMessage="You are not authorized to view the gradebook for this course."
description="Warning message in Gradebook Header when user is not allowed to view the app"
id="gradebook.GradebookHeader.unauthorizedWarning"
/>
</div>
</div>
`;
exports[`GradebookHeader component snapshots show bulk management, active view is bulkManagementHistory view toggle view button to grades 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="http://localhost:18000/courses/fakeID/instructor"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
<FormattedMessage
defaultMessage="Back to Dashboard"
description="Button text to take user back to LMS dashboard in Gradebook Header"
id="gradebook.GradebookHeader.backButton"
/>
</a>
<h1>
<FormattedMessage
defaultMessage="Gradebook"
description="Top-level app title in Gradebook Header component"
id="gradebook.GradebookHeader.appLabel"
/>
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2>
fakeID
</h2>
<Button
onClick={[MockFunction this.handleToggleViewClick]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="Return to Gradebook"
description="Button text for button navigating to Grades view."
id="gradebook.GradebookHeader.toGradesView"
/>
</Button>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
defaultMessage="You are not authorized to view the gradebook for this course."
description="Warning message in Gradebook Header when user is not allowed to view the app"
id="gradebook.GradebookHeader.unauthorizedWarning"
/>
</div>
</div>
`;
exports[`GradebookHeader component snapshots show bulk management, active view is grades view toggle view button to activity log 1`] = `
<div
className="gradebook-header"
>
<a
className="mb-3"
href="http://localhost:18000/courses/fakeID/instructor"
>
<span
aria-hidden="true"
>
&lt;&lt;
</span>
<FormattedMessage
defaultMessage="Back to Dashboard"
description="Button text to take user back to LMS dashboard in Gradebook Header"
id="gradebook.GradebookHeader.backButton"
/>
</a>
<h1>
<FormattedMessage
defaultMessage="Gradebook"
description="Top-level app title in Gradebook Header component"
id="gradebook.GradebookHeader.appLabel"
/>
</h1>
<div
className="subtitle-row d-flex justify-content-between align-items-center"
>
<h2>
fakeID
</h2>
<Button
onClick={[MockFunction this.handleToggleViewClick]}
variant="tertiary"
>
<FormattedMessage
defaultMessage="View Bulk Management History"
description="Button text for button navigating to Bulk Managment Activity Log"
id="gradebook.GradebookHeader.toActivityLogButton"
/>
</Button>
</div>
<div
className="alert alert-warning"
role="alert"
>
<FormattedMessage
defaultMessage="You are not authorized to view the gradebook for this course."
description="Warning message in Gradebook Header when user is not allowed to view the app"
id="gradebook.GradebookHeader.unauthorizedWarning"
/>
</div>
</div>
`;

View File

@@ -0,0 +1,35 @@
import { views } from 'data/constants/app';
import { actions, selectors } from 'data/redux/hooks';
import messages from './messages';
export const useGradebookHeaderData = () => {
const activeView = selectors.app.useActiveView();
const courseId = selectors.app.useCourseId();
const areGradesFrozen = selectors.assignmentTypes.useAreGradesFrozen();
const canUserViewGradebook = selectors.roles.useCanUserViewGradebook();
const showBulkManagement = selectors.root.useShowBulkManagement();
const setView = actions.app.useSetView();
const handleToggleViewClick = () => setView(
activeView === views.grades
? views.bulkManagementHistory
: views.grades,
);
const toggleViewMessage = activeView === views.grades
? messages.toActivityLog
: messages.toGradesView;
return {
areGradesFrozen,
canUserViewGradebook,
courseId,
showBulkManagement,
handleToggleViewClick,
toggleViewMessage,
};
};
export default useGradebookHeaderData;

View File

@@ -0,0 +1,90 @@
import { views } from 'data/constants/app';
import { actions, selectors } from 'data/redux/hooks';
import messages from './messages';
import useGradebookHeaderData from './hooks';
jest.mock('data/redux/hooks', () => ({
actions: {
app: {
useSetView: jest.fn(),
},
},
selectors: {
app: {
useActiveView: jest.fn(),
useCourseId: jest.fn(),
},
assignmentTypes: {
useAreGradesFrozen: jest.fn(),
},
roles: {
useCanUserViewGradebook: jest.fn(),
},
root: {
useShowBulkManagement: jest.fn(),
},
},
}));
const activeView = 'test-active-view';
selectors.app.useActiveView.mockReturnValue(activeView);
const courseId = 'test-course-id';
selectors.app.useCourseId.mockReturnValue(courseId);
const areGradesFrozen = 'test-are-grades-frozen';
selectors.assignmentTypes.useAreGradesFrozen.mockReturnValue(areGradesFrozen);
const canUserViewGradebook = 'test-can-user-view-gradebook';
selectors.roles.useCanUserViewGradebook.mockReturnValue(canUserViewGradebook);
const showBulkManagement = 'test-show-bulk-management';
selectors.root.useShowBulkManagement.mockReturnValue(showBulkManagement);
const setView = jest.fn();
actions.app.useSetView.mockReturnValue(setView);
let out;
describe('useGradebookHeaderData hooks', () => {
describe('initialization', () => {
it('initializes redux hooks', () => {
out = useGradebookHeaderData();
expect(selectors.app.useActiveView).toHaveBeenCalled();
expect(selectors.app.useCourseId).toHaveBeenCalled();
expect(selectors.assignmentTypes.useAreGradesFrozen).toHaveBeenCalled();
expect(selectors.roles.useCanUserViewGradebook).toHaveBeenCalled();
expect(selectors.root.useShowBulkManagement).toHaveBeenCalled();
expect(actions.app.useSetView).toHaveBeenCalled();
});
});
describe('output', () => {
test('redux fields', () => {
out = useGradebookHeaderData();
expect(out.areGradesFrozen).toEqual(areGradesFrozen);
expect(out.canUserViewGradebook).toEqual(canUserViewGradebook);
expect(out.courseId).toEqual(courseId);
expect(out.showBulkManagement).toEqual(showBulkManagement);
});
describe('handleToggleViewClick', () => {
it('calls setView with bulkManagemnetHistory message if grades view is active', () => {
selectors.app.useActiveView.mockReturnValueOnce(views.grades);
out = useGradebookHeaderData();
out.handleToggleViewClick();
expect(setView).toHaveBeenCalledWith(views.bulkManagementHistory);
});
it('calls setView with grades view if grades view is not active', () => {
out = useGradebookHeaderData();
out.handleToggleViewClick();
expect(setView).toHaveBeenCalledWith(views.grades);
});
});
describe('toggleViewMessage', () => {
it('returns toActivityLog message if grades view is active', () => {
selectors.app.useActiveView.mockReturnValueOnce(views.grades);
out = useGradebookHeaderData();
expect(out.toggleViewMessage).toEqual(messages.toActivityLog);
});
it('returns toGradesView message if grades view is not active', () => {
out = useGradebookHeaderData();
expect(out.toggleViewMessage).toEqual(messages.toGradesView);
});
});
});
});

View File

@@ -1,106 +1,50 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { views } from 'data/constants/app';
import actions from 'data/actions';
import selectors from 'data/selectors';
import { instructorDashboardUrl } from 'data/services/lms/urls';
import useGradebookHeaderData from './hooks';
import messages from './messages';
export class GradebookHeader extends React.Component {
constructor(props) {
super(props);
this.handleToggleViewClick = this.handleToggleViewClick.bind(this);
}
handleToggleViewClick() {
const newView = this.props.activeView === views.grades ? views.bulkManagementHistory : views.grades;
this.props.setView(newView);
}
get toggleViewMessage() {
return this.props.activeView === views.grades
? messages.toActivityLog
: messages.toGradesView;
}
lmsInstructorDashboardUrl = courseId => (
`${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor`
);
render() {
return (
<div className="gradebook-header">
<a
href={this.lmsInstructorDashboardUrl(this.props.courseId)}
className="mb-3"
>
<span aria-hidden="true">{'<< '}</span>
<FormattedMessage {...messages.backToDashboard} />
</a>
<h1>
<FormattedMessage {...messages.gradebook} />
</h1>
<div className="subtitle-row d-flex justify-content-between align-items-center">
<h2>{this.props.courseId}</h2>
{ this.props.showBulkManagement && (
<Button
variant="tertiary"
onClick={this.handleToggleViewClick}
>
<FormattedMessage {...this.toggleViewMessage} />
</Button>
)}
</div>
{this.props.areGradesFrozen
&& (
<div className="alert alert-warning" role="alert">
<FormattedMessage {...messages.frozenWarning} />
</div>
)}
{(this.props.canUserViewGradebook === false) && (
<div className="alert alert-warning" role="alert">
<FormattedMessage {...messages.unauthorizedWarning} />
</div>
export const GradebookHeader = () => {
const { formatMessage } = useIntl();
const {
areGradesFrozen,
canUserViewGradebook,
courseId,
handleToggleViewClick,
showBulkManagement,
toggleViewMessage,
} = useGradebookHeaderData();
const dashboardUrl = instructorDashboardUrl();
return (
<div className="gradebook-header">
<a href={dashboardUrl} className="mb-3">
<span aria-hidden="true">{'<< '}</span>
{formatMessage(messages.backToDashboard)}
</a>
<h1>{formatMessage(messages.gradebook)}</h1>
<div className="subtitle-row d-flex justify-content-between align-items-center">
<h2>{courseId}</h2>
{showBulkManagement && (
<Button variant="tertiary" onClick={handleToggleViewClick}>
{formatMessage(toggleViewMessage)}
</Button>
)}
</div>
);
}
}
GradebookHeader.defaultProps = {
// redux
courseId: '',
areGradesFrozen: false,
canUserViewGradebook: false,
showBulkManagement: false,
{areGradesFrozen && (
<div className="alert alert-warning" role="alert">
{formatMessage(messages.frozenWarning)}
</div>
)}
{(canUserViewGradebook === false) && (
<div className="alert alert-warning" role="alert">
{formatMessage(messages.unauthorizedWarning)}
</div>
)}
</div>
);
};
GradebookHeader.propTypes = {
// redux
activeView: PropTypes.string.isRequired,
courseId: PropTypes.string,
areGradesFrozen: PropTypes.bool,
canUserViewGradebook: PropTypes.bool,
setView: PropTypes.func.isRequired,
showBulkManagement: PropTypes.bool,
};
export const mapStateToProps = (state) => ({
activeView: selectors.app.activeView(state),
courseId: selectors.app.courseId(state),
areGradesFrozen: selectors.assignmentTypes.areGradesFrozen(state),
canUserViewGradebook: selectors.roles.canUserViewGradebook(state),
showBulkManagement: selectors.root.showBulkManagement(state),
});
export const mapDispatchToProps = {
setView: actions.app.setView,
};
export default connect(mapStateToProps, mapDispatchToProps)(GradebookHeader);
export default GradebookHeader;

View File

@@ -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 { formatMessage } from 'testUtils';
import { instructorDashboardUrl } from 'data/services/lms/urls';
import useGradebookHeaderData from './hooks';
import GradebookHeader from '.';
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('data/services/lms/urls', () => ({
instructorDashboardUrl: jest.fn(),
}));
instructorDashboardUrl.mockReturnValue('test-dashboard-url');
const hookProps = {
areGradesFrozen: false,
canUserViewGradebook: true,
courseId: 'test-course-id',
handleToggleViewClick: jest.fn().mockName('hooks.handleToggleViewClick'),
showBulkManagement: false,
toggleViewMessage: { defaultMessage: 'toggle-view-message' },
};
useGradebookHeaderData.mockReturnValue(hookProps);
let el;
describe('GradebookHeader component', () => {
beforeAll(() => {
el = shallow(<GradebookHeader />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useGradebookHeaderData).toHaveBeenCalledWith();
expect(useIntl).toHaveBeenCalledWith();
});
});
describe('render', () => {
describe('default view', () => {
test('shapshot', () => {
expect(el).toMatchSnapshot();
});
});
describe('show bulk management', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValueOnce({ ...hookProps, showBulkManagement: true });
el = shallow(<GradebookHeader />);
});
test('snapshot: show toggle view message button with handleToggleViewClick method', () => {
expect(el).toMatchSnapshot();
const { onClick, children } = el.find(Button).props();
expect(onClick).toEqual(hookProps.handleToggleViewClick);
expect(children).toEqual(formatMessage(hookProps.toggleViewMessage));
});
});
describe('frozen grades', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValueOnce({ ...hookProps, areGradesFrozen: true });
el = shallow(<GradebookHeader />);
});
test('snapshot: show frozen warning', () => {
expect(el).toMatchSnapshot();
});
});
describe('user cannot view gradebook', () => {
beforeEach(() => {
useGradebookHeaderData.mockReturnValueOnce({ ...hookProps, canUserViewGradebook: false });
el = shallow(<GradebookHeader />);
});
test('snapshot: show unauthorized warning', () => {
expect(el).toMatchSnapshot();
});
});
});
});

View File

@@ -1,152 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Button } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import actions from 'data/actions';
import selectors from 'data/selectors';
import { views } from 'data/constants/app';
import messages from './messages';
import { GradebookHeader, mapDispatchToProps, mapStateToProps } from '.';
jest.mock('@edx/paragon', () => ({
Button: () => 'Button',
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
app: { setView: jest.fn() },
},
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
activeView: jest.fn(state => ({ aciveView: state })),
courseId: jest.fn(state => ({ courseId: state })),
},
assignmentTypes: { areGradesFrozen: jest.fn(state => ({ areGradesFrozen: state })) },
roles: { canUserViewGradebook: jest.fn(state => ({ canUserViewGradebook: state })) },
root: { showBulkManagement: jest.fn(state => ({ showBulkManagement: state })) },
},
}));
const courseId = 'fakeID';
describe('GradebookHeader component', () => {
const props = {
activeView: views.grades,
areGradesFrozen: false,
canUserViewGradebook: false,
courseId,
showBulkManagement: false,
};
beforeEach(() => {
props.setView = jest.fn();
});
describe('snapshots', () => {
let el;
beforeEach(() => {
el = shallow(<GradebookHeader {...props} />);
el.instance().handleToggleViewClick = jest.fn().mockName('this.handleToggleViewClick');
});
describe('default values (grades frozen, cannot view).', () => {
test('unauthorized warning, but no grades frozen warning', () => {
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('grades frozen, cannot view', () => {
test('unauthorized warning, and grades frozen warning.', () => {
el.setProps({ areGradesFrozen: true });
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('grades frozen, can view.', () => {
test('grades frozen warning but no unauthorized warning', () => {
el.setProps({ areGradesFrozen: true, canUserViewGradebook: true });
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('show bulk management, active view is grades view', () => {
test('toggle view button to activity log', () => {
el.setProps({ showBulkManagement: true });
expect(el.find(Button).getElement()).toEqual((
<Button
variant="tertiary"
onClick={el.instance().handleToggleViewClick}
>
<FormattedMessage {...messages.toActivityLog} />
</Button>
));
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('show bulk management, active view is bulkManagementHistory view', () => {
test('toggle view button to grades', () => {
el.setProps({ showBulkManagement: true, activeView: views.bulkManagementHistory });
expect(el.find(Button).getElement()).toEqual((
<Button
variant="tertiary"
onClick={el.instance().handleToggleViewClick}
>
<FormattedMessage {...messages.toGradesView} />
</Button>
));
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('behavior', () => {
let el;
beforeEach(() => {
el = shallow(<GradebookHeader {...props} />);
});
describe('handleToggleViewClick', () => {
test('calls setView with activity view if activeView is grades', () => {
el.instance().handleToggleViewClick();
expect(props.setView).toHaveBeenCalledWith(views.bulkManagementHistory);
});
test('calls setView with grades view if activeView is bulkManagementHistory', () => {
el.setProps({ activeView: views.bulkManagementHistory });
el.instance().handleToggleViewClick();
expect(props.setView).toHaveBeenCalledWith(views.grades);
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = { a: 'test', example: 'state' };
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('activeView from app.activeView', () => {
expect(mapped.activeView).toEqual(selectors.app.activeView(testState));
});
test('courseId from app.courseId', () => {
expect(mapped.courseId).toEqual(selectors.app.courseId(testState));
});
test('areGradesFrozen from assignmentTypes selector', () => {
expect(
mapped.areGradesFrozen,
).toEqual(selectors.assignmentTypes.areGradesFrozen(testState));
});
test('canUserViewGradebook from roles selector', () => {
expect(
mapped.canUserViewGradebook,
).toEqual(selectors.roles.canUserViewGradebook(testState));
});
test('showBulkManagement from root showBulkManagement selector', () => {
expect(mapped.showBulkManagement).toEqual(selectors.root.showBulkManagement(testState));
});
});
describe('mapDispatchToProps', () => {
test('setView from actions.app.setView', () => {
expect(mapDispatchToProps.setView).toEqual(actions.app.setView);
});
});
});

View File

@@ -1,71 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { views } from 'data/constants/app';
import actions from 'data/actions';
import selectors from 'data/selectors';
import NetworkButton from 'components/NetworkButton';
import ImportGradesButton from './ImportGradesButton';
import messages from './BulkManagementControls.messages';
/**
* <BulkManagementControls />
* Provides download buttons for Bulk Management and Intervention reports, only if
* showBulkManagement is set in redus.
*/
export class BulkManagementControls extends React.Component {
constructor(props) {
super(props);
this.handleClickExportGrades = this.handleClickExportGrades.bind(this);
this.handleViewActivityLog = this.handleViewActivityLog.bind(this);
}
handleClickExportGrades() {
this.props.downloadBulkGradesReport();
window.location.assign(this.props.gradeExportUrl);
}
handleViewActivityLog() {
this.props.setView(views.bulkManagementHistory);
}
render() {
return this.props.showBulkManagement && (
<div className="d-flex">
<NetworkButton
label={messages.downloadGradesBtn}
onClick={this.handleClickExportGrades}
/>
<ImportGradesButton />
</div>
);
}
}
BulkManagementControls.defaultProps = {
showBulkManagement: false,
};
BulkManagementControls.propTypes = {
// redux
downloadBulkGradesReport: PropTypes.func.isRequired,
gradeExportUrl: PropTypes.string.isRequired,
showBulkManagement: PropTypes.bool,
setView: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
gradeExportUrl: selectors.root.gradeExportUrl(state),
showBulkManagement: selectors.root.showBulkManagement(state),
});
export const mapDispatchToProps = {
downloadBulkGradesReport: actions.grades.downloadReport.bulkGrades,
setView: actions.app.setView,
};
export default connect(mapStateToProps, mapDispatchToProps)(BulkManagementControls);

View File

@@ -1,124 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import selectors from 'data/selectors';
import { views } from 'data/constants/app';
import {
BulkManagementControls,
mapStateToProps,
mapDispatchToProps,
} from './BulkManagementControls';
jest.mock('./ImportGradesButton', () => 'ImportGradesButton');
jest.mock('components/NetworkButton', () => 'NetworkButton');
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
root: {
gradeExportUrl: (state) => ({ gradeExportUrl: state }),
interventionExportUrl: (state) => ({ interventionExportUrl: state }),
showBulkManagement: (state) => ({ showBulkManagement: state }),
},
},
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
app: { setView: jest.fn() },
grades: {
downloadReport: {
bulkGrades: jest.fn(),
intervention: jest.fn(),
},
},
},
}));
describe('BulkManagementControls', () => {
describe('component', () => {
let el;
let props = {
gradeExportUrl: 'gradesGoHere',
interventionExportUrl: 'interventionsGoHere',
};
beforeEach(() => {
props = {
...props,
downloadBulkGradesReport: jest.fn(),
downloadInterventionReport: jest.fn(),
setView: jest.fn(),
};
});
test('snapshot - empty if showBulkManagement is not truthy', () => {
expect(shallow(<BulkManagementControls {...props} />)).toEqual({});
});
describe('behavior', () => {
const oldWindowLocation = window.location;
beforeAll(() => {
delete window.location;
window.location = Object.defineProperties(
{},
{
...Object.getOwnPropertyDescriptors(oldWindowLocation),
assign: {
configurable: true,
value: jest.fn(),
},
},
);
});
beforeEach(() => {
window.location.assign.mockReset();
el = shallow(<BulkManagementControls {...props} showBulkManagement />);
});
afterAll(() => {
// restore `window.location` to the `jsdom` `Location` object
window.location = oldWindowLocation;
});
describe('handleViewActivityLog', () => {
it('calls props.setView(views.bulkManagementHistory)', () => {
el.instance().handleViewActivityLog();
expect(props.setView).toHaveBeenCalledWith(views.bulkManagementHistory);
});
});
describe('handleClickExportGrades', () => {
const assertions = [
'calls props.downloadBulkGradesReport',
'sets location to props.gradeExportUrl',
];
it(assertions.join(' and '), () => {
el.instance().handleClickExportGrades();
expect(props.downloadBulkGradesReport).toHaveBeenCalled();
expect(window.location.assign).toHaveBeenCalledWith(props.gradeExportUrl);
});
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = { do: 'not', test: 'me' };
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('gradeExportUrl from root.gradeExportUrl', () => {
expect(mapped.gradeExportUrl).toEqual(selectors.root.gradeExportUrl(testState));
});
test('showBulkManagement from root.showBulkManagement', () => {
expect(mapped.showBulkManagement).toEqual(selectors.root.showBulkManagement(testState));
});
});
describe('mapDispatchToProps', () => {
test('downloadBulkGradesReport from actions.grades.downloadReport.bulkGrades', () => {
expect(
mapDispatchToProps.downloadBulkGradesReport,
).toEqual(actions.grades.downloadReport.bulkGrades);
});
test('setView from actions.app.setView', () => {
expect(mapDispatchToProps.setView).toEqual(actions.app.setView);
});
});
});

View File

@@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BulkManagementControls render snapshot - show - network and import buttons 1`] = `
<div
className="d-flex"
>
<NetworkButton
label={
Object {
"defaultMessage": "Download Grades",
"description": "A labeled button that allows an admin user to download course grades all at once (in bulk).",
"id": "gradebook.GradesView.BulkManagementControls.bulkManagementLabel",
}
}
onClick={[MockFunction]}
/>
<ImportGradesButton />
</div>
`;

View File

@@ -0,0 +1,18 @@
import { actions, selectors } from 'data/redux/hooks';
export const useBulkManagementControlsData = () => {
const gradeExportUrl = selectors.root.useGradeExportUrl();
const showBulkManagement = selectors.root.useShowBulkManagement();
const downloadBulkGradesReport = actions.grades.useDownloadBulkGradesReport();
const handleClickExportGrades = () => {
downloadBulkGradesReport();
window.location.assign(gradeExportUrl);
};
return {
show: showBulkManagement,
handleClickExportGrades,
};
};
export default useBulkManagementControlsData;

View File

@@ -0,0 +1,72 @@
import { actions, selectors } from 'data/redux/hooks';
import useBulkManagementControlsData from './hooks';
jest.mock('data/redux/hooks', () => ({
actions: {
grades: {
useDownloadBulkGradesReport: jest.fn(),
},
},
selectors: {
root: {
useGradeExportUrl: jest.fn(),
useShowBulkManagement: jest.fn(),
},
},
}));
const downloadBulkGrades = jest.fn();
actions.grades.useDownloadBulkGradesReport.mockReturnValue(downloadBulkGrades);
const gradeExportUrl = 'test-grade-export-url';
selectors.root.useGradeExportUrl.mockReturnValue(gradeExportUrl);
selectors.root.useShowBulkManagement.mockReturnValue(true);
let hook;
describe('useBulkManagementControlsData', () => {
const oldWindowLocation = window.location;
beforeAll(() => {
delete window.location;
window.location = Object.defineProperties(
{},
{
...Object.getOwnPropertyDescriptors(oldWindowLocation),
assign: { configurable: true, value: jest.fn() },
},
);
});
beforeEach(() => {
window.location.assign.mockReset();
hook = useBulkManagementControlsData();
});
afterAll(() => {
// restore `window.location` to the `jsdom` `Location` object
window.location = oldWindowLocation;
});
describe('initialization', () => {
it('initializes redux hooks', () => {
expect(selectors.root.useGradeExportUrl).toHaveBeenCalledWith();
expect(selectors.root.useShowBulkManagement).toHaveBeenCalledWith();
expect(actions.grades.useDownloadBulkGradesReport).toHaveBeenCalledWith();
});
});
describe('output', () => {
it('forwards show from showBulkManagement', () => {
expect(hook.show).toEqual(true);
selectors.root.useShowBulkManagement.mockReturnValue(false);
hook = useBulkManagementControlsData();
expect(hook.show).toEqual(false);
});
describe('handleClickExportGrades', () => {
beforeEach(() => {
hook.handleClickExportGrades();
});
it('downloads bulk grades report', () => {
expect(downloadBulkGrades).toHaveBeenCalledWith();
});
it('sets window location to grade export url', () => {
expect(window.location.assign).toHaveBeenCalledWith(gradeExportUrl);
});
});
});
});

View File

@@ -0,0 +1,33 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import NetworkButton from 'components/NetworkButton';
import ImportGradesButton from '../ImportGradesButton';
import useBulkManagementControlsData from './hooks';
import messages from './messages';
/**
* <BulkManagementControls />
* Provides download buttons for Bulk Management and Intervention reports, only if
* showBulkManagement is set in redus.
*/
export const BulkManagementControls = () => {
const {
show,
handleClickExportGrades,
} = useBulkManagementControlsData();
if (!show) { return null; }
return (
<div className="d-flex">
<NetworkButton
label={messages.downloadGradesBtn}
onClick={handleClickExportGrades}
/>
<ImportGradesButton />
</div>
);
};
export default BulkManagementControls;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { shallow } from 'enzyme';
import useBulkManagementControlsData from './hooks';
import BulkManagementControls from '.';
jest.mock('../ImportGradesButton', () => 'ImportGradesButton');
jest.mock('components/NetworkButton', () => 'NetworkButton');
jest.mock('./hooks', () => jest.fn());
const hookProps = {
show: true,
handleClickExportGrades: jest.fn(),
};
useBulkManagementControlsData.mockReturnValue(hookProps);
describe('BulkManagementControls', () => {
describe('behavior', () => {
shallow(<BulkManagementControls />);
expect(useBulkManagementControlsData).toHaveBeenCalledWith();
});
describe('render', () => {
test('snapshot - show - network and import buttons', () => {
expect(shallow(<BulkManagementControls />)).toMatchSnapshot();
});
test('snapshot - empty if show is not truthy', () => {
useBulkManagementControlsData.mockReturnValueOnce({ ...hookProps, show: false });
expect(shallow(<BulkManagementControls />).isEmptyRender()).toEqual(true);
});
});
});

View File

@@ -1,68 +1,53 @@
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 selectors from 'data/selectors';
import { StrictDict } from 'utils';
import { selectors } from 'data/redux/hooks';
import messages from './messages';
import HistoryHeader from './HistoryHeader';
export const HistoryKeys = StrictDict({
assignment: 'assignment',
student: 'student',
originalGrade: 'original-grade',
currentGrade: 'current-grade',
});
/**
* <ModalHeaders />
* Provides a list of HistoryHeaders for the student name, assignment,
* original grade, and current override grade.
*/
export const ModalHeaders = ({
modalState,
originalGrade,
currentGrade,
}) => (
<div>
<HistoryHeader
id="assignment"
label={<FormattedMessage {...messages.assignmentHeader} />}
value={modalState.assignmentName}
/>
<HistoryHeader
id="student"
label={<FormattedMessage {...messages.studentHeader} />}
value={modalState.updateUserName}
/>
<HistoryHeader
id="original-grade"
label={<FormattedMessage {...messages.originalGradeHeader} />}
value={originalGrade}
/>
<HistoryHeader
id="current-grade"
label={<FormattedMessage {...messages.currentGradeHeader} />}
value={currentGrade}
/>
</div>
);
ModalHeaders.defaultProps = {
currentGrade: null,
originalGrade: null,
};
ModalHeaders.propTypes = {
// redux
currentGrade: PropTypes.number,
originalGrade: PropTypes.number,
modalState: PropTypes.shape({
assignmentName: PropTypes.string.isRequired,
updateUserName: PropTypes.string,
}).isRequired,
export const ModalHeaders = () => {
const { assignmentName, updateUserName } = selectors.app.useModalData();
const { currentGrade, originalGrade } = selectors.grades.useGradeData();
const { formatMessage } = useIntl();
return (
<div>
<HistoryHeader
id={HistoryKeys.assignment}
label={formatMessage(messages.assignmentHeader)}
value={assignmentName}
/>
<HistoryHeader
id={HistoryKeys.student}
label={formatMessage(messages.studentHeader)}
value={updateUserName}
/>
<HistoryHeader
id={HistoryKeys.originalGrade}
label={formatMessage(messages.originalGradeHeader)}
value={originalGrade}
/>
<HistoryHeader
id={HistoryKeys.currentGrade}
label={formatMessage(messages.currentGradeHeader)}
value={currentGrade}
/>
</div>
);
};
export const mapStateToProps = (state) => ({
modalState: {
assignmentName: selectors.app.modalState.assignmentName(state),
updateUserName: selectors.app.modalState.updateUserName(state),
},
currentGrade: selectors.grades.gradeOverrideCurrentEarnedGradedOverride(state),
originalGrade: selectors.grades.gradeOriginalEarnedGraded(state),
});
export default connect(mapStateToProps)(ModalHeaders);
export default ModalHeaders;

View File

@@ -1,93 +1,84 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import { useIntl } from '@edx/frontend-platform/i18n';
import { selectors } from 'data/redux/hooks';
import {
ModalHeaders,
mapStateToProps,
} from './ModalHeaders';
import { formatMessage } from 'testUtils';
import HistoryHeader from './HistoryHeader';
import ModalHeaders, { HistoryKeys } from './ModalHeaders';
import messages from './messages';
jest.mock('./HistoryHeader', () => 'HistoryHeader');
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
editUpdateData: jest.fn(state => ({ editUpdateData: state })),
modalState: {
assignmentName: jest.fn(state => ({ assignmentName: state })),
updateUserName: jest.fn(state => ({ updateUserName: state })),
},
},
grades: {
gradeOverrideCurrentEarnedGradedOverride: jest.fn(state => ({ currentGrade: state })),
gradeOriginalEarnedGraded: jest.fn(state => ({ originalGrade: state })),
},
jest.mock('data/redux/hooks', () => ({
selectors: {
app: { useModalData: jest.fn() },
grades: { useGradeData: jest.fn() },
},
}));
describe('ModalHeaders', () => {
let el;
const props = {
currentGrade: 2,
originalGrade: 20,
modalState: {
assignmentName: 'Qwerty',
updateUserName: 'Uiop',
},
};
describe('Component', () => {
describe('snapshots', () => {
beforeEach(() => {
});
describe('gradeOverrideHistoryError is and empty and open is true', () => {
test('modal open and StatusAlert showing', () => {
el = shallow(<ModalHeaders {...props} />);
expect(el).toMatchSnapshot();
});
});
describe('gradeOverrideHistoryError is empty and open is false', () => {
test('modal closed and StatusAlert closed', () => {
el = shallow(
<ModalHeaders {...props} open={false} gradeOverrideHistoryError="" />,
);
expect(el).toMatchSnapshot();
});
});
const modalData = {
assignmentName: 'test-assignment-name',
updateUserName: 'test-user-name',
};
selectors.app.useModalData.mockReturnValue(modalData);
const gradeData = {
currentGrade: 'test-current-grade',
originalGrade: 'test-original-grade',
};
selectors.grades.useGradeData.mockReturnValue(gradeData);
let el;
describe('ModalHeaders', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<ModalHeaders />);
});
describe('behavior', () => {
it('initializes intl', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes redux hooks', () => {
expect(selectors.app.useModalData).toHaveBeenCalled();
expect(selectors.grades.useGradeData).toHaveBeenCalled();
});
});
describe('mapStateToProps', () => {
const testState = { he: 'lives in a', pineapple: 'under the sea' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
describe('modalState', () => {
test('assignmentName from app.modalState.assignmentName', () => {
expect(
mapped.modalState.assignmentName,
).toEqual(selectors.app.modalState.assignmentName(testState));
});
test('updateUserName from app.modalState.updateUserName', () => {
expect(
mapped.modalState.updateUserName,
).toEqual(selectors.app.modalState.updateUserName(testState));
test('assignment header', () => {
const headerProps = el.find(HistoryHeader).at(0).props();
expect(headerProps).toMatchObject({
id: HistoryKeys.assignment,
label: formatMessage(messages.assignmentHeader),
value: modalData.assignmentName,
});
});
describe('originalGrade', () => {
test('from grades.gradeOverrideCurrentEarnedGradedOverride', () => {
expect(mapped.currentGrade).toEqual(
selectors.grades.gradeOverrideCurrentEarnedGradedOverride(testState),
);
test('student header', () => {
const headerProps = el.find(HistoryHeader).at(1).props();
expect(headerProps).toMatchObject({
id: HistoryKeys.student,
label: formatMessage(messages.studentHeader),
value: modalData.updateUserName,
});
});
describe('originalGrade', () => {
test('from grades.gradeOriginalEarnedGrades', () => {
expect(mapped.originalGrade).toEqual(
selectors.grades.gradeOriginalEarnedGraded(testState),
);
test('originalGrade header', () => {
const headerProps = el.find(HistoryHeader).at(2).props();
expect(headerProps).toMatchObject({
id: HistoryKeys.originalGrade,
label: formatMessage(messages.originalGradeHeader),
value: gradeData.originalGrade,
});
});
test('currentGrade header', () => {
const headerProps = el.find(HistoryHeader).at(3).props();
expect(headerProps).toMatchObject({
id: HistoryKeys.currentGrade,
label: formatMessage(messages.currentGradeHeader),
value: gradeData.currentGrade,
});
});
});

View File

@@ -1,65 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Form } from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
import { getLocalizedSlash } from 'i18n/utils';
/**
* <AdjustedGradeInput />
* Input control for adjusting the grade of a unit
* displays an "/ ${possibleGrade} if there is one in the data model.
*/
export class AdjustedGradeInput extends React.Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
}
onChange = ({ target }) => {
this.props.setModalState({ adjustedGradeValue: target.value });
};
render() {
return (
<span>
<Form.Control
type="text"
name="adjustedGradeValue"
value={this.props.value}
onChange={this.onChange}
/>
{this.props.possibleGrade && ` ${getLocalizedSlash()} ${this.props.possibleGrade}`}
</span>
);
}
}
AdjustedGradeInput.defaultProps = {
possibleGrade: null,
};
AdjustedGradeInput.propTypes = {
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
possibleGrade: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
setModalState: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
possibleGrade: selectors.root.editModalPossibleGrade(state),
value: selectors.app.modalState.adjustedGradeValue(state),
});
export const mapDispatchToProps = {
setModalState: actions.app.setModalState,
};
export default connect(mapStateToProps, mapDispatchToProps)(AdjustedGradeInput);

View File

@@ -1,92 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import selectors from 'data/selectors';
import {
AdjustedGradeInput,
mapStateToProps,
mapDispatchToProps,
} from './AdjustedGradeInput';
jest.mock('@edx/paragon', () => ({
Form: { Control: () => 'Form.Control' },
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
root: {
editModalPossibleGrade: jest.fn(state => ({ updateUserName: state })),
},
app: {
modalState: { adjustedGradeValue: jest.fn(state => ({ adjustedGradeValue: state })) },
},
},
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
app: { setModalState: jest.fn() },
},
}));
describe('AdjustedGradeInput', () => {
let el;
let props = {
value: 1,
possibleGrade: 5,
};
beforeEach(() => {
props = {
...props,
setModalState: jest.fn(),
};
});
describe('Component', () => {
beforeEach(() => {
el = shallow(<AdjustedGradeInput {...props} />);
});
describe('snapshots', () => {
test('displays input control and "out of possible grade" label', () => {
el.instance().onChange = jest.fn().mockName('this.onChange');
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('behavior', () => {
describe('onChange', () => {
it('calls props.setModalState event target value', () => {
const value = 42;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: value,
});
});
});
});
});
describe('mapStateToProps', () => {
const testState = { like: 'no one', ever: 'was' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
describe('modalState', () => {
test('possibleGrade from root.editModalPossibleGrade', () => {
expect(
mapped.possibleGrade,
).toEqual(selectors.root.editModalPossibleGrade(testState));
});
test('updateUserName from app.modalState.updateUserName', () => {
expect(
mapped.value,
).toEqual(selectors.app.modalState.adjustedGradeValue(testState));
});
});
});
describe('mapDispatchToProps', () => {
test('setModalState from actions.app.setModalState', () => {
expect(mapDispatchToProps.setModalState).toEqual(actions.app.setModalState);
});
});
});

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AdjustedGradeInput component render snapshot 1`] = `
<span>
<Form.Control
name="adjustedGradeValue"
onChange={[MockFunction hook.onChange]}
type="text"
value="test-value"
/>
some-hint-text
</span>
`;

View File

@@ -0,0 +1,21 @@
import { actions, selectors } from 'data/redux/hooks';
import { getLocalizedSlash } from 'i18n/utils';
const useAdjustedGradeInputData = () => {
const possibleGrade = selectors.root.useEditModalPossibleGrade();
const value = selectors.app.useModalData().adjustedGradeValue;
const setModalState = actions.app.useSetModalState();
const hintText = possibleGrade && ` ${getLocalizedSlash()} ${possibleGrade}`;
const onChange = ({ target }) => {
setModalState({ adjustedGradeValue: target.value });
};
return {
value,
onChange,
hintText,
};
};
export default useAdjustedGradeInputData;

View File

@@ -0,0 +1,67 @@
import { getLocalizedSlash } from 'i18n/utils';
import { actions, selectors } from 'data/redux/hooks';
import useAdjustedGradeInputData from './hooks';
jest.mock('data/redux/hooks', () => ({
selectors: {
root: {
useEditModalPossibleGrade: jest.fn(),
},
app: {
useModalData: jest.fn(),
},
},
actions: {
app: {
useSetModalState: jest.fn(),
},
},
}));
jest.mock('i18n/utils', () => ({ getLocalizedSlash: jest.fn() }));
const localizedSlash = 'localized-slash';
getLocalizedSlash.mockReturnValue(localizedSlash);
const possibleGrade = 105;
selectors.root.useEditModalPossibleGrade.mockReturnValue(possibleGrade);
const modalData = { adjustedGradeValue: 70 };
const setModalState = jest.fn();
selectors.app.useModalData.mockReturnValue(modalData);
actions.app.useSetModalState.mockReturnValue(setModalState);
let out;
describe('useAdjustedGradeInputData hook', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useAdjustedGradeInputData();
});
describe('behavior', () => {
it('initializes redux hooks', () => {
expect(selectors.root.useEditModalPossibleGrade).toHaveBeenCalled();
expect(selectors.app.useModalData).toHaveBeenCalled();
expect(actions.app.useSetModalState).toHaveBeenCalled();
});
});
describe('output', () => {
it('forwards adjusted grade value as value from modal data', () => {
expect(out.value).toEqual(modalData.adjustedGradeValue);
});
describe('hintText', () => {
it('passes an undefined value if possibleGrade is not available', () => {
selectors.root.useEditModalPossibleGrade.mockReturnValueOnce(undefined);
out = useAdjustedGradeInputData();
expect(out.hintText).toEqual(undefined);
});
it('passes localized slash and possible grade if available', () => {
expect(out.hintText).toEqual(` ${localizedSlash} ${possibleGrade}`);
});
});
describe('onChange', () => {
it('sets modal state with event target value', () => {
const testValue = 'test-value';
out.onChange({ target: { value: testValue } });
expect(setModalState).toHaveBeenCalledWith({ adjustedGradeValue: testValue });
});
});
});
});

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Form } from '@edx/paragon';
import useAdjustedGradeInputData from './hooks';
/**
* <AdjustedGradeInput />
* Input control for adjusting the grade of a unit
* displays an "/ ${possibleGrade} if there is one in the data model.
*/
export const AdjustedGradeInput = () => {
const {
value,
onChange,
hintText,
} = useAdjustedGradeInputData();
return (
<span>
<Form.Control
type="text"
name="adjustedGradeValue"
value={value}
onChange={onChange}
/>
{hintText}
</span>
);
};
AdjustedGradeInput.propTypes = {};
export default AdjustedGradeInput;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Form } from '@edx/paragon';
import useAdjustedGradeInputData from './hooks';
import AdjustedGradeInput from '.';
jest.mock('./hooks', () => jest.fn());
const hookProps = {
hintText: 'some-hint-text',
onChange: jest.fn().mockName('hook.onChange'),
value: 'test-value',
};
useAdjustedGradeInputData.mockReturnValue(hookProps);
let el;
describe('AdjustedGradeInput component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<AdjustedGradeInput />);
});
describe('behavior', () => {
it('initializes hook data', () => {
expect(useAdjustedGradeInputData).toHaveBeenCalled();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
const control = el.find(Form.Control);
expect(control.props().value).toEqual(hookProps.value);
expect(control.props().onChange).toEqual(hookProps.onChange);
expect(el.contains(hookProps.hintText)).toEqual(true);
});
});
});

View File

@@ -1,55 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Form } from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
/**
* <ReasonInput />
* Input control for the "reason for change" field in the Edit modal.
*/
export class ReasonInput extends React.Component {
constructor(props) {
super(props);
this.ref = React.createRef();
this.onChange = this.onChange.bind(this);
}
componentDidMount() {
this.ref.current.focus();
}
onChange = (event) => {
this.props.setModalState({ reasonForChange: event.target.value });
};
render() {
return (
<Form.Control
type="text"
name="reasonForChange"
value={this.props.value}
onChange={this.onChange}
ref={this.ref}
/>
);
}
}
ReasonInput.propTypes = {
// redux
setModalState: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
};
export const mapStateToProps = (state) => ({
value: selectors.app.modalState.reasonForChange(state),
});
export const mapDispatchToProps = {
setModalState: actions.app.setModalState,
};
export default connect(mapStateToProps, mapDispatchToProps)(ReasonInput);

View File

@@ -1,90 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import selectors from 'data/selectors';
import {
ReasonInput,
mapStateToProps,
mapDispatchToProps,
} from './ReasonInput';
jest.mock('@edx/paragon', () => ({
Form: { Control: () => 'Form.Control' },
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
modalState: { reasonForChange: jest.fn(state => ({ reasonForChange: state })) },
},
},
}));
jest.mock('data/actions', () => ({
__esModule: true,
default: {
app: { setModalState: jest.fn() },
},
}));
describe('ReasonInput', () => {
let el;
let props = {
value: 'did not answer the question',
};
beforeEach(() => {
props = {
...props,
setModalState: jest.fn(),
};
});
describe('Component', () => {
beforeEach(() => {
el = shallow(<ReasonInput {...props} />, { disableLifecycleMethods: true });
});
describe('snapshots', () => {
test('displays reason for change input control', () => {
el.instance().onChange = jest.fn().mockName('this.onChange');
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('behavior', () => {
describe('onChange', () => {
it('calls props.setModalState event target value', () => {
const value = 42;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
reasonForChange: value,
});
});
});
describe('componentDidMount', () => {
it('focuses the input ref', () => {
const focus = jest.fn();
expect(el.instance().ref).toEqual({ current: null });
el.instance().ref.current = { focus };
el.instance().componentDidMount();
expect(el.instance().ref.current.focus).toHaveBeenCalledWith();
});
});
});
});
describe('mapStateToProps', () => {
const testState = { to: { catchThem: 'my real test', trainThem: 'my cause!' } };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
describe('modalState', () => {
test('value from app.modalState.reasonForChange', () => {
expect(mapped.value).toEqual(selectors.app.modalState.reasonForChange(testState));
});
});
});
describe('mapDispatchToProps', () => {
test('setModalState from actions.app.setModalState', () => {
expect(mapDispatchToProps.setModalState).toEqual(actions.app.setModalState);
});
});
});

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReasonInput component render snapshot 1`] = `
<Form.Control
data-testid="reason-input-control"
name="reasonForChange"
onChange={[MockFunction hook.onChange]}
type="text"
value="test-value"
/>
`;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { actions, selectors } from 'data/redux/hooks';
const useReasonInputData = () => {
const ref = React.useRef();
const { reasonForChange } = selectors.app.useModalData();
const setModalState = actions.app.useSetModalState();
React.useEffect(() => {
ref.current.focus();
}, [ref]);
const onChange = (event) => {
setModalState({ reasonForChange: event.target.value });
};
return {
value: reasonForChange,
onChange,
ref,
};
};
export default useReasonInputData;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { actions, selectors } from 'data/redux/hooks';
import useReasonInputData from './hooks';
jest.mock('data/redux/hooks', () => ({
selectors: {
app: {
useModalData: jest.fn(),
},
},
actions: {
app: {
useSetModalState: jest.fn(),
},
},
}));
const modalData = { reasonForChange: 'test-reason-for-change' };
const setModalState = jest.fn();
selectors.app.useModalData.mockReturnValue(modalData);
actions.app.useSetModalState.mockReturnValue(setModalState);
const ref = { current: { focus: jest.fn() }, useRef: true };
React.useRef.mockReturnValue(ref);
let out;
describe('useReasonInputData hook', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useReasonInputData();
});
describe('behavior', () => {
it('initializes ref', () => {
expect(React.useRef).toHaveBeenCalled();
});
it('initializes redux hooks', () => {
expect(selectors.app.useModalData).toHaveBeenCalled();
expect(actions.app.useSetModalState).toHaveBeenCalled();
});
it('focuses ref on load', () => {
const [[cb, prereqs]] = React.useEffect.mock.calls;
expect(prereqs).toEqual([ref]);
cb();
expect(ref.current.focus).toHaveBeenCalled();
});
});
describe('output', () => {
it('forwards reasonForChange as value from modal data', () => {
expect(out.value).toEqual(modalData.reasonForChange);
});
it('forwards ref', () => {
expect(out.ref).toEqual(ref);
});
describe('onChange', () => {
it('sets modal state with event target value', () => {
const testValue = 'test-value';
out.onChange({ target: { value: testValue } });
expect(setModalState).toHaveBeenCalledWith({ reasonForChange: testValue });
});
});
});
});

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Form } from '@edx/paragon';
import useReasonInputData from './hooks';
export const controlTestId = 'reason-input-control';
/**
* <ReasonInput />
* Input control for the "reason for change" field in the Edit modal.
*/
export const ReasonInput = () => {
const { ref, value, onChange } = useReasonInputData();
return (
<Form.Control
type="text"
name="reasonForChange"
data-testid={controlTestId}
{...{ value, onChange, ref }}
/>
);
};
ReasonInput.propTypes = {};
export default ReasonInput;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Form } from '@edx/paragon';
import useReasonInputData from './hooks';
import ReasonInput from '.';
jest.mock('./hooks', () => jest.fn());
const hookProps = {
ref: 'reason-input-ref',
onChange: jest.fn().mockName('hook.onChange'),
value: 'test-value',
};
useReasonInputData.mockReturnValue(hookProps);
let el;
describe('ReasonInput component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<ReasonInput />);
});
describe('behavior', () => {
it('initializes hook data', () => {
expect(useReasonInputData).toHaveBeenCalled();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
const control = el.find(Form.Control);
expect(control.props().value).toEqual(hookProps.value);
expect(control.props().onChange).toEqual(hookProps.onChange);
});
});
});

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { render } from '@testing-library/react';
import useReasonInputData from './hooks';
import ReasonInput, { controlTestId } from '.';
jest.unmock('react');
jest.unmock('@edx/paragon');
jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
const focus = jest.fn();
const props = {
value: 'test-value',
onChange: jest.fn(),
ref: { current: { focus }, useRef: jest.fn() },
};
useReasonInputData.mockReturnValue(props);
let el;
describe('ReasonInput ref', () => {
it('loads ref from hook', () => {
el = render(<ReasonInput />);
const control = el.getByTestId(controlTestId);
expect(control).toEqual(props.ref.current);
});
});

View File

@@ -1,13 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AdjustedGradeInput Component snapshots displays input control and "out of possible grade" label 1`] = `
<span>
<Control
name="adjustedGradeValue"
onChange={[MockFunction this.onChange]}
type="text"
value={1}
/>
/ 5
</span>
`;

View File

@@ -1,10 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReasonInput Component snapshots displays reason for change input control 1`] = `
<Control
name="reasonForChange"
onChange={[MockFunction this.onChange]}
type="text"
value="did not answer the question"
/>
`;

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`OverrideTable component render snapshot 1`] = `
<DataTable
columns="test-columns"
data={
Array [
Object {
"test": "data",
},
Object {
"andOther": "test-data",
},
Object {
"adjustedGrade": <AdjustedGradeInput />,
"date": Object {
"formatted": 2000-01-01T00:00:00.000Z,
},
"reason": <ReasonInput />,
},
]
}
itemCount={2}
/>
`;

View File

@@ -1,64 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`OverrideTable Component snapshots basic snapshot shows a row for each entry and one editable row 1`] = `
<DataTable
columns={
Array [
Object {
"Header": <FormattedMessage
defaultMessage="Date"
description="Edit Modal Override Table Date column header"
id="gradebook.GradesView.EditModal.Overrides.dateHeader"
/>,
"accessor": "date",
},
Object {
"Header": <FormattedMessage
defaultMessage="Grader"
description="Edit Modal Override Table Grader column header"
id="gradebook.GradesView.EditModal.Overrides.graderHeader"
/>,
"accessor": "grader",
},
Object {
"Header": <FormattedMessage
defaultMessage="Reason"
description="Edit Modal Override Table Reason column header"
id="gradebook.GradesView.EditModal.Overrides.reasonHeader"
/>,
"accessor": "reason",
},
Object {
"Header": <FormattedMessage
defaultMessage="Adjusted grade"
description="Edit Modal Override Table Adjusted grade column header"
id="gradebook.GradesView.EditModal.Overrides.adjustedGradeHeader"
/>,
"accessor": "adjustedGrade",
},
]
}
data={
Array [
Object {
"adjustedGrade": 0,
"date": "yesterday",
"grader": "me",
"reason": "you ate my sandwich",
},
Object {
"adjustedGrade": 20,
"date": "today",
"grader": "me",
"reason": "you brought me a new sandwich",
},
Object {
"adjustedGrade": <AdjustedGradeInput />,
"date": "todaaaaaay",
"reason": <ReasonInput />,
},
]
}
itemCount={2}
/>
`;

View File

@@ -0,0 +1,26 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { gradeOverrideHistoryColumns as columns } from 'data/constants/app';
import { selectors } from 'data/redux/hooks';
import messages from './messages';
const useOverrideTableData = () => {
const { formatMessage } = useIntl();
const hide = selectors.grades.useHasOverrideErrors();
const gradeOverrides = selectors.grades.useGradeData().gradeOverrideHistoryResults;
const tableProps = {};
if (!hide) {
tableProps.columns = [
{ Header: formatMessage(messages.dateHeader), accessor: columns.date },
{ Header: formatMessage(messages.graderHeader), accessor: columns.grader },
{ Header: formatMessage(messages.reasonHeader), accessor: columns.reason },
{ Header: formatMessage(messages.adjustedGradeHeader), accessor: columns.adjustedGrade },
];
tableProps.data = gradeOverrides;
}
return { hide, ...tableProps };
};
export default useOverrideTableData;

View File

@@ -0,0 +1,78 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { gradeOverrideHistoryColumns as columns } from 'data/constants/app';
import { selectors } from 'data/redux/hooks';
import useOverrideTableData from './hooks';
import messages from './messages';
jest.mock('data/redux/hooks', () => ({
selectors: {
grades: {
useHasOverrideErrors: jest.fn(),
useGradeData: jest.fn(),
},
},
}));
selectors.grades.useHasOverrideErrors.mockReturnValue(false);
const gradeOverrides = ['some', 'override', 'data'];
const gradeData = { gradeOverrideHistoryResults: gradeOverrides };
selectors.grades.useGradeData.mockReturnValue(gradeData);
let out;
describe('useOverrideTableData', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useOverrideTableData();
});
describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes redux hooks', () => {
expect(selectors.grades.useHasOverrideErrors).toHaveBeenCalled();
expect(selectors.grades.useGradeData).toHaveBeenCalled();
});
});
describe('output', () => {
describe('no errors', () => {
test('hide is false', () => {
expect(out.hide).toEqual(false);
});
describe('columns', () => {
test('date column', () => {
const { Header, accessor } = out.columns[0];
expect(Header).toEqual(formatMessage(messages.dateHeader));
expect(accessor).toEqual(columns.date);
});
test('grader column', () => {
const { Header, accessor } = out.columns[1];
expect(Header).toEqual(formatMessage(messages.graderHeader));
expect(accessor).toEqual(columns.grader);
});
test('reason column', () => {
const { Header, accessor } = out.columns[2];
expect(Header).toEqual(formatMessage(messages.reasonHeader));
expect(accessor).toEqual(columns.reason);
});
test('adjustedGrade column', () => {
const { Header, accessor } = out.columns[3];
expect(Header).toEqual(formatMessage(messages.adjustedGradeHeader));
expect(accessor).toEqual(columns.adjustedGrade);
});
});
test('data passed from grade data', () => {
expect(out.data).toEqual(gradeOverrides);
});
});
describe('with errors', () => {
it('returns hide true and no other fields', () => {
selectors.grades.useHasOverrideErrors.mockReturnValue(true);
out = useOverrideTableData();
expect(out).toEqual({ hide: true });
});
});
});
});

View File

@@ -1,73 +1,40 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { DataTable } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { gradeOverrideHistoryColumns as columns } from 'data/constants/app';
import selectors from 'data/selectors';
import { formatDateForDisplay } from 'utils';
import messages from './messages';
import ReasonInput from './ReasonInput';
import AdjustedGradeInput from './AdjustedGradeInput';
import useOverrideTableData from './hooks';
/**
* <OverrideTable />
* Table containing previous grade override entries, and an "edit" row
* with todays date, an AdjustedGradeInput and a ReasonInput
*/
export const OverrideTable = ({
hide,
gradeOverrides,
todaysDate,
}) => {
if (hide) {
return null;
}
export const OverrideTable = () => {
const { hide, columns, data } = useOverrideTableData();
if (hide) { return null; }
return (
<DataTable
columns={[
{ Header: <FormattedMessage {...messages.dateHeader} />, accessor: columns.date },
{ Header: <FormattedMessage {...messages.graderHeader} />, accessor: columns.grader },
{ Header: <FormattedMessage {...messages.reasonHeader} />, accessor: columns.reason },
{
Header: <FormattedMessage {...messages.adjustedGradeHeader} />,
accessor: columns.adjustedGrade,
},
]}
columns={columns}
data={[
...gradeOverrides,
...data,
{
adjustedGrade: <AdjustedGradeInput />,
date: todaysDate,
date: formatDateForDisplay(new Date()),
reason: <ReasonInput />,
},
]}
itemCount={gradeOverrides.length}
itemCount={data.length}
/>
);
};
OverrideTable.defaultProps = {
gradeOverrides: [],
};
OverrideTable.propTypes = {
// redux
gradeOverrides: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
grader: PropTypes.string,
reason: PropTypes.string,
adjustedGrade: PropTypes.number,
})),
hide: PropTypes.bool.isRequired,
todaysDate: PropTypes.string.isRequired,
};
OverrideTable.propTypes = {};
export const mapStateToProps = (state) => ({
hide: selectors.grades.hasOverrideErrors(state),
gradeOverrides: selectors.grades.gradeOverrides(state),
todaysDate: selectors.app.modalState.todaysDate(state),
});
export default connect(mapStateToProps)(OverrideTable);
export default OverrideTable;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { shallow } from 'enzyme';
import { DataTable } from '@edx/paragon';
import { formatDateForDisplay } from 'utils';
import AdjustedGradeInput from './AdjustedGradeInput';
import ReasonInput from './ReasonInput';
import useOverrideTableData from './hooks';
import OverrideTable from '.';
jest.mock('utils', () => ({
formatDateForDisplay: (date) => ({ formatted: date }),
}));
jest.mock('./hooks', () => jest.fn());
jest.mock('./AdjustedGradeInput', () => 'AdjustedGradeInput');
jest.mock('./ReasonInput', () => 'ReasonInput');
const hookProps = {
hide: false,
data: [
{ test: 'data' },
{ andOther: 'test-data' },
],
columns: 'test-columns',
};
useOverrideTableData.mockReturnValue(hookProps);
let el;
describe('OverrideTable component', () => {
beforeEach(() => {
jest
.clearAllMocks()
.useFakeTimers('modern')
.setSystemTime(new Date('2000-01-01').getTime());
el = shallow(<OverrideTable />);
});
describe('behavior', () => {
it('initializes hook data', () => {
expect(useOverrideTableData).toHaveBeenCalled();
});
});
describe('render', () => {
test('null render if hide', () => {
useOverrideTableData.mockReturnValueOnce({ ...hookProps, hide: true });
el = shallow(<OverrideTable />);
expect(el.isEmptyRender()).toEqual(true);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
const table = el.find(DataTable);
expect(table.props().columns).toEqual(hookProps.columns);
const data = [...table.props().data];
const inputRow = data.pop();
const formattedDate = formatDateForDisplay(new Date());
expect(data).toEqual(hookProps.data);
expect(inputRow).toMatchObject({
adjustedGrade: <AdjustedGradeInput />,
date: formattedDate,
reason: <ReasonInput />,
});
});
});
});

View File

@@ -1,81 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import {
OverrideTable,
mapStateToProps,
} from '.';
jest.mock('@edx/paragon', () => ({ DataTable: () => 'DataTable' }));
jest.mock('./ReasonInput', () => 'ReasonInput');
jest.mock('./AdjustedGradeInput', () => 'AdjustedGradeInput');
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
modalState: {
todaysDate: jest.fn(state => ({ todaysDate: state })),
},
},
grades: {
hasOverrideErrors: jest.fn(state => ({ hasOverrideErrors: state })),
gradeOverrides: jest.fn(state => ({ gradeOverrides: state })),
},
},
}));
describe('OverrideTable', () => {
const props = {
gradeOverrides: [
{
date: 'yesterday',
grader: 'me',
reason: 'you ate my sandwich',
adjustedGrade: 0,
},
{
date: 'today',
grader: 'me',
reason: 'you brought me a new sandwich',
adjustedGrade: 20,
},
],
hide: false,
todaysDate: 'todaaaaaay',
};
describe('Component', () => {
describe('snapshots', () => {
it('returns null if hide is true', () => {
expect(shallow(<OverrideTable {...props} hide />)).toEqual({});
});
describe('basic snapshot', () => {
test('shows a row for each entry and one editable row', () => {
expect(shallow(<OverrideTable {...props} />)).toMatchSnapshot();
});
});
});
});
describe('mapStateToProps', () => {
const testState = { I: 'wanna', be: 'the', very: 'best' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
describe('modalState', () => {
test('hide from grades.hasOverrideErrors', () => {
expect(mapped.hide).toEqual(selectors.grades.hasOverrideErrors(testState));
});
test('gradeOverrides from grades.gradeOverrides', () => {
expect(mapped.gradeOverrides).toEqual(selectors.grades.gradeOverrides(testState));
});
test('todaysData from app.modalState.todaysDate', () => {
expect(mapped.todaysDate).toEqual(selectors.app.modalState.todaysDate(testState));
});
});
});
});

View File

@@ -1,99 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ModalHeaders Component snapshots gradeOverrideHistoryError is and empty and open is true modal open and StatusAlert showing 1`] = `
exports[`ModalHeaders render snapshot 1`] = `
<div>
<HistoryHeader
id="assignment"
label={
<FormattedMessage
defaultMessage="Assignment"
description="Edit Modal Assignment header"
id="gradebook.GradesView.EditModal.headers.assignment"
/>
}
value="Qwerty"
label="Assignment"
value="test-assignment-name"
/>
<HistoryHeader
id="student"
label={
<FormattedMessage
defaultMessage="Student"
description="Edit Modal Student header"
id="gradebook.GradesView.EditModal.headers.student"
/>
}
value="Uiop"
label="Student"
value="test-user-name"
/>
<HistoryHeader
id="original-grade"
label={
<FormattedMessage
defaultMessage="Original Grade"
description="Edit Modal Original Grade header"
id="gradebook.GradesView.EditModal.headers.originalGrade"
/>
}
value={20}
label="Original Grade"
value="test-original-grade"
/>
<HistoryHeader
id="current-grade"
label={
<FormattedMessage
defaultMessage="Current Grade"
description="Edit Modal Current Grade header"
id="gradebook.GradesView.EditModal.headers.currentGrade"
/>
}
value={2}
/>
</div>
`;
exports[`ModalHeaders Component snapshots gradeOverrideHistoryError is empty and open is false modal closed and StatusAlert closed 1`] = `
<div>
<HistoryHeader
id="assignment"
label={
<FormattedMessage
defaultMessage="Assignment"
description="Edit Modal Assignment header"
id="gradebook.GradesView.EditModal.headers.assignment"
/>
}
value="Qwerty"
/>
<HistoryHeader
id="student"
label={
<FormattedMessage
defaultMessage="Student"
description="Edit Modal Student header"
id="gradebook.GradesView.EditModal.headers.student"
/>
}
value="Uiop"
/>
<HistoryHeader
id="original-grade"
label={
<FormattedMessage
defaultMessage="Original Grade"
description="Edit Modal Original Grade header"
id="gradebook.GradesView.EditModal.headers.originalGrade"
/>
}
value={20}
/>
<HistoryHeader
id="current-grade"
label={
<FormattedMessage
defaultMessage="Current Grade"
description="Edit Modal Current Grade header"
id="gradebook.GradesView.EditModal.headers.currentGrade"
/>
}
value={2}
label="Current Grade"
value="test-current-grade"
/>
</div>
`;

View File

@@ -0,0 +1,91 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditModal component render with error snapshot 1`] = `
<ModalDialog
hasCloseButton={true}
isFullscreenOnMobile={true}
isOpen="test-is-open"
onClose={[MockFunction hooks.onClose]}
size="xl"
title="Edit Grades"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
dismissible={false}
show={true}
variant="danger"
>
test-error
</Alert>
<OverrideTable />
<div>
Showing most recent actions (max 5). To see more, please contact support
</div>
<div>
Note: Once you save, your changes will be visible to students.
</div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
Cancel
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction hooks.handleAdjustedGradeClick]}
variant="primary"
>
Save Grades
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
`;
exports[`EditModal component render without error snapshot 1`] = `
<ModalDialog
hasCloseButton={true}
isFullscreenOnMobile={true}
isOpen="test-is-open"
onClose={[MockFunction hooks.onClose]}
size="xl"
title="Edit Grades"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
dismissible={false}
show={false}
variant="danger"
/>
<OverrideTable />
<div>
Showing most recent actions (max 5). To see more, please contact support
</div>
<div>
Note: Once you save, your changes will be visible to students.
</div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
Cancel
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction hooks.handleAdjustedGradeClick]}
variant="primary"
>
Save Grades
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
`;

View File

@@ -1,125 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditModal Component snapshots gradeOverrideHistoryError is and empty and open is true modal open and StatusAlert showing 1`] = `
<ModalDialog
hasCloseButton={true}
isFullscreenOnMobile={true}
isOpen={true}
onClose={[MockFunction this.closeAssignmentModal]}
size="xl"
title="Edit Grades"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
dismissible={false}
show={true}
variant="danger"
>
Weve been trying to contact you regarding...
</Alert>
<OverrideTable />
<div>
<FormattedMessage
defaultMessage="Showing most recent actions (max 5). To see more, please contact support"
description="Edit Modal visibility hint message"
id="gradebook.GradesView.EditModal.contactSupport"
/>
</div>
<div>
<FormattedMessage
defaultMessage="Note: Once you save, your changes will be visible to students."
description="Edit Modal saved changes effect hint"
id="gradebook.GradesView.EditModal.saveVisibility"
/>
</div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction this.handleAdjustedGradeClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Save Grades"
description="Edit Modal Save button label"
id="gradebook.GradesView.EditModal.saveGrade"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
`;
exports[`EditModal Component snapshots gradeOverrideHistoryError is empty and open is false modal closed and StatusAlert closed 1`] = `
<ModalDialog
hasCloseButton={true}
isFullscreenOnMobile={true}
isOpen={false}
onClose={[MockFunction this.closeAssignmentModal]}
size="xl"
title="Edit Grades"
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
dismissible={false}
show={false}
variant="danger"
>
</Alert>
<OverrideTable />
<div>
<FormattedMessage
defaultMessage="Showing most recent actions (max 5). To see more, please contact support"
description="Edit Modal visibility hint message"
id="gradebook.GradesView.EditModal.contactSupport"
/>
</div>
<div>
<FormattedMessage
defaultMessage="Note: Once you save, your changes will be visible to students."
description="Edit Modal saved changes effect hint"
id="gradebook.GradesView.EditModal.saveVisibility"
/>
</div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton
variant="tertiary"
>
<FormattedMessage
defaultMessage="Cancel"
description="Edit Modal close button text"
id="gradebook.GradesView.EditModal.closeText"
/>
</ModalDialog.CloseButton>
<Button
onClick={[MockFunction this.handleAdjustedGradeClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Save Grades"
description="Edit Modal Save button label"
id="gradebook.GradesView.EditModal.saveGrade"
/>
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
`;

View File

@@ -0,0 +1,29 @@
import { selectors, actions, thunkActions } from 'data/redux/hooks';
export const useEditModalData = () => {
const error = selectors.grades.useGradeData().gradeOverrideHistoryError;
const isOpen = selectors.app.useModalData().open;
const closeModal = actions.app.useCloseModal();
const doneViewingAssignment = actions.grades.useDoneViewingAssignment();
const updateGrades = thunkActions.grades.useUpdateGrades();
const onClose = () => {
doneViewingAssignment();
closeModal();
};
const handleAdjustedGradeClick = () => {
updateGrades();
doneViewingAssignment();
closeModal();
};
return {
onClose,
error,
handleAdjustedGradeClick,
isOpen,
};
};
export default useEditModalData;

View File

@@ -0,0 +1,68 @@
import { selectors, actions, thunkActions } from 'data/redux/hooks';
import useEditModalData from './hooks';
jest.mock('data/redux/hooks', () => ({
actions: {
app: { useCloseModal: jest.fn() },
grades: { useDoneViewingAssignment: jest.fn() },
},
selectors: {
app: { useModalData: jest.fn() },
grades: { useGradeData: jest.fn() },
},
thunkActions: {
grades: { useUpdateGrades: jest.fn() },
},
}));
const closeModal = jest.fn();
const doneViewingAssignment = jest.fn();
const updateGrades = jest.fn();
actions.app.useCloseModal.mockReturnValue(closeModal);
actions.grades.useDoneViewingAssignment.mockReturnValue(doneViewingAssignment);
thunkActions.grades.useUpdateGrades.mockReturnValue(updateGrades);
const gradeData = { gradeOverridHistoryError: 'test-error' };
const modalData = { open: true };
selectors.app.useModalData.mockReturnValue(modalData);
selectors.grades.useGradeData.mockReturnValue(gradeData);
let out;
describe('useEditModalData', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useEditModalData();
});
describe('behavior', () => {
it('initializes redux hooks', () => {
expect(selectors.grades.useGradeData).toHaveBeenCalled();
expect(selectors.app.useModalData).toHaveBeenCalled();
expect(actions.app.useCloseModal).toHaveBeenCalled();
expect(actions.grades.useDoneViewingAssignment).toHaveBeenCalled();
expect(thunkActions.grades.useUpdateGrades).toHaveBeenCalled();
});
});
describe('output', () => {
it('forwards error from gradeData.gradeOverrideHistoryError', () => {
expect(out.error).toEqual(gradeData.gradeOverrideHistoryError);
});
it('forwards isOpen from modalData.open', () => {
expect(out.isOpen).toEqual(modalData.open);
});
describe('handleAdjustedGradeClick', () => {
it('updates grades, calls doneViewingAssignment and closeModal', () => {
out.handleAdjustedGradeClick();
expect(updateGrades).toHaveBeenCalled();
expect(doneViewingAssignment).toHaveBeenCalled();
expect(closeModal).toHaveBeenCalled();
});
});
test('onClose calls doneViewingAssignment and closeModal', () => {
out.onClose();
expect(doneViewingAssignment).toHaveBeenCalled();
expect(closeModal).toHaveBeenCalled();
expect(updateGrades).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,7 +1,4 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
@@ -9,15 +6,12 @@ import {
ModalDialog,
ActionRow,
} from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import messages from './messages';
import OverrideTable from './OverrideTable';
import ModalHeaders from './ModalHeaders';
import useEditModalData from './hooks';
import messages from './messages';
/**
* <EditModal />
@@ -28,87 +22,48 @@ import ModalHeaders from './ModalHeaders';
* adjusting the grade.
* (also provides a close button that clears the modal state)
*/
export class EditModal extends React.Component {
constructor(props) {
super(props);
this.closeAssignmentModal = this.closeAssignmentModal.bind(this);
this.handleAdjustedGradeClick = this.handleAdjustedGradeClick.bind(this);
}
export const EditModal = () => {
const { formatMessage } = useIntl();
const {
onClose,
error,
handleAdjustedGradeClick,
isOpen,
} = useEditModalData();
closeAssignmentModal() {
this.props.doneViewingAssignment();
this.props.closeModal();
}
return (
<ModalDialog
title={formatMessage(messages.title)}
isOpen={isOpen}
onClose={onClose}
size="xl"
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert variant="danger" show={!!error} dismissible={false}>
{error}
</Alert>
<OverrideTable />
<div>{formatMessage(messages.visibility)}</div>
<div>{formatMessage(messages.saveVisibility)}</div>
</div>
</ModalDialog.Body>
handleAdjustedGradeClick() {
this.props.updateGrades();
this.closeAssignmentModal();
}
render() {
return (
<ModalDialog
title={this.props.intl.formatMessage(messages.title)}
isOpen={this.props.open}
onClose={this.closeAssignmentModal}
size="xl"
hasCloseButton
isFullscreenOnMobile
>
<ModalDialog.Body>
<div>
<ModalHeaders />
<Alert
variant="danger"
show={!!this.props.gradeOverrideHistoryError}
dismissible={false}
>
{this.props.gradeOverrideHistoryError}
</Alert>
<OverrideTable />
<div><FormattedMessage {...messages.visibility} /></div>
<div><FormattedMessage {...messages.saveVisibility} /></div>
</div>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
<FormattedMessage {...messages.closeText} />
</ModalDialog.CloseButton>
<Button variant="primary" onClick={this.handleAdjustedGradeClick}>
<FormattedMessage {...messages.saveGrade} />
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
}
}
EditModal.defaultProps = {
gradeOverrideHistoryError: '',
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{formatMessage(messages.closeText)}
</ModalDialog.CloseButton>
<Button variant="primary" onClick={handleAdjustedGradeClick}>
{formatMessage(messages.saveGrade)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};
EditModal.propTypes = {
// redux
gradeOverrideHistoryError: PropTypes.string,
open: PropTypes.bool.isRequired,
closeModal: PropTypes.func.isRequired,
doneViewingAssignment: PropTypes.func.isRequired,
updateGrades: PropTypes.func.isRequired,
// injected
intl: intlShape.isRequired,
};
export const mapStateToProps = (state) => ({
gradeOverrideHistoryError: selectors.grades.gradeOverrideHistoryError(state),
open: selectors.app.modalState.open(state),
});
export const mapDispatchToProps = {
closeModal: actions.app.closeModal,
doneViewingAssignment: actions.grades.doneViewingAssignment,
updateGrades: thunkActions.grades.updateGrades,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(EditModal));
export default EditModal;

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { shallow } from 'enzyme';
import {
ActionRow,
ModalDialog,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import ModalHeaders from './ModalHeaders';
import OverrideTable from './OverrideTable';
import useEditModalData from './hooks';
import EditModal from '.';
import messages from './messages';
jest.mock('./hooks', () => jest.fn());
jest.mock('./ModalHeaders', () => 'ModalHeaders');
jest.mock('./OverrideTable', () => 'OverrideTable');
const hookProps = {
onClose: jest.fn().mockName('hooks.onClose'),
error: 'test-error',
handleAdjustedGradeClick: jest.fn().mockName('hooks.handleAdjustedGradeClick'),
isOpen: 'test-is-open',
};
useEditModalData.mockReturnValue(hookProps);
let el;
describe('EditModal component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<EditModal />);
});
describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes component hooks', () => {
expect(useEditModalData).toHaveBeenCalled();
});
});
describe('render', () => {
test('modal props', () => {
const modalProps = el.find(ModalDialog).props();
expect(modalProps.title).toEqual(formatMessage(messages.title));
expect(modalProps.isOpen).toEqual(hookProps.isOpen);
expect(modalProps.onClose).toEqual(hookProps.onClose);
});
const loadBody = () => {
const body = el.find(ModalDialog).children().at(0);
const children = body.find('div').children();
return { body, children };
};
const testBody = () => {
test('type', () => {
const { body } = loadBody();
expect(body.type()).toEqual('ModalDialog.Body');
});
test('headers row', () => {
const { children } = loadBody();
expect(children.at(0)).toMatchObject(shallow(<ModalHeaders />));
});
test('table row', () => {
const { children } = loadBody();
expect(children.at(2)).toMatchObject(shallow(<OverrideTable />));
});
test('messages', () => {
const { children } = loadBody();
expect(
children.at(3).contains(formatMessage(messages.visibility)),
).toEqual(true);
expect(
children.at(4).contains(formatMessage(messages.saveVisibility)),
).toEqual(true);
});
};
const testFooter = () => {
let footer;
beforeEach(() => {
footer = el.find(ModalDialog).children().at(1);
});
test('type', () => {
expect(footer.type()).toEqual('ModalDialog.Footer');
});
test('contains action row', () => {
expect(footer.children().at(0).type()).toEqual('ActionRow');
});
test('close button', () => {
const button = footer.find(ActionRow).children().at(0);
expect(button.contains(formatMessage(messages.closeText))).toEqual(true);
expect(button.type()).toEqual('ModalDialog.CloseButton');
});
test('adjusted grade button', () => {
const button = footer.find(ActionRow).children().at(1);
expect(button.contains(formatMessage(messages.saveGrade))).toEqual(true);
expect(button.type()).toEqual('Button');
expect(button.props().onClick).toEqual(hookProps.handleAdjustedGradeClick);
});
};
describe('without error', () => {
beforeEach(() => {
useEditModalData.mockReturnValueOnce({ ...hookProps, error: undefined });
el = shallow(<EditModal />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
testBody();
testFooter();
test('alert row', () => {
const alert = loadBody().children.at(1);
expect(alert.type()).toEqual('Alert');
expect(alert.props().show).toEqual(false);
});
});
describe('with error', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
testBody();
test('alert row', () => {
const alert = loadBody().children.at(1);
expect(alert.type()).toEqual('Alert');
expect(alert.props().show).toEqual(true);
expect(alert.contains(hookProps.error)).toEqual(true);
});
testFooter();
});
});
});

View File

@@ -1,130 +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 {
EditModal,
mapDispatchToProps,
mapStateToProps,
}
from '.';
jest.mock('./OverrideTable', () => 'OverrideTable');
jest.mock('./ModalHeaders', () => 'ModalHeaders');
jest.mock('data/actions', () => ({
__esModule: true,
default: {
app: { closeModal: jest.fn() },
grades: { doneViewingAssignment: jest.fn() },
},
}));
jest.mock('data/thunkActions', () => ({
__esModule: true,
default: {
grades: { updateGrades: jest.fn() },
},
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
modalState: {
open: jest.fn(state => ({ isModalOpen: state })),
},
},
grades: {
gradeOverrideHistoryError: jest.fn(state => ({ overrideHistoryError: state })),
},
},
}));
describe('EditModal', () => {
let props;
beforeEach(() => {
props = {
gradeOverrideHistoryError: 'Weve been trying to contact you regarding...',
open: true,
closeModal: jest.fn(),
doneViewingAssignment: jest.fn(),
updateGrades: jest.fn(),
intl: { formatMessage: (msg) => msg.defaultMessage },
};
});
describe('Component', () => {
describe('behavior', () => {
let el;
beforeEach(() => {
el = shallow(<EditModal {...props} />);
});
describe('closeAssignmentModal', () => {
it('calls props.doneViewingAssignment and props.closeModal', () => {
el.instance().closeAssignmentModal();
expect(props.doneViewingAssignment).toHaveBeenCalledWith();
expect(props.closeModal).toHaveBeenCalledWith();
});
});
describe('handleAdjustedGradeClick', () => {
it('calls props.updateGardes and this.closeAssignmentModal', () => {
el.instance().closeAssignmentModal = jest.fn();
el.instance().handleAdjustedGradeClick();
expect(props.updateGrades).toHaveBeenCalledWith();
expect(el.instance().closeAssignmentModal).toHaveBeenCalledWith();
});
});
});
describe('snapshots', () => {
let el;
beforeEach(() => {
el = shallow(<EditModal {...props} />);
el.instance().closeAssignmentModal = jest.fn().mockName('this.closeAssignmentModal');
el.instance().handleAdjustedGradeClick = jest.fn().mockName(
'this.handleAdjustedGradeClick',
);
});
describe('gradeOverrideHistoryError is and empty and open is true', () => {
test('modal open and StatusAlert showing', () => {
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('gradeOverrideHistoryError is empty and open is false', () => {
test('modal closed and StatusAlert closed', () => {
el.setProps({ open: false, gradeOverrideHistoryError: '' });
expect(el.instance().render()).toMatchSnapshot();
});
});
});
});
describe('mapStateToProps', () => {
const testState = { martha: 'why did you say that name?!' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('gradeOverrideHistoryError from grades.gradeOverrideHistoryError', () => {
expect(
mapped.gradeOverrideHistoryError,
).toEqual(selectors.grades.gradeOverrideHistoryError(testState));
});
test('open from app.modalState.open', () => {
expect(mapped.open).toEqual(selectors.app.modalState.open(testState));
});
});
describe('mapDispatchToProps', () => {
test('closeModal from actions.app.closeModal', () => {
expect(mapDispatchToProps.closeModal).toEqual(actions.app.closeModal);
});
test('doneViewingAssignemtn from actions.grades.doneViewingAssignment', () => {
expect(
mapDispatchToProps.doneViewingAssignment,
).toEqual(actions.grades.doneViewingAssignment);
});
test('updateGrades from thunkActions.grades.updateGrades', () => {
expect(mapDispatchToProps.updateGrades).toEqual(thunkActions.grades.updateGrades);
});
});
});

View File

@@ -1,11 +1,10 @@
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 { useIntl } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import { selectors } from 'data/redux/hooks';
/**
* FilterBadge
@@ -16,56 +15,43 @@ import selectors from 'data/selectors';
* @param {string} filterName - api filter name (for redux connector)
*/
export const FilterBadge = ({
config: {
filterName,
handleClose,
}) => {
const { formatMessage } = useIntl();
const {
displayName,
isDefault,
hideValue,
value,
connectedFilters,
},
handleClose,
}) => !isDefault && (
<div>
<span className="badge badge-info">
<span>
<FormattedMessage {...displayName} />
} = selectors.root.useFilterBadgeConfig(filterName);
if (isDefault) {
return null;
}
return (
<div>
<span className="badge badge-info">
<span>{formatMessage(displayName)}</span>
<span>
{!hideValue ? `: ${value}` : ''}
</span>
<Button
className="btn-info"
aria-label="close"
onClick={handleClose(connectedFilters)}
>
<span aria-hidden="true">&times;</span>
</Button>
</span>
<span>
{!hideValue ? `: ${value}` : ''}
</span>
<Button
className="btn-info"
aria-label="close"
onClick={handleClose(connectedFilters)}
>
<span aria-hidden="true">&times;</span>
</Button>
</span>
<br />
</div>
);
<br />
</div>
);
};
FilterBadge.propTypes = {
handleClose: PropTypes.func.isRequired,
// eslint-disable-next-line
filterName: PropTypes.string.isRequired,
// redux
config: PropTypes.shape({
connectedFilters: PropTypes.arrayOf(PropTypes.string),
displayName: PropTypes.shape({
defaultMessage: PropTypes.string,
}).isRequired,
isDefault: PropTypes.bool.isRequired,
hideValue: PropTypes.bool,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]),
}).isRequired,
};
export const mapStateToProps = (state, ownProps) => ({
config: selectors.root.filterBadgeConfig(state, ownProps.filterName),
});
export default connect(mapStateToProps)(FilterBadge);
export default FilterBadge;

View File

@@ -1,107 +1,95 @@
import React from 'react';
import { shallow } from 'enzyme';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { Button } from '@edx/paragon';
import selectors from 'data/selectors';
import { FilterBadge, mapStateToProps } from './FilterBadge';
import { selectors } from 'data/redux/hooks';
import FilterBadge from './FilterBadge';
jest.mock('@edx/paragon', () => ({
Button: () => 'Button',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
jest.mock('data/redux/hooks', () => ({
selectors: {
root: {
filterBadgeConfig: jest.fn(state => ({ filterBadgeConfig: state })),
useFilterBadgeConfig: jest.fn(),
},
},
}));
const handleClose = jest.fn(filters => ({ handleClose: filters }));
const filterName = 'test-filter-name';
const hookProps = {
displayName: {
defaultMessage: 'a common name',
},
isDefault: false,
hideValue: false,
value: 'a common value',
connectedFilters: ['some', 'filters'],
};
selectors.root.useFilterBadgeConfig.mockReturnValue(hookProps);
let el;
describe('FilterBadge', () => {
describe('component', () => {
const config = {
displayName: {
defaultMessage: 'a common name',
},
isDefault: false,
hideValue: false,
value: 'a common value',
connectedFilters: ['some', 'filters'],
};
const filterName = 'api.filter.name';
let handleClose;
let el;
let props;
beforeEach(() => {
handleClose = (filters) => ({ handleClose: filters });
props = { filterName, handleClose, config };
beforeEach(() => {
el = shallow(<FilterBadge {...{ handleClose, filterName }} />);
});
describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
describe('with default value', () => {
beforeEach(() => {
el = shallow(
<FilterBadge {...props} config={{ ...config, isDefault: true }} />,
);
});
test('snapshot - empty', () => {
expect(el).toMatchSnapshot();
});
it('does not display', () => {
expect(el).toEqual({});
});
});
describe('with non-default value (active)', () => {
describe('if hideValue is true', () => {
beforeEach(() => {
el = shallow(
<FilterBadge {...props} config={{ ...config, hideValue: true }} />,
);
});
test('snapshot - shows displayName but not value in span', () => {
expect(el).toMatchSnapshot();
});
it('shows displayName but not value in span', () => {
expect(el.find('span.badge').childAt(0).getElement()).toEqual(
<span>
<FormattedMessage {...config.displayName} />
</span>,
);
});
it('calls a handleClose event for connected filters on button click', () => {
expect(el.find(Button).props().onClick).toEqual(handleClose(config.connectedFilters));
});
});
describe('if hideValue is false (default)', () => {
beforeEach(() => {
el = shallow(<FilterBadge {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
it('shows displayName and value in span', () => {
expect(el.find('span.badge').childAt(0).getElement()).toEqual(
<span>
<FormattedMessage {...config.displayName} />
</span>,
);
expect(el.find('span.badge').childAt(1).getElement()).toEqual(
<span>
{`: ${config.value}`}
</span>,
);
});
it('calls a handleClose event for connected filters on button click', () => {
expect(el.find(Button).props().onClick).toEqual(handleClose(config.connectedFilters));
});
});
it('initializes redux hooks', () => {
expect(selectors.root.useFilterBadgeConfig).toHaveBeenCalledWith(filterName);
});
});
describe('mapStateToProps', () => {
const testState = { some: 'kind', of: 'alien' };
const filterName = 'Lilu Dallas Multipass';
test('config loads config from root.filterBadgeConfig with ownProps.filterName', () => {
const { config } = mapStateToProps(testState, { filterName });
expect(config).toEqual(selectors.root.filterBadgeConfig(testState, filterName));
describe('render', () => {
const testDisplayName = () => {
test('formatted display name appears on badge', () => {
expect(el.contains(formatMessage(hookProps.displayName))).toEqual(true);
});
};
const testCloseButton = () => {
test('close button forwards close method', () => {
expect(el.find(Button).props().onClick).toEqual(handleClose(hookProps.connectedFilters));
});
};
test('empty render if isDefault', () => {
selectors.root.useFilterBadgeConfig.mockReturnValueOnce({
...hookProps,
isDefault: true,
});
el = shallow(<FilterBadge {...{ handleClose, filterName }} />);
expect(el.isEmptyRender()).toEqual(true);
});
describe('hide Value', () => {
beforeEach(() => {
selectors.root.useFilterBadgeConfig.mockReturnValueOnce({
...hookProps,
hideValue: true,
});
el = shallow(<FilterBadge {...{ handleClose, filterName }} />);
});
testDisplayName();
testCloseButton();
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('value is note present in the badge', () => {
expect(el.contains(hookProps.value)).toEqual(false);
});
});
describe('do not hide value', () => {
testDisplayName();
testCloseButton();
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('value is note present in the badge', () => {
expect(el.text().includes(hookProps.value)).toEqual(true);
});
});
});
});

View File

@@ -1,16 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilterBadge component with default value snapshot - empty 1`] = `""`;
exports[`FilterBadge component with non-default value (active) if hideValue is false (default) snapshot 1`] = `
exports[`FilterBadge render do not hide value snapshot 1`] = `
<div>
<span
className="badge badge-info"
>
<span>
<FormattedMessage
defaultMessage="a common name"
/>
a common name
</span>
<span>
: a common value
@@ -38,15 +34,13 @@ exports[`FilterBadge component with non-default value (active) if hideValue is f
</div>
`;
exports[`FilterBadge component with non-default value (active) if hideValue is true snapshot - shows displayName but not value in span 1`] = `
exports[`FilterBadge render hide Value snapshot 1`] = `
<div>
<span
className="badge badge-info"
>
<span>
<FormattedMessage
defaultMessage="a common name"
/>
a common name
</span>
<span />
<Button

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilterMenuToggle component render snapshot 1`] = `
<Button
className="btn-primary align-self-start"
id="edit-filters-btn"
onClick={[MockFunction hooks.toggleFilterMenu]}
>
<Icon
src="FilterAlt"
/>
Edit Filters
</Button>
`;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { Button, Icon } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FilterAlt } from '@edx/paragon/icons';
import { thunkActions } from 'data/redux/hooks';
import messages from './messages';
/**
* Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
* as well as the search box for searching by username/email.
*/
export const FilterMenuToggle = () => {
const toggleFilterMenu = thunkActions.app.filterMenu.useToggleMenu();
const { formatMessage } = useIntl();
return (
<Button
id="edit-filters-btn"
className="btn-primary align-self-start"
onClick={toggleFilterMenu}
>
<Icon src={FilterAlt} /> {formatMessage(messages.editFilters)}
</Button>
);
};
FilterMenuToggle.propTypes = {};
export default FilterMenuToggle;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { thunkActions } from 'data/redux/hooks';
import FilterMenuToggle from '.';
import messages from './messages';
jest.mock('data/redux/hooks', () => ({
thunkActions: {
app: {
filterMenu: {
useToggleMenu: jest.fn(),
},
},
},
}));
const toggleFilterMenu = jest.fn().mockName('hooks.toggleFilterMenu');
thunkActions.app.filterMenu.useToggleMenu.mockReturnValue(toggleFilterMenu);
let el;
describe('FilterMenuToggle component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<FilterMenuToggle />);
});
describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes redux hooks', () => {
expect(thunkActions.app.filterMenu.useToggleMenu).toHaveBeenCalled();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
expect(el.type()).toEqual('Button');
expect(el.props().onClick).toEqual(toggleFilterMenu);
expect(el.text().includes(formatMessage(messages.editFilters)));
});
});
});

View File

@@ -1,44 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
/**
* <FilteredUsersLabel />
* Simple label component displaying the filtered and total users shown
*/
export const FilteredUsersLabel = ({
filteredUsersCount,
totalUsersCount,
}) => {
if (!totalUsersCount) {
return null;
}
const bold = (val) => (<span className="font-weight-bold">{val}</span>);
return (
<FormattedMessage
id="gradebook.GradesTab.usersVisibilityLabel'"
defaultMessage="Showing {filteredUsers} of {totalUsers} total learners"
description="Users visibility label"
values={{
filteredUsers: bold(filteredUsersCount),
totalUsers: bold(totalUsersCount),
}}
/>
);
};
FilteredUsersLabel.propTypes = {
filteredUsersCount: PropTypes.number.isRequired,
totalUsersCount: PropTypes.number.isRequired,
};
export const mapStateToProps = (state) => ({
totalUsersCount: selectors.grades.totalUsersCount(state),
filteredUsersCount: selectors.grades.filteredUsersCount(state),
});
export default connect(mapStateToProps)(FilteredUsersLabel);

View File

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

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FilteredUsersLabel component render snapshot 1`] = `
<format-message-function
message={
Object {
"defaultMessage": "Showing {filteredUsers} of {totalUsers} total learners",
"description": "Users visibility label",
"id": "gradebook.GradesTab.usersVisibilityLabel",
}
}
values={
Object {
"filteredUsers": <BoldText
text={100}
/>,
"totalUsers": <BoldText
text={123}
/>,
}
}
/>
`;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { selectors } from 'data/redux/hooks';
import messages from './messages';
export const BoldText = ({ text }) => (
<span className="font-weight-bold">{text}</span>
);
BoldText.propTypes = {
text: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
};
/**
* <FilteredUsersLabel />
* Simple label component displaying the filtered and total users shown
*/
export const FilteredUsersLabel = () => {
const { filteredUsersCount, totalUsersCount } = selectors.grades.useUserCounts();
const { formatMessage } = useIntl();
if (!totalUsersCount) {
return null;
}
return formatMessage(
messages.visibilityLabel,
{
filteredUsers: <BoldText text={filteredUsersCount} />,
totalUsers: <BoldText text={totalUsersCount} />,
},
);
};
FilteredUsersLabel.propTypes = {};
export default FilteredUsersLabel;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { selectors } from 'data/redux/hooks';
import FilteredUsersLabel, { BoldText } from '.';
import messages from './messages';
jest.mock('data/redux/hooks', () => ({
selectors: {
grades: {
useUserCounts: jest.fn(),
},
},
}));
const userCounts = {
filteredUsersCount: 100,
totalUsersCount: 123,
};
selectors.grades.useUserCounts.mockReturnValue(userCounts);
let el;
describe('FilteredUsersLabel component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<FilteredUsersLabel />);
});
describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes redux hooks', () => {
expect(selectors.grades.useUserCounts).toHaveBeenCalled();
});
});
describe('render', () => {
test('null render if totalUsersCount is 0', () => {
selectors.grades.useUserCounts.mockReturnValueOnce({
...userCounts,
totalUsersCount: 0,
});
expect(shallow(<FilteredUsersLabel />).isEmptyRender()).toEqual(true);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
expect(el).toMatchObject(shallow(formatMessage(messages.visibilityLabel, {
filteredUsers: <BoldText text={userCounts.filteredUsersCount} />,
totalUsers: <BoldText text={userCounts.totalUsersCount} />,
})));
});
});
});

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
visibilityLabel: {
id: 'gradebook.GradesTab.usersVisibilityLabel',
defaultMessage: 'Showing {filteredUsers} of {totalUsers} total learners',
description: 'Users visibility label',
},
});
export default messages;

View File

@@ -1,14 +1,31 @@
/* eslint-disable react/sort-comp, react/button-has-type */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button } from '@edx/paragon';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import { selectors, thunkActions } from 'data/redux/hooks';
import transforms from 'data/redux/transforms';
import * as module from './GradeButton';
const { subsectionGrade } = selectors.grades;
export const useGradeButtonData = ({ entry, subsection }) => {
const areGradesFrozen = selectors.assignmentTypes.useAreGradesFrozen();
const { gradeFormat } = selectors.grades.useGradeData();
const setModalState = thunkActions.app.useSetModalStateFromTable();
const label = transforms.grades.subsectionGrade({ gradeFormat, subsection });
const onClick = () => {
setModalState({
userEntry: entry,
subsection,
});
};
return {
areGradesFrozen,
label,
onClick,
};
};
/**
* GradeButton
@@ -18,38 +35,24 @@ const { subsectionGrade } = selectors.grades;
* @param {object} entry - user's grade entry
* @param {object} subsection - user's subsection grade from subsection_breakdown
*/
export class GradeButton extends React.Component {
constructor(props) {
super(props);
this.onClick = this.onClick.bind(this);
}
get label() {
return subsectionGrade[this.props.format](this.props.subsection);
}
onClick() {
this.props.setModalState({
userEntry: this.props.entry,
subsection: this.props.subsection,
});
}
render() {
return this.props.areGradesFrozen
? this.label
: (
<Button
variant="link"
className="btn-header grade-button"
onClick={this.onClick}
>
{this.label}
</Button>
);
}
}
export const GradeButton = ({ entry, subsection }) => {
const {
areGradesFrozen,
label,
onClick,
} = module.useGradeButtonData({ entry, subsection });
return areGradesFrozen
? label
: (
<Button
variant="link"
className="btn-header grade-button"
onClick={onClick}
>
{label}
</Button>
);
};
GradeButton.propTypes = {
subsection: PropTypes.shape({
attempted: PropTypes.bool,
@@ -62,19 +65,6 @@ GradeButton.propTypes = {
user_id: PropTypes.number,
username: PropTypes.string,
}).isRequired,
// redux
areGradesFrozen: PropTypes.bool.isRequired,
format: PropTypes.string.isRequired,
setModalState: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
areGradesFrozen: selectors.assignmentTypes.areGradesFrozen(state),
format: selectors.grades.gradeFormat(state),
});
export const mapDispatchToProps = {
setModalState: thunkActions.app.setModalStateFromTable,
};
export default connect(mapStateToProps, mapDispatchToProps)(GradeButton);
export default GradeButton;

View File

@@ -1,118 +1,121 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Button } from '@edx/paragon';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import { selectors, thunkActions } from 'data/redux/hooks';
import transforms from 'data/redux/transforms';
import { keyStore } from 'utils';
import {
GradeButton,
mapStateToProps,
mapDispatchToProps,
} from './GradeButton';
import * as module from './GradeButton';
jest.mock('@edx/paragon', () => ({
Button: () => 'Button',
}));
const { useGradeButtonData, default: GradeButton } = module;
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
assignmentTypes: {
areGradesFrozen: jest.fn(state => ({ areGradesFrozen: state })),
},
jest.mock('data/redux/hooks', () => ({
selectors: {
assignmentTypes: { useAreGradesFrozen: jest.fn() },
grades: {
subsectionGrade: {
percent: jest.fn(subsection => ({ percent: subsection })),
},
gradeFormat: jest.fn(state => ({ gradeFormat: state })),
useGradeData: jest.fn(),
},
},
thunkActions: {
app: { useSetModalStateFromTable: jest.fn() },
},
}));
jest.mock('data/thunkActions', () => ({
app: {
setModalStateFromTable: jest.fn(),
jest.mock('data/redux/transforms', () => ({
grades: {
subsectionGrade: jest.fn(),
},
}));
const props = {
subsection: {
attempted: false,
percent: 23,
score_possible: 32,
subsection_name: 'the things we do',
module_id: 'in potions',
},
entry: {
user_id: 2,
username: 'Jessie',
},
};
const gradeFormat = 'percent';
const setModalState = jest.fn();
const subsectionGrade = 'test-subsection-grade';
selectors.assignmentTypes.useAreGradesFrozen.mockReturnValue(false);
selectors.grades.useGradeData.mockReturnValue({ gradeFormat });
thunkActions.app.useSetModalStateFromTable.mockReturnValue(setModalState);
transforms.grades.subsectionGrade.mockReturnValue(subsectionGrade);
let el;
let out;
describe('GradeButton', () => {
let el;
let props = {
subsection: {
attempted: false,
percent: 23,
score_possible: 32,
subsection_name: 'the things we do',
module_id: 'in potions',
},
entry: {
user_id: 2,
username: 'Jessie',
},
areGradesFrozen: false,
format: 'percent',
};
beforeEach(() => {
props = { ...props, setModalState: jest.fn() };
jest.clearAllMocks();
});
describe('component', () => {
describe('snapshots', () => {
test('grades are frozen', () => {
el = shallow(<GradeButton {...{ ...props, areGradesFrozen: true }} />);
const label = 'why you gotta label people?';
jest.spyOn(el.instance(), 'label', 'get').mockReturnValue(label);
el.instance().onClick = jest.fn().mockName('this.onClick');
expect(el.instance().render()).toMatchSnapshot();
expect(el.instance().render()).toEqual(label);
});
test('grades are not frozen', () => {
el = shallow(<GradeButton {...props} />);
const label = 'why you gotta label people?';
jest.spyOn(el.instance(), 'label', 'get').mockReturnValue(label);
el.instance().onClick = jest.fn().mockName('this.onClick');
expect(el.instance().render()).toMatchSnapshot();
expect(el.instance().render().props.children).toEqual(label);
expect(el.render().is(Button)).toEqual(true);
describe('useGradeButton hook', () => {
beforeEach(() => {
out = useGradeButtonData(props);
});
describe('behavior', () => {
it('initializes redux hooks', () => {
expect(selectors.assignmentTypes.useAreGradesFrozen).toHaveBeenCalled();
expect(selectors.grades.useGradeData).toHaveBeenCalled();
expect(transforms.grades.subsectionGrade).toHaveBeenCalledWith({
gradeFormat,
subsection: props.subsection,
});
expect(thunkActions.app.useSetModalStateFromTable).toHaveBeenCalled();
});
});
describe('label', () => {
it('calls the appropriate formatter with the subsection prop', () => {
el = shallow(<GradeButton {...props} />);
expect(
el.instance().label,
).toEqual(selectors.grades.subsectionGrade[props.format](props.subsection));
describe('output', () => {
test('forwards areGradesFrozen from redux hook', () => {
expect(out.areGradesFrozen).toEqual(false);
});
});
describe('onClick', () => {
it('calls props.setModalState with userEntry and subsection', () => {
el = shallow(<GradeButton {...props} />);
el.instance().onClick();
expect(props.setModalState).toHaveBeenCalledWith({
test('label passed from subsection grade redux hook', () => {
expect(out.label).toEqual(subsectionGrade);
});
test('onClick sets modal state with user entry and subsection', () => {
out.onClick();
expect(setModalState).toHaveBeenCalledWith({
userEntry: props.entry,
subsection: props.subsection,
});
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = { teams: { rocket: ['jesse', 'james'] } };
describe('component', () => {
let hookSpy;
const moduleKeys = keyStore(module);
const hookProps = {
areGradesFrozen: false,
label: 'test-label',
onClick: jest.fn().mockName('hooks.onClick'),
};
beforeEach(() => {
mapped = mapStateToProps(testState);
hookSpy = jest.spyOn(module, moduleKeys.useGradeButtonData);
});
test('areGradesFrozen form assignmentTypes.areGradesFrozen', () => {
expect(
mapped.areGradesFrozen,
).toEqual(selectors.assignmentTypes.areGradesFrozen(testState));
describe('frozen grades', () => {
beforeEach(() => {
hookSpy.mockReturnValue({ ...hookProps, areGradesFrozen: true });
el = shallow(<GradeButton {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
expect(el.text()).toEqual(hookProps.label);
});
});
test('format form grades.format', () => {
expect(mapped.format).toEqual(selectors.grades.gradeFormat(testState));
});
});
describe('mapDispatchToProps', () => {
test('setModalState from thunkActions.app.setModalStateFromTable', () => {
expect(mapDispatchToProps.setModalState).toEqual(thunkActions.app.setModalStateFromTable);
describe('not frozen grades', () => {
beforeEach(() => {
hookSpy.mockReturnValue(hookProps);
el = shallow(<GradeButton {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
expect(el.type()).toEqual('Button');
expect(el.props().onClick).toEqual(hookProps.onClick);
expect(el.contains(hookProps.label)).toEqual(true);
});
});
});
});

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { StrictDict } from 'utils';
import { useIntl, getLocale, isRtl } from '@edx/frontend-platform/i18n';
import {
Icon,
OverlayTrigger,
Tooltip,
} from '@edx/paragon';
import { FormattedMessage, getLocale, isRtl } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
import messages from './messages';
@@ -18,32 +18,33 @@ export const totalGradePercentageMessage = 'Total Grade values are always displa
* Total Grade column header.
* displays an overlay tooltip with screen-reader text to indicate total grade percentage
*/
const TotalGradeLabelReplacement = () => (
<div>
<OverlayTrigger
trigger={['hover', 'focus']}
key="left-basic"
placement={isRtl(getLocale()) ? 'right' : 'left'}
overlay={(
<Tooltip id="course-grade-tooltip">
<FormattedMessage {...messages.totalGradePercentage} />
</Tooltip>
)}
>
<div>
<FormattedMessage {...messages.totalGradeHeading} />
<div id="courseGradeTooltipIcon">
<Icon
className="fa fa-info-circle"
screenReaderText={(
<FormattedMessage {...messages.totalGradePercentage} />
)}
/>
const TotalGradeLabelReplacement = () => {
const { formatMessage } = useIntl();
return (
<div>
<OverlayTrigger
trigger={['hover', 'focus']}
key="left-basic"
placement={isRtl(getLocale()) ? 'right' : 'left'}
overlay={(
<Tooltip id="course-grade-tooltip">
{formatMessage(messages.totalGradePercentage)}
</Tooltip>
)}
>
<div>
{formatMessage(messages.totalGradeHeading)}
<div id="courseGradeTooltipIcon">
<Icon
className="fa fa-info-circle"
screenReaderText={formatMessage(messages.totalGradePercentage)}
/>
</div>
</div>
</div>
</OverlayTrigger>
</div>
);
</OverlayTrigger>
</div>
);
};
/**
* Asterisk to display next to heading labels that are only used for masters students
@@ -56,28 +57,34 @@ const mastersOnlyFieldAsterisk = (
* <UsernameLabelReplacement />
* Username column header. Lists that Student Key is possibly available
*/
const UsernameLabelReplacement = () => (
<div>
const UsernameLabelReplacement = () => {
const { formatMessage } = useIntl();
return (
<div>
<FormattedMessage {...messages.usernameHeading} />
<div>
{formatMessage(messages.usernameHeading)}
</div>
<div className="font-weight-normal student-key">
{formatMessage(messages.studentKeyLabel)}
{ mastersOnlyFieldAsterisk }
</div>
</div>
<div className="font-weight-normal student-key">
<FormattedMessage {...messages.studentKeyLabel} />
{ mastersOnlyFieldAsterisk }
</div>
</div>
);
);
};
/**
* <MastersOnlyLabelReplacement {message}>
* Column header for fields that are only available for masters students
*/
const MastersOnlyLabelReplacement = (message) => (
<div>
<FormattedMessage {...message} />
{ mastersOnlyFieldAsterisk }
</div>
);
const MastersOnlyLabelReplacement = (message) => {
const { formatMessage } = useIntl();
return (
<div>
{formatMessage(message)}
{ mastersOnlyFieldAsterisk }
</div>
);
};
export default StrictDict({
TotalGradeLabelReplacement,

View File

@@ -1,13 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradeButton component snapshots grades are frozen 1`] = `"why you gotta label people?"`;
exports[`GradeButton component frozen grades snapshot 1`] = `"test-label"`;
exports[`GradeButton component snapshots grades are not frozen 1`] = `
exports[`GradeButton component not frozen grades snapshot 1`] = `
<Button
className="btn-header grade-button"
onClick={[MockFunction this.onClick]}
onClick={[MockFunction hooks.onClick]}
variant="link"
>
why you gotta label people?
test-label
</Button>
`;

View File

@@ -2,11 +2,7 @@
exports[`LabelReplacements MastersOnlyLabelReplacement snapshot 1`] = `
<div>
<FormattedMessage
defaultMessage="defaultMessAge"
description="desCripTion"
id="id"
/>
defaultMessAge
<span
className="font-weight-normal"
>
@@ -19,11 +15,7 @@ exports[`LabelReplacements TotalGradeLabelReplacement displays overlay tooltip 1
<Tooltip
id="course-grade-tooltip"
>
<FormattedMessage
defaultMessage="Total Grade values are always displayed as a percentage"
description="Gradebook table message that total grades are displayed in percent format"
id="gradebook.GradesView.table.totalGradePercentage"
/>
Total Grade values are always displayed as a percentage
</Tooltip>
`;
@@ -35,11 +27,7 @@ exports[`LabelReplacements TotalGradeLabelReplacement snapshot 1`] = `
<Tooltip
id="course-grade-tooltip"
>
<FormattedMessage
defaultMessage="Total Grade values are always displayed as a percentage"
description="Gradebook table message that total grades are displayed in percent format"
id="gradebook.GradesView.table.totalGradePercentage"
/>
Total Grade values are always displayed as a percentage
</Tooltip>
}
placement="left"
@@ -51,23 +39,13 @@ exports[`LabelReplacements TotalGradeLabelReplacement snapshot 1`] = `
}
>
<div>
<FormattedMessage
defaultMessage="Total Grade (%)"
description="Gradebook table total grade column header"
id="gradebook.GradesView.table.headings.totalGrade"
/>
Total Grade (%)
<div
id="courseGradeTooltipIcon"
>
<Icon
className="fa fa-info-circle"
screenReaderText={
<FormattedMessage
defaultMessage="Total Grade values are always displayed as a percentage"
description="Gradebook table message that total grades are displayed in percent format"
id="gradebook.GradesView.table.totalGradePercentage"
/>
}
screenReaderText="Total Grade values are always displayed as a percentage"
/>
</div>
</div>
@@ -78,20 +56,12 @@ exports[`LabelReplacements TotalGradeLabelReplacement snapshot 1`] = `
exports[`LabelReplacements UsernameLabelReplacement snapshot 1`] = `
<div>
<div>
<FormattedMessage
defaultMessage="Username"
description="Gradebook table username column header"
id="gradebook.GradesView.table.headings.username"
/>
Username
</div>
<div
className="font-weight-normal student-key"
>
<FormattedMessage
defaultMessage="Student Key"
description="Gradebook table Student Key label"
id="gradebook.GradesView.table.labels.studentKey"
/>
Student Key
<span
className="font-weight-normal"
>
@@ -109,11 +79,7 @@ exports[`snapshot left to right overlay placement 1`] = `
<Tooltip
id="course-grade-tooltip"
>
<FormattedMessage
defaultMessage="Total Grade values are always displayed as a percentage"
description="Gradebook table message that total grades are displayed in percent format"
id="gradebook.GradesView.table.totalGradePercentage"
/>
Total Grade values are always displayed as a percentage
</Tooltip>
}
placement="right"
@@ -125,23 +91,13 @@ exports[`snapshot left to right overlay placement 1`] = `
}
>
<div>
<FormattedMessage
defaultMessage="Total Grade (%)"
description="Gradebook table total grade column header"
id="gradebook.GradesView.table.headings.totalGrade"
/>
Total Grade (%)
<div
id="courseGradeTooltipIcon"
>
<Icon
className="fa fa-info-circle"
screenReaderText={
<FormattedMessage
defaultMessage="Total Grade values are always displayed as a percentage"
description="Gradebook table message that total grades are displayed in percent format"
id="gradebook.GradesView.table.totalGradePercentage"
/>
}
screenReaderText="Total Grade values are always displayed as a percentage"
/>
</div>
</div>
@@ -157,11 +113,7 @@ exports[`snapshot right to left overlay placement 1`] = `
<Tooltip
id="course-grade-tooltip"
>
<FormattedMessage
defaultMessage="Total Grade values are always displayed as a percentage"
description="Gradebook table message that total grades are displayed in percent format"
id="gradebook.GradesView.table.totalGradePercentage"
/>
Total Grade values are always displayed as a percentage
</Tooltip>
}
placement="left"
@@ -173,23 +125,13 @@ exports[`snapshot right to left overlay placement 1`] = `
}
>
<div>
<FormattedMessage
defaultMessage="Total Grade (%)"
description="Gradebook table total grade column header"
id="gradebook.GradesView.table.headings.totalGrade"
/>
Total Grade (%)
<div
id="courseGradeTooltipIcon"
>
<Icon
className="fa fa-info-circle"
screenReaderText={
<FormattedMessage
defaultMessage="Total Grade values are always displayed as a percentage"
description="Gradebook table message that total grades are displayed in percent format"
id="gradebook.GradesView.table.totalGradePercentage"
/>
}
screenReaderText="Total Grade values are always displayed as a percentage"
/>
</div>
</div>

View File

@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookTable snapshot 1`] = `
<div
className="gradebook-container"
>
<DataTable
RowStatusComponent={[MockFunction hooks.nullMethod]}
columns={
Array [
"some",
"columns",
]
}
data={
Array [
"some",
"data",
]
}
hasFixedColumnWidths={true}
itemCount={3}
rowHeaderColumnKey="username"
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable
content="empty-table-content"
/>
</DataTable>
</div>
`;

View File

@@ -1,63 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookTable component snapshot - fields1 and 2 between email and totalGrade, mocked rows 1`] = `
<div
className="gradebook-container"
>
<DataTable
RowStatusComponent={[MockFunction this.nullMethod]}
columns={
Array [
Object {
"Header": <UsernameLabelReplacement />,
"accessor": "Username",
},
Object {
"Header": <MastersOnlyLabelReplacement
defaultMessage="Full Name"
description="Gradebook table full name column header"
id="gradebook.GradesView.table.headings.fullName"
/>,
"accessor": "Full Name",
},
Object {
"Header": <MastersOnlyLabelReplacement
defaultMessage="Email"
description="Gradebook table email column header"
id="gradebook.GradesView.table.headings.email"
/>,
"accessor": "Email",
},
Object {
"Header": "field1",
"accessor": "field1",
},
Object {
"Header": "field2",
"accessor": "field2",
},
Object {
"Header": <TotalGradeLabelReplacement />,
"accessor": "Total Grade (%)",
},
]
}
data={
Array [
"mappedRow: 1",
"mappedRow: 2",
"mappedRow: 3",
]
}
hasFixedColumnWidths={true}
itemCount={3}
rowHeaderColumnKey="username"
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable
content="No results found"
/>
</DataTable>
</div>
`;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { selectors } from 'data/redux/hooks';
import transforms from 'data/redux/transforms';
import { Headings } from 'data/constants/grades';
import { getLocalizedPercentSign } from 'i18n/utils';
import messages from './messages';
import Fields from './Fields';
import LabelReplacements from './LabelReplacements';
import GradeButton from './GradeButton';
const { roundGrade } = transforms.grades;
export const useGradebookTableData = () => {
const { formatMessage } = useIntl();
const grades = selectors.grades.useAllGrades();
const headings = selectors.root.useGetHeadings();
const mapHeaders = (heading) => {
let label;
if (heading === Headings.totalGrade) {
label = <LabelReplacements.TotalGradeLabelReplacement />;
} else if (heading === Headings.username) {
label = <LabelReplacements.UsernameLabelReplacement />;
} else if (heading === Headings.email) {
label = <LabelReplacements.MastersOnlyLabelReplacement {...messages.emailHeading} />;
} else if (heading === Headings.fullName) {
label = <LabelReplacements.MastersOnlyLabelReplacement {...messages.fullNameHeading} />;
} else {
label = heading;
}
return { Header: label, accessor: heading };
};
const mapRows = entry => ({
[Headings.username]: (
<Fields.Username username={entry.username} userKey={entry.external_user_key} />
),
[Headings.email]: (<Fields.Email email={entry.email} />),
[Headings.totalGrade]: `${roundGrade(entry.percent * 100)}${getLocalizedPercentSign()}`,
...entry.section_breakdown.reduce((acc, subsection) => ({
...acc,
[subsection.label]: <GradeButton {...{ entry, subsection }} />,
}), {}),
});
const nullMethod = () => null;
return {
columns: headings.map(mapHeaders),
data: grades.map(mapRows),
grades,
nullMethod,
emptyContent: formatMessage(messages.noResultsFound),
};
};
export default useGradebookTableData;

View File

@@ -0,0 +1,192 @@
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { getLocalizedPercentSign } from 'i18n/utils';
import { selectors } from 'data/redux/hooks';
import transforms from 'data/redux/transforms';
import { Headings } from 'data/constants/grades';
import LabelReplacements from './LabelReplacements';
import Fields from './Fields';
import GradeButton from './GradeButton';
import messages from './messages';
import useGradebookTableData from './hooks';
jest.mock('i18n/utils', () => ({
getLocalizedPercentSign: () => '%',
}));
jest.mock('./GradeButton', () => 'GradeButton');
jest.mock('./Fields', () => jest.requireActual('testUtils').mockNestedComponents({
Username: 'Fields.Username',
Email: 'Fields.Email',
}));
jest.mock('./LabelReplacements', () => jest.requireActual('testUtils').mockNestedComponents({
TotalGradeLabelReplacement: 'LabelReplacements.TotalGradeLabelReplacement',
UsernameLabelReplacement: 'LabelReplacements.UsernameLabelReplacement',
MastersOnlyLabelReplacement: 'LabelReplacements.MastersOnlyLabelReplacement',
}));
jest.mock('data/redux/hooks', () => ({
selectors: {
grades: { useAllGrades: jest.fn() },
root: { useGetHeadings: jest.fn() },
},
}));
jest.mock('data/redux/transforms', () => ({
grades: { roundGrade: jest.fn() },
}));
const roundGrade = grade => grade * 20;
transforms.grades.roundGrade.mockImplementation(roundGrade);
const subsectionLabels = [
'subsectionLabel1',
'subsectionLabel2',
'subsectionLabel3',
];
const allGrades = [
{
username: 'test-username-1',
external_user_key: 'EKey1',
email: 'email-1',
fullName: 'test-fullNAME',
percent: 0.9,
section_breakdown: [
{ label: subsectionLabels[0] },
{ label: subsectionLabels[1] },
{ label: subsectionLabels[2] },
],
},
{
username: 'test-username-2',
external_user_key: 'EKey2',
email: 'email-2',
percent: 0.8,
section_breakdown: [
{ label: subsectionLabels[0] },
{ label: subsectionLabels[1] },
{ label: subsectionLabels[2] },
],
},
{
username: 'test-username-3',
external_user_key: 'EKey3',
email: 'email-3',
percent: 0.6,
section_breakdown: [
{ label: subsectionLabels[0] },
{ label: subsectionLabels[1] },
{ label: subsectionLabels[2] },
],
},
];
const testHeading = 'test-heading-value';
const headings = [
Headings.totalGrade,
Headings.username,
Headings.email,
Headings.fullName,
testHeading,
];
selectors.grades.useAllGrades.mockReturnValue(allGrades);
selectors.root.useGetHeadings.mockReturnValue(headings);
let out;
describe('useGradebookTableData', () => {
beforeEach(() => {
jest.clearAllMocks();
out = useGradebookTableData();
});
describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalled();
});
it('initializes redux hooks', () => {
expect(selectors.grades.useAllGrades).toHaveBeenCalled();
expect(selectors.root.useGetHeadings).toHaveBeenCalled();
});
});
describe('output', () => {
describe('columns', () => {
test('total grade heading produces TotalGradeLabelReplacement label', () => {
const { Header, accessor } = out.columns[0];
expect(accessor).toEqual(headings[0]);
expect(shallow(Header)).toMatchObject(
shallow(<LabelReplacements.TotalGradeLabelReplacement />),
);
});
test('username heading produces UsernameLabelReplacement', () => {
const { Header, accessor } = out.columns[1];
expect(accessor).toEqual(headings[1]);
expect(shallow(Header)).toMatchObject(
shallow(<LabelReplacements.UsernameLabelReplacement />),
);
});
test('email heading replaces with email heading message', () => {
const { Header, accessor } = out.columns[2];
expect(accessor).toEqual(headings[2]);
expect(shallow(Header)).toMatchObject(
shallow(<LabelReplacements.MastersOnlyLabelReplacement {...messages.emailHeading} />),
);
});
test('fullName heading replaces with fullName heading message', () => {
const { Header, accessor } = out.columns[3];
expect(accessor).toEqual(headings[3]);
expect(shallow(Header)).toMatchObject(
shallow(<LabelReplacements.MastersOnlyLabelReplacement {...messages.fullNameHeading} />),
);
});
test('other headings are passed through', () => {
const { Header, accessor } = out.columns[4];
expect(accessor).toEqual(headings[4]);
expect(Header).toEqual(headings[4]);
});
});
describe('data', () => {
test('username field', () => {
allGrades.forEach((entry, index) => {
expect(out.data[index][Headings.username]).toMatchObject(
<Fields.Username username={entry.username} userKey={entry.external_user_key} />,
);
});
});
test('email field', () => {
allGrades.forEach((entry, index) => {
expect(out.data[index][Headings.email]).toMatchObject(
<Fields.Email email={entry.email} />,
);
});
});
test('totalGrade field', () => {
allGrades.forEach((entry, index) => {
expect(out.data[index][Headings.totalGrade]).toEqual(
`${roundGrade(entry.percent * 100)}${getLocalizedPercentSign()}`,
);
});
});
test('section breakdown', () => {
allGrades.forEach((entry, gradeIndex) => {
subsectionLabels.forEach((label, labelIndex) => {
expect(out.data[gradeIndex][label]).toMatchObject(
<GradeButton entry={entry} subsection={entry.section_breakdown[labelIndex]} />,
);
});
});
});
});
it('forwards grades from redux', () => {
expect(out.grades).toEqual(allGrades);
});
test('nullMethod returns null', () => {
expect(out.nullMethod()).toEqual(null);
});
test('emptyContent', () => {
expect(out.emptyContent).toEqual(formatMessage(messages.noResultsFound));
});
});
});

View File

@@ -1,21 +1,8 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { DataTable } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import { Headings } from 'data/constants/grades';
import { getLocalizedPercentSign } from 'i18n/utils';
import messages from './messages';
import Fields from './Fields';
import LabelReplacements from './LabelReplacements';
import GradeButton from './GradeButton';
const { roundGrade } = selectors.grades;
import useGradebookTableData from './hooks';
/**
* <GraebookTable />
@@ -23,96 +10,33 @@ const { roundGrade } = selectors.grades;
* a row for each user, with a column for their username, email, and total grade,
* along with one for each subsection in their grade entry.
*/
export class GradebookTable extends React.Component {
constructor(props) {
super(props);
this.mapHeaders = this.mapHeaders.bind(this);
this.mapRows = this.mapRows.bind(this);
this.nullMethod = this.nullMethod.bind(this);
}
export const GradebookTable = () => {
const {
columns,
data,
grades,
nullMethod,
emptyContent,
} = useGradebookTableData();
mapHeaders(heading) {
let label;
if (heading === Headings.totalGrade) {
label = <LabelReplacements.TotalGradeLabelReplacement />;
} else if (heading === Headings.username) {
label = <LabelReplacements.UsernameLabelReplacement />;
} else if (heading === Headings.email) {
label = <LabelReplacements.MastersOnlyLabelReplacement {...messages.emailHeading} />;
} else if (heading === Headings.fullName) {
label = <LabelReplacements.MastersOnlyLabelReplacement {...messages.fullNameHeading} />;
} else {
label = heading;
}
return { Header: label, accessor: heading };
}
mapRows = entry => ({
[Headings.username]: (
<Fields.Username username={entry.username} userKey={entry.external_user_key} />
),
[Headings.fullName]: (<Fields.Text value={entry.full_name} />),
[Headings.email]: (<Fields.Text value={entry.email} />),
[Headings.totalGrade]: `${roundGrade(entry.percent * 100)}${getLocalizedPercentSign()}`,
...entry.section_breakdown.reduce((acc, subsection) => ({
...acc,
[subsection.label]: <GradeButton {...{ entry, subsection }} />,
}), {}),
});
nullMethod() {
return null;
}
render() {
return (
<div className="gradebook-container">
<DataTable
columns={this.props.headings.map(this.mapHeaders)}
data={this.props.grades.map(this.mapRows)}
rowHeaderColumnKey="username"
hasFixedColumnWidths
itemCount={this.props.grades.length}
RowStatusComponent={this.nullMethod}
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable content={this.props.intl.formatMessage(messages.noResultsFound)} />
</DataTable>
</div>
);
}
}
GradebookTable.defaultProps = {
grades: [],
return (
<div className="gradebook-container">
<DataTable
columns={columns}
data={data}
rowHeaderColumnKey="username"
hasFixedColumnWidths
itemCount={grades.length}
RowStatusComponent={nullMethod}
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable content={emptyContent} />
</DataTable>
</div>
);
};
GradebookTable.propTypes = {
// redux
grades: PropTypes.arrayOf(PropTypes.shape({
percent: PropTypes.number,
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
attempted: PropTypes.bool,
category: PropTypes.string,
label: PropTypes.string,
module_id: PropTypes.string,
percent: PropTypes.number,
scoreEarned: PropTypes.number,
scorePossible: PropTypes.number,
subsection_name: PropTypes.string,
})),
user_id: PropTypes.number,
user_name: PropTypes.string,
})),
headings: PropTypes.arrayOf(PropTypes.string).isRequired,
// injected
intl: intlShape.isRequired,
};
GradebookTable.propTypes = {};
export const mapStateToProps = (state) => ({
grades: selectors.grades.allGrades(state),
headings: selectors.root.getHeadings(state),
});
export default injectIntl(connect(mapStateToProps)(GradebookTable));
export default GradebookTable;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { shallow } from 'enzyme';
import { DataTable } from '@edx/paragon';
import useGradebookTableData from './hooks';
import GradebookTable from '.';
jest.mock('./hooks', () => jest.fn());
const hookProps = {
columns: ['some', 'columns'],
data: ['some', 'data'],
grades: ['a', 'few', 'grades'],
nullMethod: jest.fn().mockName('hooks.nullMethod'),
emptyContent: 'empty-table-content',
};
useGradebookTableData.mockReturnValue(hookProps);
let el;
describe('GradebookTable', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<GradebookTable />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('Datatable props', () => {
const datatable = el.find(DataTable);
const props = datatable.props();
expect(props.columns).toEqual(hookProps.columns);
expect(props.data).toEqual(hookProps.data);
expect(props.itemCount).toEqual(hookProps.grades.length);
expect(props.RowStatusComponent).toEqual(hookProps.nullMethod);
expect(datatable.children().at(2).type()).toEqual('DataTable.EmptyTable');
expect(datatable.children().at(2).props().content).toEqual(hookProps.emptyContent);
});
});

View File

@@ -1,186 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { DataTable } from '@edx/paragon';
import selectors from 'data/selectors';
import { Headings } from 'data/constants/grades';
import LabelReplacements from './LabelReplacements';
import Fields from './Fields';
import messages from './messages';
import { GradebookTable, mapStateToProps } from '.';
jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedComponents({
DataTable: {
Table: 'DataTable.Table',
TableControlBar: 'DataTable.TableControlBar',
EmptyTable: 'DataTable.EmptyTable',
},
}));
jest.mock('./Fields', () => ({
__esModule: true,
default: {
Username: () => 'Fields.Username',
Text: () => 'Fields.Text',
},
}));
jest.mock('./LabelReplacements', () => ({
__esModule: true,
default: {
TotalGradeLabelReplacement: () => 'TotalGradeLabelReplacement',
UsernameLabelReplacement: () => 'UsernameLabelReplacement',
MastersOnlyLabelReplacement: () => 'MastersOnlyLabelReplacement',
},
}));
jest.mock('./GradeButton', () => 'GradeButton');
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grades: {
roundGrade: jest.fn(grade => `roundedGrade: ${grade}`),
allGrades: jest.fn(state => ({ allGrades: state })),
},
root: {
getHeadings: jest.fn(state => ({ getHeadings: state })),
},
},
}));
describe('GradebookTable', () => {
describe('component', () => {
let el;
const fields = { field1: 'field1', field2: 'field2' };
const props = {
grades: [
{
percent: 1,
section_breakdown: [
{ label: fields.field1, percent: 1.2 },
{ label: fields.field2, percent: 2.3 },
],
},
{
percent: 2,
section_breakdown: [
{ label: fields.field1, percent: 1.2 },
{ label: fields.field2, percent: 2.3 },
],
},
{
percent: 3,
section_breakdown: [
{ label: fields.field1, percent: 1.2 },
{ label: fields.field2, percent: 2.3 },
],
},
],
headings: [
Headings.username,
Headings.fullName,
Headings.email,
fields.field1,
fields.field2,
Headings.totalGrade,
],
intl: { formatMessage: (msg) => msg.defaultMessage },
};
test('snapshot - fields1 and 2 between email and totalGrade, mocked rows', () => {
el = shallow(<GradebookTable {...props} />);
el.instance().nullMethod = jest.fn().mockName('this.nullMethod');
el.instance().mapRows = (entry) => `mappedRow: ${entry.percent}`;
expect(el.instance().render()).toMatchSnapshot();
});
test('null method returns null for stub component', () => {
el = shallow(<GradebookTable {...props} />);
expect(el.instance().nullMethod()).toEqual(null);
});
describe('table columns (mapHeaders)', () => {
let headings;
beforeEach(() => {
el = shallow(<GradebookTable {...props} />);
headings = el.find(DataTable).props().columns;
});
test('username sets key and replaces Header with component', () => {
const heading = headings[0];
expect(heading.accessor).toEqual(Headings.username);
expect(heading.Header.type).toEqual(LabelReplacements.UsernameLabelReplacement);
});
test('full name sets key and Header from header', () => {
const heading = headings[1];
expect(heading.accessor).toEqual(Headings.fullName);
expect(heading.Header).toEqual(<LabelReplacements.MastersOnlyLabelReplacement {...messages.fullNameHeading} />);
});
test('email sets key and Header from header', () => {
const heading = headings[2];
expect(heading.accessor).toEqual(Headings.email);
expect(heading.Header).toEqual(<LabelReplacements.MastersOnlyLabelReplacement {...messages.emailHeading} />);
});
test('subsections set key and Header from header', () => {
expect(headings[3]).toEqual({ accessor: fields.field1, Header: fields.field1 });
expect(headings[4]).toEqual({ accessor: fields.field2, Header: fields.field2 });
});
test('totalGrade sets key and replaces Header with component', () => {
const heading = headings[5];
expect(heading.accessor).toEqual(Headings.totalGrade);
expect(heading.Header.type).toEqual(LabelReplacements.TotalGradeLabelReplacement);
});
});
describe('table data (mapRows)', () => {
let rows;
beforeEach(() => {
el = shallow(<GradebookTable {...props} />);
rows = el.find(DataTable).props().data;
});
describe.each([0, 1, 2])('gradeEntry($percent)', (gradeIndex) => {
let row;
const entry = props.grades[gradeIndex];
beforeEach(() => {
row = rows[gradeIndex];
});
test('username set to Username Field', () => {
const field = row[Headings.username];
expect(field.type).toEqual(Fields.Username);
expect(field.props).toEqual({
username: entry.username,
userKey: entry.external_user_key,
});
});
test('fullName set to Text Field', () => {
const field = row[Headings.fullName];
expect(field.type).toEqual(Fields.Text);
expect(field.props).toEqual({ value: entry.full_name });
});
test('email set to Text Field', () => {
const field = row[Headings.email];
expect(field.type).toEqual(Fields.Text);
expect(field.props).toEqual({ value: entry.email });
});
test('totalGrade set to rounded percent grade * 100', () => {
expect(
row[Headings.totalGrade],
).toEqual(`${selectors.grades.roundGrade(entry.percent * 100)}%`);
});
test('subsections loaded as GradeButtons', () => {
});
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = {
where: 'did',
all: 'of',
these: 'bananas',
come: 'from?',
};
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('grades from grades.allGrades', () => {
expect(mapped.grades).toEqual(selectors.grades.allGrades(testState));
});
test('headings from root.getHeadings', () => {
expect(mapped.headings).toEqual(selectors.root.getHeadings(testState));
});
});
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImportSuccessToast component render snapshot 1`] = `
<Toast
action="test-action"
onClose={[MockFunction hooks.onClose]}
show="test-show"
>
test-description
</Toast>
`;

View File

@@ -0,0 +1,39 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { actions, selectors } from 'data/redux/hooks';
import { views } from 'data/constants/app';
import messages from './messages';
/**
* <ImportSuccessToast />
* Toast component triggered by successful grade upload.
* Provides a link to view the Bulk Management History tab.
*/
export const useImportSuccessToastData = () => {
const { formatMessage } = useIntl();
const show = selectors.app.useShowImportSuccessToast();
const setAppView = actions.app.useSetView();
const setShow = actions.app.useSetShowImportSuccessToast();
const onClose = () => {
setShow(false);
};
const handleShowHistoryView = () => {
setAppView(views.bulkManagementHistory);
setShow(false);
};
return {
action: {
label: formatMessage(messages.showHistoryViewBtn),
onClick: handleShowHistoryView,
},
onClose,
show,
description: formatMessage(messages.description),
};
};
export default useImportSuccessToastData;

View File

@@ -0,0 +1,58 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { views } from 'data/constants/app';
import { actions, selectors } from 'data/redux/hooks';
import useImportSuccessToastData from './hooks';
import messages from './messages';
jest.mock('data/redux/hooks', () => ({
actions: {
app: {
useSetView: jest.fn(),
useSetShowImportSuccessToast: jest.fn(),
},
},
selectors: {
app: { useShowImportSuccessToast: jest.fn() },
},
}));
const setView = jest.fn().mockName('hooks.setView');
const setShowToast = jest.fn().mockName('hooks.setShowImportSuccessToast');
actions.app.useSetView.mockReturnValue(setView);
actions.app.useSetShowImportSuccessToast.mockReturnValue(setShowToast);
const showImportSuccessToast = 'test-show-import-success-toast';
selectors.app.useShowImportSuccessToast.mockReturnValue(showImportSuccessToast);
let out;
describe('ImportSuccessToast component', () => {
beforeAll(() => {
out = useImportSuccessToastData();
});
describe('behavior', () => {
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalledWith();
});
it('initializes redux hooks', () => {
expect(selectors.app.useShowImportSuccessToast).toHaveBeenCalled();
expect(actions.app.useSetView).toHaveBeenCalled();
expect(actions.app.useSetShowImportSuccessToast).toHaveBeenCalled();
});
});
describe('output', () => {
test('action label', () => {
expect(out.action.label).toEqual(formatMessage(messages.showHistoryViewBtn));
});
test('action click event', () => {
out.action.onClick();
expect(setView).toHaveBeenCalledWith(views.bulkManagementHistory);
expect(setShowToast).toHaveBeenCalledWith(false);
});
test('onClose', () => {
out.onClose();
expect(setShowToast).toHaveBeenCalledWith(false);
});
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Toast } from '@edx/paragon';
import useImportSuccessToastData from './hooks';
/**
* <ImportSuccessToast />
* Toast component triggered by successful grade upload.
* Provides a link to view the Bulk Management History tab.
*/
export const ImportSuccessToast = () => {
const {
action,
onClose,
show,
description,
} = useImportSuccessToastData();
return (
<Toast {...{ action, onClose, show }}>
{description}
</Toast>
);
};
ImportSuccessToast.propTypes = {};
export default ImportSuccessToast;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { shallow } from 'enzyme';
import useImportSuccessToastData from './hooks';
import ImportSuccessToast from '.';
jest.mock('./hooks', () => jest.fn());
const hookProps = {
action: 'test-action',
onClose: jest.fn().mockName('hooks.onClose'),
show: 'test-show',
description: 'test-description',
};
useImportSuccessToastData.mockReturnValue(hookProps);
let el;
describe('ImportSuccessToast component', () => {
beforeAll(() => {
el = shallow(<ImportSuccessToast />);
});
describe('behavior', () => {
it('initializes component hook', () => {
expect(useImportSuccessToastData).toHaveBeenCalled();
});
});
describe('render', () => {
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
test('Toast', () => {
expect(el.type()).toEqual('Toast');
expect(el.props().action).toEqual(hookProps.action);
expect(el.props().onClose).toEqual(hookProps.onClose);
expect(el.props().show).toEqual(hookProps.show);
expect(el.text()).toEqual(hookProps.description);
});
});
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
import { actions, selectors } from 'data/redux/hooks';
const useInterventionsReportData = () => {
const interventionExportUrl = selectors.root.useInterventionExportUrl();
const showBulkManagement = selectors.root.useShowBulkManagement();
const downloadInterventionReport = actions.grades.useDownloadInterventionReport();
const handleClick = () => {
downloadInterventionReport();
window.location.assign(interventionExportUrl);
};
return {
show: showBulkManagement,
handleClick,
};
};
export default useInterventionsReportData;

View File

@@ -0,0 +1,56 @@
import { actions, selectors } from 'data/redux/hooks';
import useInterventionsReportData from './hooks';
jest.mock('data/redux/hooks', () => ({
actions: {
grades: {
useDownloadInterventionReport: jest.fn(),
},
},
selectors: {
root: {
useInterventionExportUrl: jest.fn(),
useShowBulkManagement: jest.fn(),
},
},
}));
const downloadReport = jest.fn();
actions.grades.useDownloadInterventionReport.mockReturnValue(downloadReport);
selectors.root.useShowBulkManagement.mockReturnValue(true);
const exportUrl = 'test-intervention-export-url';
selectors.root.useInterventionExportUrl.mockReturnValue(exportUrl);
let hook;
let oldLocation;
describe('useInterventionsReportData hooks', () => {
beforeEach(() => {
oldLocation = window.location;
delete window.location;
window.location = { assign: jest.fn() };
hook = useInterventionsReportData();
});
afterEach(() => {
window.location = oldLocation;
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(selectors.root.useInterventionExportUrl).toHaveBeenCalled();
expect(selectors.root.useShowBulkManagement).toHaveBeenCalled();
expect(actions.grades.useDownloadInterventionReport).toHaveBeenCalled();
});
});
describe('output', () => {
test('show from showBulkManagement selector', () => {
expect(hook.show).toEqual(true);
});
describe('handleClick', () => {
it('downloads interventions report and navigates to export url', () => {
hook.handleClick();
expect(downloadReport).toHaveBeenCalled();
expect(window.location.assign).toHaveBeenCalledWith(exportUrl);
});
});
});
});

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import NetworkButton from 'components/NetworkButton';
import messages from './messages';
import useInterventionsReportData from './hooks';
/**
* <InterventionsReport />
* Provides download buttons for Bulk Management and Intervention reports, only if
* showBulkManagement is set in redus.
*/
export const InterventionsReport = () => {
const { show, handleClick } = useInterventionsReportData();
const { formatMessage } = useIntl();
if (!show) {
return null;
}
return (
<div>
<h4 className="mt-0">
{formatMessage(messages.title)}
</h4>
<div
className="d-flex justify-content-between align-items-center"
>
<div className="intervention-report-description">
{formatMessage(messages.description)}
</div>
<NetworkButton
label={messages.downloadBtn}
onClick={handleClick}
/>
</div>
</div>
);
};
export default InterventionsReport;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useIntl } from '@edx/frontend-platform/i18n';
import NetworkButton from 'components/NetworkButton';
import messages from './messages';
import useInterventionsReportData from './hooks';
import InterventionsReport from '.';
jest.mock('components/NetworkButton', () => 'NetworkButton');
jest.mock('./hooks', () => jest.fn());
const hookProps = { show: true, handleClick: jest.fn() };
useInterventionsReportData.mockReturnValue(hookProps);
let el;
describe('InterventionsReport component', () => {
beforeEach(() => {
el = shallow(<InterventionsReport />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(useInterventionsReportData).toHaveBeenCalledWith();
expect(useIntl).toHaveBeenCalledWith();
});
});
describe('output', () => {
it('does now render if show is false', () => {
useInterventionsReportData.mockReturnValueOnce({ ...hookProps, show: false });
el = shallow(<InterventionsReport />);
expect(el.isEmptyRender()).toEqual(true);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
const btnProps = el.find(NetworkButton).props();
expect(btnProps.label).toEqual(messages.downloadBtn);
expect(btnProps.onClick).toEqual(hookProps.handleClick);
});
});
});

View File

@@ -1,96 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import { PageButtons, mapStateToProps, mapDispatchToProps } from '.';
jest.mock('@edx/paragon', () => ({
Button: () => 'Button',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grades: {
nextPage: jest.fn(state => ({ nextPage: state })),
prevPage: jest.fn(state => ({ prevPage: state })),
},
},
}));
jest.mock('data/thunkActions', () => ({
__esModule: true,
default: {
grades: {
fetchPrevNextGrades: jest.fn(),
},
},
}));
let props;
let el;
describe('PageButtons component', () => {
beforeEach(() => {
props = {
getPrevNextGrades: jest.fn(),
nextPage: 'NEXT PAGE',
prevPage: 'prev PAGE',
};
});
describe('snapshots', () => {
beforeEach(() => {
el = shallow(<PageButtons {...props} />);
el.instance.fetchNextGrades = jest.fn().mockName('fetchNextGrades');
el.instance.fetchPrevGrades = jest.fn().mockName('fetchPrevGrades');
});
test('buttons enabled with both endpoints provided', () => {
expect(el.instance().render()).toMatchSnapshot();
});
test('nextPage disabled if not provided', () => {
el.setProps({ nextPage: undefined });
expect(el.instance().render()).toMatchSnapshot();
});
test('prevPage disabled if not provided', () => {
el.setProps({ prevPage: undefined });
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('behavior', () => {
beforeEach(() => {
el = shallow(<PageButtons {...props} />);
});
describe('getPrevGrades', () => {
it('calls props.getPrevNextGrades with props.prevPage', () => {
el.instance().getPrevGrades();
expect(props.getPrevNextGrades).toHaveBeenCalledWith(props.prevPage);
});
});
describe('getNextGrades', () => {
it('calls props.getPrevNextGrades with props.nextPage', () => {
el.instance().getNextGrades();
expect(props.getPrevNextGrades).toHaveBeenCalledWith(props.nextPage);
});
});
});
describe('mapStateToProps', () => {
const testState = { l: 'eeeerroooooy', j: 'jjjjeeeeeeenkins' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('nextPage from grades.nextPage', () => {
expect(mapped.nextPage).toEqual(selectors.grades.nextPage(testState));
});
test('prevPage from grades.prevPage', () => {
expect(mapped.prevPage).toEqual(selectors.grades.prevPage(testState));
});
});
describe('mapDispatchToProps', () => {
test('getPrevNextGrades from thunkActions.grades.fetchPrevNextGrades', () => {
expect(
mapDispatchToProps.getPrevNextGrades,
).toEqual(thunkActions.grades.fetchPrevNextGrades);
});
});
});

View File

@@ -1,133 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PageButtons component snapshots buttons enabled with both endpoints provided 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<Button
disabled={false}
onClick={[Function]}
style={
Object {
"margin": "20px",
}
}
variant="outline-primary"
>
<FormattedMessage
defaultMessage="Previous Page"
description="Grades tab Previous Page button text"
id="gradebook.GradesView.PageButtons.prevPage"
/>
</Button>
<Button
disabled={false}
onClick={[Function]}
style={
Object {
"margin": "20px",
}
}
variant="outline-primary"
>
<FormattedMessage
defaultMessage="Next Page"
description="Grades tab Next Page button text"
id="gradebook.GradesView.PageButtons.nextPage"
/>
</Button>
</div>
`;
exports[`PageButtons component snapshots nextPage disabled if not provided 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<Button
disabled={false}
onClick={[Function]}
style={
Object {
"margin": "20px",
}
}
variant="outline-primary"
>
<FormattedMessage
defaultMessage="Previous Page"
description="Grades tab Previous Page button text"
id="gradebook.GradesView.PageButtons.prevPage"
/>
</Button>
<Button
disabled={true}
onClick={[Function]}
style={
Object {
"margin": "20px",
}
}
variant="outline-primary"
>
<FormattedMessage
defaultMessage="Next Page"
description="Grades tab Next Page button text"
id="gradebook.GradesView.PageButtons.nextPage"
/>
</Button>
</div>
`;
exports[`PageButtons component snapshots prevPage disabled if not provided 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<Button
disabled={true}
onClick={[Function]}
style={
Object {
"margin": "20px",
}
}
variant="outline-primary"
>
<FormattedMessage
defaultMessage="Previous Page"
description="Grades tab Previous Page button text"
id="gradebook.GradesView.PageButtons.prevPage"
/>
</Button>
<Button
disabled={false}
onClick={[Function]}
style={
Object {
"margin": "20px",
}
}
variant="outline-primary"
>
<FormattedMessage
defaultMessage="Next Page"
description="Grades tab Next Page button text"
id="gradebook.GradesView.PageButtons.nextPage"
/>
</Button>
</div>
`;

Some files were not shown because too many files have changed in this diff Show More