diff --git a/jest.config.js b/jest.config.js
index 914cffa..bb3d8b6 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -8,4 +8,8 @@ module.exports = createConfig('jest', {
snapshotSerializers: [
'enzyme-to-json/serializer',
],
+ coveragePathIgnorePatterns: [
+ 'src/segment.js',
+ 'src/postcss.config.js',
+ ],
});
diff --git a/package.json b/package.json
index d29feaa..1876eaf 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@edx/frontend-app-gradebook",
- "version": "1.4.40",
+ "version": "1.4.41",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",
diff --git a/src/App.jsx b/src/App.jsx
new file mode 100755
index 0000000..73fbea9
--- /dev/null
+++ b/src/App.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
+import { Provider } from 'react-redux';
+import { IntlProvider } from 'react-intl';
+
+import Footer from '@edx/frontend-component-footer';
+
+import { routePath } from 'data/constants/app';
+import store from 'data/store';
+import GradebookPage from 'containers/GradebookPage';
+import EdxHeader from 'components/EdxHeader';
+import './App.scss';
+
+const App = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export default App;
diff --git a/src/App.test.jsx b/src/App.test.jsx
new file mode 100644
index 0000000..9253fe3
--- /dev/null
+++ b/src/App.test.jsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
+import { Provider } from 'react-redux';
+import { IntlProvider } from 'react-intl';
+
+import Footer from '@edx/frontend-component-footer';
+
+import { routePath } from 'data/constants/app';
+import store from 'data/store';
+import GradebookPage from 'containers/GradebookPage';
+import EdxHeader from 'components/EdxHeader';
+
+import App from './App';
+
+jest.mock('react-router-dom', () => ({
+ BrowserRouter: () => 'BrowserRouter',
+ Route: () => 'Route',
+ Switch: () => 'Switch',
+}));
+jest.mock('react-redux', () => ({
+ Provider: () => 'Provider',
+}));
+jest.mock('react-intl', () => ({
+ IntlProvider: () => 'IntlProvider',
+}));
+jest.mock('data/constants/app', () => ({
+ routePath: '/:courseId',
+}));
+jest.mock('@edx/frontend-component-footer', () => 'Footer');
+jest.mock('data/store', () => 'testStore');
+jest.mock('containers/GradebookPage', () => 'GradebookPage');
+jest.mock('components/EdxHeader', () => 'EdxHeader');
+
+const logo = 'fakeLogo.png';
+let el;
+let router;
+
+describe('App router component', () => {
+ test('snapshot', () => {
+ expect(shallow()).toMatchSnapshot();
+ });
+ describe('component', () => {
+ beforeEach(() => {
+ process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
+ el = shallow();
+ router = el.childAt(0).childAt(0);
+ });
+ describe('IntlProvider', () => {
+ test('outer-wrapper component', () => {
+ expect(el.type()).toBe(IntlProvider);
+ });
+ test('"en" locale', () => {
+ expect(el.props().locale).toEqual('en');
+ });
+ });
+ describe('Provider, inside IntlProvider', () => {
+ test('first child, passed the redux store props', () => {
+ expect(el.childAt(0).type()).toBe(Provider);
+ expect(el.childAt(0).props().store).toEqual(store);
+ });
+ });
+ describe('Router', () => {
+ test('first child of Provider', () => {
+ expect(router.type()).toBe(Router);
+ });
+ test('EdxHeader is above/outside-of the routing', () => {
+ expect(router.childAt(0).childAt(0).type()).toBe(EdxHeader);
+ expect(router.childAt(0).childAt(1).type()).toBe('main');
+ });
+ test('Routing - GradebookPage is only route', () => {
+ expect(router.find('main')).toEqual(shallow(
+
+
+
+
+ ,
+ ));
+ });
+ });
+ test('Footer logo drawn from env variable', () => {
+ expect(router.find(Footer).props().logo).toEqual(logo);
+ });
+ });
+});
diff --git a/src/__snapshots__/App.test.jsx.snap b/src/__snapshots__/App.test.jsx.snap
new file mode 100644
index 0000000..4636475
--- /dev/null
+++ b/src/__snapshots__/App.test.jsx.snap
@@ -0,0 +1,27 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`App router component snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/components/BulkManagementTab/FileUploadForm.test.jsx b/src/components/BulkManagementTab/FileUploadForm.test.jsx
index 31302af..7db6058 100644
--- a/src/components/BulkManagementTab/FileUploadForm.test.jsx
+++ b/src/components/BulkManagementTab/FileUploadForm.test.jsx
@@ -114,6 +114,14 @@ describe('FileUploadForm', () => {
});
});
});
+ describe('fileInput helper', () => {
+ test('links to fileInputRef.current', () => {
+ el = mount();
+ const ref = 'a-fake-ref';
+ el.instance().fileInputRef = { current: ref };
+ expect(el.instance().fileInput).toEqual(ref);
+ });
+ });
describe('behavior', () => {
let fileInput;
beforeEach(() => {
diff --git a/src/components/GradebookFilters/AssignmentFilter/test.jsx b/src/components/GradebookFilters/AssignmentFilter/test.jsx
index dfcb709..e8791f4 100644
--- a/src/components/GradebookFilters/AssignmentFilter/test.jsx
+++ b/src/components/GradebookFilters/AssignmentFilter/test.jsx
@@ -69,7 +69,6 @@ describe('AssignmentFilter', () => {
el = mount();
el.instance().handleChange(event);
});
-
it('calls props.updateAssignmentFilter with selection', () => {
expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
label: newAssgn,
@@ -87,6 +86,30 @@ describe('AssignmentFilter', () => {
const method = props.fetchGradesIfAssignmentGradeFiltersSet;
expect(method).toHaveBeenCalledWith();
});
+ describe('no selected option', () => {
+ const value = 'fake';
+ beforeEach(() => {
+ el = mount();
+ el.instance().handleChange({ target: { value } });
+ });
+ it('calls props.updateAssignmentFilter with selection', () => {
+ expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
+ label: value,
+ type: undefined,
+ id: undefined,
+ });
+ });
+ it('calls props.updateQueryParams with selected assignment id',
+ () => {
+ expect(props.updateQueryParams).toHaveBeenCalledWith({
+ assignment: undefined,
+ });
+ });
+ it('calls props.fetchGradesIfAssignmentGradeFiltersSet', () => {
+ const method = props.fetchGradesIfAssignmentGradeFiltersSet;
+ expect(method).toHaveBeenCalledWith();
+ });
+ });
});
});
describe('snapshots', () => {
diff --git a/src/components/GradesTab/test.jsx b/src/components/GradesTab/test.jsx
index a669938..6bb2524 100644
--- a/src/components/GradesTab/test.jsx
+++ b/src/components/GradesTab/test.jsx
@@ -6,6 +6,7 @@ import thunkActions from 'data/thunkActions';
import {
GradesTab,
+ mapStateToProps,
mapDispatchToProps,
} from '.';
@@ -78,6 +79,9 @@ describe('GradesTab', () => {
});
});
});
+ test('mapStateToProps is empty', () => {
+ expect(mapStateToProps({ some: 'state' })).toEqual({});
+ });
describe('mapDispatchToProps', () => {
describe('fetchGrades', () => {
test('from thunkActions.grades.fetchGrades', () => {
diff --git a/src/data/actions/utils.js b/src/data/actions/utils.js
index 91cec11..5e1517f 100644
--- a/src/data/actions/utils.js
+++ b/src/data/actions/utils.js
@@ -1,19 +1,22 @@
import { createAction } from '@reduxjs/toolkit';
+export const options = {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ timeZone: 'UTC',
+};
+export const timeOptions = {
+ hour: '2-digit',
+ minute: '2-digit',
+ timeZone: 'UTC',
+ timeZoneName: 'short',
+};
+
const formatDateForDisplay = (inputDate) => {
- const options = {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- timeZone: 'UTC',
- };
- const timeOptions = {
- hour: '2-digit',
- minute: '2-digit',
- timeZone: 'UTC',
- timeZoneName: 'short',
- };
- return `${inputDate.toLocaleDateString('en-US', options)} at ${inputDate.toLocaleTimeString('en-US', timeOptions)}`;
+ const date = inputDate.toLocaleDateString('en-US', options);
+ const time = inputDate.toLocaleTimeString('en-US', timeOptions);
+ return `${date} at ${time}`;
};
const sortAlphaAsc = (gradeRowA, gradeRowB) => {
diff --git a/src/data/actions/utils.test.js b/src/data/actions/utils.test.js
new file mode 100644
index 0000000..f31e69a
--- /dev/null
+++ b/src/data/actions/utils.test.js
@@ -0,0 +1,34 @@
+import { createAction } from '@reduxjs/toolkit';
+import * as utils from './utils';
+
+jest.mock('@reduxjs/toolkit', () => ({
+ createAction: (key, ...args) => ({ action: key, args }),
+}));
+
+describe('redux action utils', () => {
+ describe('formatDateForDisplay', () => {
+ it('returns the datetime as a formatted string', () => {
+ expect(utils.formatDateForDisplay(new Date('Jun 3 2021 11:59 AM EDT'))).toEqual(
+ 'June 3, 2021 at 03:59 PM UTC',
+ );
+ });
+ });
+ describe('sortAlphaAsc', () => {
+ it('returns sorting value (-1, 0, 1) by uppercase username', () => {
+ const sort = (v1, v2) => utils.sortAlphaAsc({ username: v1 }, { username: v2 });
+ expect(sort('aName', 'ANAme')).toEqual(0);
+ expect(sort('aName', 'laterName')).toEqual(-1);
+ expect(sort('laterName', 'aName')).toEqual(1);
+ });
+ });
+ describe('createActionFactory', () => {
+ it('returns an action creator with the data key', () => {
+ const dataKey = 'part-of-the-model';
+ const actionKey = 'an-action';
+ const args = ['some', 'args'];
+ expect(utils.createActionFactory(dataKey)(actionKey, ...args)).toEqual(
+ createAction(`${dataKey}/${actionKey}`, ...args),
+ );
+ });
+ });
+});
diff --git a/src/data/constants/app.js b/src/data/constants/app.js
index 39619d3..196902f 100644
--- a/src/data/constants/app.js
+++ b/src/data/constants/app.js
@@ -1,4 +1,7 @@
import { StrictDict } from 'utils';
+import { getConfig } from '@edx/frontend-platform';
+
+export const routePath = `${getConfig().PUBLIC_PATH}:courseId`;
export const modalFieldKeys = StrictDict({
adjustedGradePossible: 'adjustedGradePossible',
diff --git a/src/data/reducers/app.test.js b/src/data/reducers/app.test.js
index 04c7756..8f1eeb8 100644
--- a/src/data/reducers/app.test.js
+++ b/src/data/reducers/app.test.js
@@ -42,6 +42,37 @@ describe('app reducer', () => {
).toEqual({ ...testingState, courseId: testValue });
});
});
+ describe('appActions.filterMenu.startTransition', () => {
+ it('sets filterMenu.transitioning to true', () => {
+ expect(
+ app(testingState, appActions.filterMenu.startTransition()),
+ ).toEqual({
+ ...testingState,
+ filterMenu: { ...testingState.filterMenu, transitioning: true },
+ });
+ });
+ });
+ describe('appActions.filterMenu.endTransition', () => {
+ it('sets filterMenu.transitioning to false', () => {
+ const transitioningState = {
+ ...testingState,
+ filterMenu: { ...testingState.filterMenu, transitioning: true },
+ };
+ expect(
+ app(transitioningState, appActions.filterMenu.endTransition()),
+ ).toEqual(testingState);
+ });
+ });
+ describe('appActions.filterMenu.toggle', () => {
+ it('toggles filterMenu.open', () => {
+ const openState = {
+ ...testingState,
+ filterMenu: { ...testingState.filterMenu, open: true },
+ };
+ expect(app(testingState, appActions.filterMenu.toggle())).toEqual(openState);
+ expect(app(openState, appActions.filterMenu.toggle())).toEqual(testingState);
+ });
+ });
describe('appActions.setLocalFilter', () => {
it('loads filter values from the payload', () => {
expect(
diff --git a/src/data/reducers/filters.js b/src/data/reducers/filters.js
index 05f7ad3..8d09b70 100644
--- a/src/data/reducers/filters.js
+++ b/src/data/reducers/filters.js
@@ -30,17 +30,13 @@ const reducer = (state = initialState, { type: actionType, payload }) => {
assignmentGradeMax: payload.assignmentGradeMax,
assignmentGradeMin: payload.assignmentGradeMin,
};
- case actions.update.assignmentType.toString():
- return {
- ...state,
- assignmentType: payload,
- assignment: (
- (
- payload !== ''
- && (state.assignment || {}).type !== payload
- ) ? '' : state.assignment
- ),
- };
+ case actions.update.assignmentType.toString(): {
+ const newState = { ...state, assignmentType: payload };
+ if (payload !== '' && state.assignment && payload !== state.assignment.type) {
+ newState.assignment = '';
+ }
+ return newState;
+ }
case actions.update.cohort.toString():
return { ...state, cohort: payload };
case actions.update.courseGradeLimits.toString():
diff --git a/src/data/reducers/index.js b/src/data/reducers/index.js
index 6831164..aa62378 100755
--- a/src/data/reducers/index.js
+++ b/src/data/reducers/index.js
@@ -9,6 +9,7 @@ import grades from './grades';
import roles from './roles';
import tracks from './tracks';
+/* istanbul ignore next */
const rootReducer = combineReducers({
app,
assignmentTypes,
diff --git a/src/data/selectors/grades.test.js b/src/data/selectors/grades.test.js
index acda34b..937af0b 100644
--- a/src/data/selectors/grades.test.js
+++ b/src/data/selectors/grades.test.js
@@ -87,6 +87,44 @@ describe('grades selectors', () => {
describe('grade formatters', () => {
const selectedAssignment = { assignmentId: 'block-v1:edX+type@sequential+block@abcde' };
+ describe('formatGradeOverrideForDisplay', () => {
+ it('maps history entries with formatted date, grader, reason, and adjusted grade', () => {
+ const historyArray = [
+ {
+ history_date: 'Jan 01 2021',
+ history_user: 'Grog',
+ override_reason: 'rage',
+ earned_graded_override: 0,
+ },
+ {
+ history_date: 'Jan 02 2021',
+ history_user: 'Keyleth',
+ override_reason: 'nature',
+ earned_graded_override: 10,
+ },
+ {
+ history_date: 'Jan 03 2021',
+ history_user: 'Pike',
+ override_reason: 'Sarenrae',
+ earned_graded_override: 9001,
+ },
+ ];
+ const mapped = selectors.formatGradeOverrideForDisplay(historyArray);
+ const testEntry = (index) => {
+ const entry = historyArray[index];
+ expect(mapped[index]).toEqual({
+ date: formatDateForDisplay(new Date(entry.history_date)),
+ grader: entry.history_user,
+ reason: entry.override_reason,
+ adjustedGrade: entry.earned_graded_override,
+ });
+ };
+ testEntry(0);
+ testEntry(1);
+ testEntry(2);
+ });
+ });
+
describe('formatMinAssignmentGrade', () => {
const modifiedGrade = '1';
const selector = selectors.formatMinAssignmentGrade;
diff --git a/src/data/services/segment/constants.js b/src/data/services/segment/constants.js
new file mode 100644
index 0000000..0ae7aed
--- /dev/null
+++ b/src/data/services/segment/constants.js
@@ -0,0 +1,43 @@
+import { StrictDict } from 'utils';
+import actions from 'data/actions';
+
+export const courseId = window.location.pathname.slice(1);
+
+export const events = StrictDict({
+ receivedRoles: 'receivedRoles',
+ receivedGrades: 'receivedGrades',
+ updateSucceeded: 'updateSucceeded',
+ updateFailed: 'updateFailed',
+ uploadOverrideSucceeded: 'uploadOverrideSucceeded',
+ uploadOverrideFailed: 'uploadOverrideFailed',
+ filterApplied: 'filterApplied',
+ gradesReportDownloaded: 'gradesReportDownloaded',
+ interventionReportDownloaded: 'interventionReportDownloaded',
+});
+
+export const eventNames = StrictDict({
+ [events.receivedGrades]: 'edx.gradebook.grades.displayed',
+ [events.updateSucceeded]: 'edx.gradebook.grades.grade_override.succeeded',
+ [events.updateFailed]: 'edx.gradebook.grades.grade_override.failed',
+ [events.uploadOverrideSucceeded]: 'edx.gradebook.grades.upload.grades_overrides.succeeded',
+ [events.uploadOverrideFailed]: 'edx.gradebook.grades.upload.grades_overrides.failed',
+ [events.filterApplied]: 'edx.gradebook.grades.filter_applied',
+ [events.gradesReportDownloaded]: 'edx.gradebook.reports.grade_export.downloaded',
+ [events.interventionReportDownloaded]: 'edx.gradebook.reports.intervention.downloaded',
+});
+
+export const triggers = StrictDict({
+ [events.receivedRoles]: actions.roles.fetching.received.toString(),
+ [events.receivedGrades]: actions.grades.fetching.received.toString(),
+ [events.updateSucceeded]: actions.grades.update.success.toString(),
+ [events.updateFailed]: actions.grades.update.failure.toString(),
+ [events.uploadOverrideSucceeded]: actions.grades.uploadOverride.success.toString(),
+ [events.uploadOverrideFailed]: actions.grades.uploadOverride.failure.toString(),
+ [events.filterApplied]: actions.filters.update.courseGradeLimits.toString(),
+ [events.gradesReportDownloaded]: actions.grades.downloadReport.bulkGrades.toString(),
+ [events.interventionReportDownloaded]: actions.grades.downloadReport.intervention.toString(),
+});
+
+export const trackingCategory = 'gradebook';
+
+export const pageViewEvent = { category: trackingCategory, page: courseId };
diff --git a/src/data/services/segment/mapping.js b/src/data/services/segment/mapping.js
new file mode 100755
index 0000000..cf51738
--- /dev/null
+++ b/src/data/services/segment/mapping.js
@@ -0,0 +1,48 @@
+import { trackPageView } from '@redux-beacon/segment';
+
+import { StrictDict } from 'utils';
+import { handleEvent } from './utils';
+
+import {
+ courseId,
+ events,
+ eventNames,
+ trackingCategory,
+ triggers,
+} from './constants';
+
+const eventsMap = {};
+const loadTrigger = (event, options = {}) => {
+ eventsMap[triggers[event]] = handleEvent(eventNames[event], options);
+};
+
+eventsMap[triggers[events.receivedRoles]] = trackPageView(() => ({
+ category: trackingCategory,
+ page: courseId,
+}));
+loadTrigger(events.receivedGrades, {
+ propsFn: (payload) => ({
+ assignmentType: payload.assignmentType,
+ cohort: payload.cohort,
+ prev: payload.prev,
+ next: payload.next,
+ track: payload.track,
+ }),
+});
+loadTrigger(events.updateSucceeded, {
+ propsFn: ({ responseData }) => ({ updatedGrades: responseData }),
+});
+loadTrigger(events.updateFailed, {
+ propsFn: ({ error }) => ({ error }),
+});
+loadTrigger(events.uploadOverrideSucceeded);
+loadTrigger(events.uploadOverrideFailed, {
+ propsFn: ({ error }) => ({ error }),
+});
+loadTrigger(events.filterApplied, {
+ extrasFn: () => ({ label: courseId }),
+});
+loadTrigger(events.gradesReportDownloaded);
+loadTrigger(events.interventionReportDownloaded);
+
+export default StrictDict(eventsMap);
diff --git a/src/data/services/segment/test.js b/src/data/services/segment/test.js
new file mode 100644
index 0000000..fa1634a
--- /dev/null
+++ b/src/data/services/segment/test.js
@@ -0,0 +1,180 @@
+/**
+ * This module houses integration tests for the segment api integration
+ */
+import actions from 'data/actions';
+import eventsMap from './mapping';
+
+import {
+ courseId,
+ events,
+ eventNames,
+ trackingCategory as category,
+ triggers,
+} from './constants';
+
+jest.mock('./constants', () => ({
+ ...jest.requireActual('./constants'),
+ courseId: 'a-fake-course-id',
+}));
+jest.mock('@redux-beacon/segment', () => ({
+ trackEvent: (event) => ({ trackEvent: event }),
+ trackPageView: (event) => ({ trackPageView: event }),
+}));
+
+describe('segments event map', () => {
+ const loadEvent = (event) => ({
+ event,
+ trigger: triggers[event],
+ handler: eventsMap[triggers[event]],
+ });
+ describe('app initialization (received roles api response)', () => {
+ const { trigger, handler } = loadEvent(events.receivedRoles);
+ test('triggers on actions.roles.fetching.received', () => {
+ expect(trigger).toEqual(actions.roles.fetching.received.toString());
+ });
+ it('tracks the page view', () => {
+ expect(handler.trackPageView()).toEqual({ category, page: courseId });
+ });
+ });
+ describe('received grades update success', () => {
+ const { event, trigger, handler } = loadEvent(events.receivedGrades);
+ const payload = {
+ assignmentType: 'potions',
+ cohort: 3,
+ prev: 'aPlace',
+ next: 'aNotherPlace',
+ track: 'auror',
+ };
+ test('triggers on actions.grades.fetching.received', () => {
+ expect(trigger).toEqual(actions.grades.fetching.received.toString());
+ });
+ it('tracks event with ({ assignmentType, cohort, prev, next, track })', () => {
+ expect(handler.trackEvent({ payload })).toEqual({
+ name: eventNames[event],
+ properties: {
+ category,
+ label: courseId,
+ ...payload,
+ },
+ });
+ });
+ });
+ describe('grades update success', () => {
+ const { event, trigger, handler } = loadEvent(events.updateSucceeded);
+ const payload = { responseData: 'some new GRADES!' };
+ test('triggers on actions.grades.update.success', () => {
+ expect(trigger).toEqual(actions.grades.update.success.toString());
+ });
+ it('tracks event with ({ updatedGrades: payload.responseData })', () => {
+ expect(handler.trackEvent({ payload })).toEqual({
+ name: eventNames[event],
+ properties: {
+ category,
+ label: courseId,
+ updatedGrades: payload.responseData,
+ },
+ });
+ });
+ });
+
+ describe('grade update failure', () => {
+ const { event, trigger, handler } = loadEvent(events.updateFailed);
+ const payload = { error: 'some Errors happened :-(', other: 'stuff also' };
+ test('triggers on actions.grades.update.failure', () => {
+ expect(trigger).toEqual(actions.grades.update.failure.toString());
+ });
+ it('tracks event with error info', () => {
+ expect(handler.trackEvent({ payload })).toEqual({
+ name: eventNames[event],
+ properties: {
+ category,
+ label: courseId,
+ error: payload.error,
+ },
+ });
+ });
+ });
+
+ describe('grade override upload failure', () => {
+ const { event, trigger, handler } = loadEvent(events.uploadOverrideSucceeded);
+ test('triggers on actions.grades.uploadOverride.success', () => {
+ expect(trigger).toEqual(actions.grades.uploadOverride.success.toString());
+ });
+ it('tracks event', () => {
+ expect(handler.trackEvent()).toEqual({
+ name: eventNames[event],
+ properties: {
+ category,
+ label: courseId,
+ },
+ });
+ });
+ });
+
+ describe('grade override upload failure', () => {
+ const { event, trigger, handler } = loadEvent(events.uploadOverrideFailed);
+ const payload = { error: 'some Errors happened :-(', other: 'stuff also' };
+ test('triggers on actions.grades.uploadOverride.failure', () => {
+ expect(trigger).toEqual(actions.grades.uploadOverride.failure.toString());
+ });
+ it('tracks event with error info', () => {
+ expect(handler.trackEvent({ payload })).toEqual({
+ name: eventNames[event],
+ properties: {
+ category,
+ label: courseId,
+ error: payload.error,
+ },
+ });
+ });
+ });
+
+ describe('gradebook filter applied', () => {
+ const { event, trigger, handler } = loadEvent(events.filterApplied);
+ test('triggers on actions.filters.update.courseGradeLimits', () => {
+ expect(trigger).toEqual(actions.filters.update.courseGradeLimits.toString());
+ });
+ it('tracks event with error info', () => {
+ expect(handler.trackEvent()).toEqual({
+ name: eventNames[event],
+ label: courseId,
+ properties: {
+ category,
+ label: courseId,
+ },
+ });
+ });
+ });
+
+ describe('bulk grade report downloaded', () => {
+ const { event, trigger, handler } = loadEvent(events.gradesReportDownloaded);
+ test('triggers on actions.grades.downloadReport.bulkGrades', () => {
+ expect(trigger).toEqual(actions.grades.downloadReport.bulkGrades.toString());
+ });
+ it('tracks event', () => {
+ expect(handler.trackEvent()).toEqual({
+ name: eventNames[event],
+ properties: {
+ category,
+ label: courseId,
+ },
+ });
+ });
+ });
+
+ describe('intervention report downloaded', () => {
+ const { event, trigger, handler } = loadEvent(events.interventionReportDownloaded);
+ test('triggers on actions.grades.downloadReport.intervention', () => {
+ expect(trigger).toEqual(actions.grades.downloadReport.intervention.toString());
+ });
+ it('tracks event', () => {
+ expect(handler.trackEvent()).toEqual({
+ name: eventNames[event],
+ properties: {
+ category,
+ label: courseId,
+ },
+ });
+ });
+ });
+});
diff --git a/src/data/services/segment/utils.js b/src/data/services/segment/utils.js
new file mode 100755
index 0000000..a3b0b9b
--- /dev/null
+++ b/src/data/services/segment/utils.js
@@ -0,0 +1,19 @@
+/* eslint-disable import/prefer-default-export */
+import { trackEvent } from '@redux-beacon/segment';
+import { courseId, trackingCategory as category } from './constants';
+
+export const handleEvent = (name, options = {}) => trackEvent(
+ (event = {}) => {
+ const { payload } = event;
+ const { propsFn, extrasFn } = options;
+ return {
+ name,
+ ...(extrasFn && extrasFn(payload)),
+ properties: {
+ category,
+ label: courseId,
+ ...(propsFn && propsFn(payload)),
+ },
+ };
+ },
+);
diff --git a/src/data/services/segment/utils.test.js b/src/data/services/segment/utils.test.js
new file mode 100644
index 0000000..d1b4493
--- /dev/null
+++ b/src/data/services/segment/utils.test.js
@@ -0,0 +1,59 @@
+import * as constants from './constants';
+import { handleEvent } from './utils';
+
+jest.mock('@redux-beacon/segment', () => ({
+ trackEvent: (handleFn) => ({ trackEvent: handleFn }),
+}));
+
+const courseId = 'a-fake-course-id';
+const category = 'AFakeCategory';
+describe('segment service utils', () => {
+ beforeAll(() => {
+ global.window = Object.create(window);
+ const url = 'http://dummy.com';
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: `${url}/${courseId}`,
+ pathname: `/${courseId}`,
+ },
+ writable: true,
+ });
+ constants.courseId = courseId;
+ constants.trackingCategory = category;
+ });
+
+ describe('handleEvent', () => {
+ const name = 'aName';
+ const payload = { field1: 'some data', field2: 'other data' };
+ describe('when called with just a name', () => {
+ it('returns a TrackEvent call with the name, courseId, and tracking category', () => {
+ const handler = handleEvent(name).trackEvent;
+ expect(handler(payload)).toEqual({
+ name,
+ properties: { category, label: courseId },
+ });
+ });
+ });
+ describe('when a propsFn is provided', () => {
+ it('adds the output of propsFn(event.payload) to properties', () => {
+ const propsFn = ({ field1 }) => ({ field1 });
+ const handler = handleEvent(name, { propsFn }).trackEvent;
+ expect(handler({ payload })).toEqual({
+ name,
+ properties: { category, label: courseId, field1: payload.field1 },
+ });
+ });
+ });
+ describe('when an extrasFn object is provided', () => {
+ it('adds the output of extrasFn(event.payload) to top-level object', () => {
+ const extrasFn = ({ field2 }) => ({ field2 });
+ const handler = handleEvent(name, { extrasFn }).trackEvent;
+ expect(handler({ payload })).toEqual({
+ name,
+ field2: payload.field2,
+ properties: { category, label: courseId },
+ });
+ });
+ });
+ });
+});
diff --git a/src/data/store.js b/src/data/store.js
index 7946202..09474a9 100755
--- a/src/data/store.js
+++ b/src/data/store.js
@@ -1,113 +1,41 @@
-import { applyMiddleware, createStore } from 'redux';
+import * as redux from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import { createMiddleware } from 'redux-beacon';
-import Segment, { trackEvent, trackPageView } from '@redux-beacon/segment';
+import Segment from '@redux-beacon/segment';
import actions from './actions';
import selectors from './selectors';
import reducers from './reducers';
+import eventsMap from './services/segment/mapping';
import { configuration } from '../config';
-const loggerMiddleware = createLogger();
-const trackingCategory = 'gradebook';
+export const createStore = () => {
+ const loggerMiddleware = createLogger();
-const eventsMap = {
- [actions.roles.fetching.received.toString()]: trackPageView((action, prevState) => ({
- category: trackingCategory,
- page: selectors.app.courseId(prevState),
- })),
- [actions.grades.fetching.received.toString()]: trackEvent(({ payload }, prevState) => ({
- name: 'edx.gradebook.grades.displayed',
- properties: {
- category: trackingCategory,
- label: selectors.app.courseId(prevState),
- track: payload.track,
- cohort: payload.cohort,
- assignmentType: payload.assignmentType,
- prev: payload.prev,
- next: payload.next,
- },
- })),
- [actions.grades.update.success.toString()]: trackEvent(({ payload }, prevState) => ({
- name: 'edx.gradebook.grades.grade_override.succeeded',
- properties: {
- category: trackingCategory,
- label: selectors.app.courseId(prevState),
- updatedGrades: payload.responseData,
- },
- })),
- [actions.grades.update.failure.toString()]: trackEvent(({ payload }, prevState) => ({
- name: 'edx.gradebook.grades.grade_override.failed',
- properties: {
- category: trackingCategory,
- label: selectors.app.courseId(prevState),
- error: payload.error,
- },
- })),
- [actions.grades.uploadOverride.success.toString()]: trackEvent((action, prevState) => ({
- name: 'edx.gradebook.grades.upload.grades_overrides.succeeded',
- properties: {
- category: trackingCategory,
- label: selectors.app.courseId(prevState),
- },
- })),
- [actions.grades.uploadOverride.failure.toString()]: trackEvent(({ payload }, prevState) => ({
- name: 'edx.gradebook.grades.upload.grades_overrides.failed',
- properties: {
- category: trackingCategory,
- label: selectors.app.courseId(prevState),
- error: payload.error,
- },
- })),
- [actions.filters.update.courseGradeLimits]: trackEvent((action, prevState) => ({
- name: 'edx.gradebook.grades.filter_applied',
- label: selectors.app.courseId(prevState),
- properties: {
- category: trackingCategory,
- label: selectors.app.courseId(prevState),
- },
- })),
- [actions.grades.downloadReport.bulkGrades.toString()]: trackEvent(
- (action, prevState) => ({
- name: 'edx.gradebook.reports.grade_export.downloaded',
- properties: {
- category: trackingCategory,
- label: selectors.app.courseId(prevState),
- },
- }),
- ),
- [actions.grades.downloadReport.intervention.toString()]: trackEvent(
- (action, prevState) => ({
- name: 'edx.gradebook.reports.intervention.downloaded',
- properties: {
- category: trackingCategory,
- label: selectors.app.courseId(prevState),
- },
- }),
- ),
+ const middleware = [thunkMiddleware, loggerMiddleware];
+ // Conditionally add the segmentMiddleware only if the SEGMENT_KEY environment variable exists.
+ if (configuration.SEGMENT_KEY) {
+ middleware.push(createMiddleware(eventsMap, Segment()));
+ }
+ const store = redux.createStore(
+ reducers,
+ composeWithDevTools(redux.applyMiddleware(...middleware)),
+ );
+
+ /**
+ * Dev tools for redux work
+ */
+ if (process.env.NODE_ENV === 'development') {
+ window.store = store;
+ window.actions = actions;
+ window.selectors = selectors;
+ }
+
+ return store;
};
-const middleware = [thunkMiddleware, loggerMiddleware];
-// Conditionally add the segmentMiddleware only if the SEGMENT_KEY environment variable exists.
-if (configuration.SEGMENT_KEY) {
- middleware.push(createMiddleware(eventsMap, Segment()));
-}
+const store = createStore();
-const store = createStore(
- reducers,
- composeWithDevTools(applyMiddleware(...middleware)),
-);
-
-/**
- * Dev tools for redux work
- */
-if (process.env.NODE_ENV === 'development') {
- window.store = store;
- window.actions = actions;
- window.selectors = selectors;
-}
-
-export { trackingCategory };
export default store;
diff --git a/src/data/store.test.js b/src/data/store.test.js
new file mode 100644
index 0000000..f0d3f7b
--- /dev/null
+++ b/src/data/store.test.js
@@ -0,0 +1,99 @@
+import { applyMiddleware } from 'redux';
+import thunkMiddleware from 'redux-thunk';
+import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
+import { createLogger } from 'redux-logger';
+import { createMiddleware } from 'redux-beacon';
+import Segment from '@redux-beacon/segment';
+
+import actions from './actions';
+import selectors from './selectors';
+import reducers from './reducers';
+import eventsMap from './services/segment/mapping';
+import { configuration } from '../config';
+
+import exportedStore, { createStore } from './store';
+
+jest.mock('./reducers', () => 'REDUCER');
+jest.mock('./actions', () => 'ACTIONS');
+jest.mock('./selectors', () => 'SELECTORS');
+jest.mock('./services/segment/mapping', () => 'EVENTS MAP');
+
+jest.mock('redux-logger', () => ({
+ createLogger: () => 'logger',
+}));
+jest.mock('redux-thunk', () => 'thunkMiddleware');
+jest.mock('../config', () => ({
+ configuration: {
+ SEGMENT_KEY: 'a-fake-segment-key',
+ },
+}));
+jest.mock('redux-beacon', () => ({
+ createMiddleware: jest.fn((map, model) => ({ map, model })),
+}));
+jest.mock('@redux-beacon/segment', () => () => 'Segment');
+jest.mock('redux', () => ({
+ applyMiddleware: (...middleware) => ({ applied: middleware }),
+ createStore: (reducer, middleware) => ({ reducer, middleware }),
+}));
+jest.mock('redux-devtools-extension/logOnlyInProduction', () => ({
+ composeWithDevTools: (middleware) => ({ withDevTools: middleware }),
+}));
+
+describe('store aggregator module', () => {
+ describe('exported store', () => {
+ it('is generated by createStore', () => {
+ expect(exportedStore).toEqual(createStore());
+ });
+ it('creates store with connected reducers', () => {
+ expect(createStore().reducer).toEqual(reducers);
+ });
+ describe('middleware', () => {
+ describe('if SEGMENT_KEY is in the configuration', () => {
+ it('also adds segment middleware from eventMap', () => {
+ expect(createStore().middleware).toEqual(
+ composeWithDevTools(applyMiddleware(
+ thunkMiddleware,
+ createLogger(),
+ createMiddleware(eventsMap, Segment()),
+ )),
+ );
+ });
+ });
+ describe('if no SEGMENT_KEY', () => {
+ const key = configuration.SEGMENT_KEY;
+ beforeEach(() => {
+ configuration.SEGMENT_KEY = false;
+ });
+ it('exports thunk and logger middleware, composed and applied with dev tools', () => {
+ expect(createStore().middleware).toEqual(
+ composeWithDevTools(applyMiddleware(thunkMiddleware, createLogger())),
+ );
+ });
+ afterEach(() => {
+ configuration.SEGMENT_KEY = key;
+ });
+ });
+ });
+ });
+ describe('dev exposed tools', () => {
+ beforeEach(() => {
+ window.store = undefined;
+ window.actions = undefined;
+ window.selectors = undefined;
+ });
+ it('exposes redux tools if in development env', () => {
+ process.env.NODE_ENV = 'development';
+ const store = createStore();
+ expect(window.store).toEqual(store);
+ expect(window.actions).toEqual(actions);
+ expect(window.selectors).toEqual(selectors);
+ });
+ it('does not expose redux tools if in production env', () => {
+ process.env.NODE_ENV = 'production';
+ createStore();
+ expect(window.store).toEqual(undefined);
+ expect(window.actions).toEqual(undefined);
+ expect(window.selectors).toEqual(undefined);
+ });
+ });
+});
diff --git a/src/data/thunkActions/testUtils.js b/src/data/thunkActions/testUtils.js
index 77c6f43..387d84c 100644
--- a/src/data/thunkActions/testUtils.js
+++ b/src/data/thunkActions/testUtils.js
@@ -35,15 +35,13 @@ export const createTestFetcher = (
) => (
resolveFn,
expectedActions,
- verifyFn,
) => {
const store = mockStore({});
mockedMethod.mockReturnValue(new Promise(resolve => {
resolve(new Promise(resolveFn));
}));
return store.dispatch(thunkAction(...args)).then(() => {
- if (onDispatch) { onDispatch(); }
- if (verifyFn) { verifyFn(); }
+ onDispatch();
if (expectedActions !== undefined) {
expect(store.getActions()).toEqual(expectedActions);
}
diff --git a/src/data/utils.test.js b/src/data/utils.test.js
index 4267154..fd870d2 100644
--- a/src/data/utils.test.js
+++ b/src/data/utils.test.js
@@ -13,12 +13,14 @@ describe('Redux utilities - creators', () => {
const keys = ['a', 'b'];
const selectors = simpleSelectorFactory(transformer, keys);
expect(Object.keys(selectors)).toEqual(['root', ...keys]);
+ expect(selectors.root(state)).toEqual(data);
expect(selectors.a(state)).toEqual(data.a);
expect(selectors.b(state)).toEqual(data.b);
});
test('given an object for keys, returns a dict w/ simple selector per key', () => {
const selectors = simpleSelectorFactory(transformer, data);
expect(Object.keys(selectors)).toEqual(['root', ...Object.keys(data)]);
+ expect(selectors.root(state)).toEqual(data);
expect(selectors.a(state)).toEqual(data.a);
expect(selectors.b(state)).toEqual(data.b);
expect(selectors.c(state)).toEqual(data.c);
diff --git a/src/index.jsx b/src/index.jsx
index c358dbd..c86d3f4 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -1,43 +1,17 @@
-/* eslint-disable import/no-named-as-default */
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import React from 'react';
import ReactDOM from 'react-dom';
-import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
-import { Provider } from 'react-redux';
import {
APP_READY,
- getConfig,
initialize,
subscribe,
} from '@edx/frontend-platform';
-import { IntlProvider } from 'react-intl';
-import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
+import { messages as footerMessages } from '@edx/frontend-component-footer';
-import GradebookPage from './containers/GradebookPage';
-import EdxHeader from './components/EdxHeader';
-import store from './data/store';
-import './App.scss';
-
-const App = () => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
+import App from './App';
subscribe(APP_READY, () => {
ReactDOM.render(, document.getElementById('root'));
diff --git a/src/index.test.jsx b/src/index.test.jsx
new file mode 100644
index 0000000..ab70d7d
--- /dev/null
+++ b/src/index.test.jsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import {
+ APP_READY,
+ initialize,
+ subscribe,
+} from '@edx/frontend-platform';
+import { messages as footerMessages } from '@edx/frontend-component-footer';
+
+import App from './App';
+import '.';
+
+jest.mock('react-dom', () => ({
+ render: jest.fn(),
+}));
+jest.mock('@edx/frontend-platform', () => ({
+ APP_READY: 'app-is-ready-key',
+ initialize: jest.fn(),
+ subscribe: jest.fn(),
+}));
+jest.mock('@edx/frontend-component-footer', () => ({
+ messages: ['some', 'messages'],
+}));
+jest.mock('./App', () => 'App');
+
+describe('app registry', () => {
+ let getElement;
+
+ beforeEach(() => {
+ getElement = window.document.getElementById;
+ window.document.getElementById = jest.fn(id => ({ id }));
+ });
+ afterAll(() => {
+ window.document.getElementById = getElement;
+ });
+ test('subscribe is called for APP_READY, linking App to root element', () => {
+ const callArgs = subscribe.mock.calls[0];
+ expect(callArgs[0]).toEqual(APP_READY);
+ expect(callArgs[1]()).toEqual(
+ ReactDOM.render(, document.getElementById('root')),
+ );
+ });
+ test('initialize is called with footerMessages and requireAuthenticatedUser', () => {
+ expect(initialize).toHaveBeenCalledWith({
+ messages: [footerMessages],
+ requireAuthenticatedUser: true,
+ });
+ });
+});
diff --git a/src/utils/StrictDict.js b/src/utils/StrictDict.js
index 78a002d..dd9588b 100644
--- a/src/utils/StrictDict.js
+++ b/src/utils/StrictDict.js
@@ -1,27 +1,8 @@
/* eslint-disable no-console */
-import util from 'util';
-
-const staticReturnOptions = [
- 'dict',
- 'inspect',
- Symbol.toStringTag,
- util.inspect.custom,
- Symbol.for('nodejs.util.inspect.custom'),
-];
-
const strictGet = (target, name) => {
if (name === Symbol.toStringTag) {
return target;
}
- if (name === 'length') {
- return target.length;
- }
- if (staticReturnOptions.indexOf(name) >= 0) {
- return target;
- }
- if (name === Symbol.iterator) {
- return { ...target };
- }
if (name in target || name === '_reactFragment') {
return target[name];
diff --git a/src/utils/StrictDict.test.js b/src/utils/StrictDict.test.js
new file mode 100644
index 0000000..276f50d
--- /dev/null
+++ b/src/utils/StrictDict.test.js
@@ -0,0 +1,62 @@
+import StrictDict from './StrictDict';
+
+const value1 = 'valUE1';
+const value2 = 'vALue2';
+const key1 = 'Key1';
+const key2 = 'keY2';
+
+jest.spyOn(window, 'Error').mockImplementation(error => ({ stack: error }));
+
+describe('StrictDict', () => {
+ let consoleError;
+ let consoleLog;
+ let windowError;
+ beforeEach(() => {
+ consoleError = window.console.error;
+ consoleLog = window.console.lot;
+ windowError = window.Error;
+ window.console.error = jest.fn();
+ window.console.log = jest.fn();
+ window.Error = jest.fn(error => ({ stack: error }));
+ });
+ afterAll(() => {
+ window.console.error = consoleError;
+ window.console.log = consoleLog;
+ window.Error = windowError;
+ });
+ const rawDict = {
+ [key1]: value1,
+ [key2]: value2,
+ };
+ const dict = StrictDict(rawDict);
+ it('provides key access like a normal dict object', () => {
+ expect(dict[key1]).toEqual(value1);
+ });
+ it('allows key listing', () => {
+ expect(Object.keys(dict)).toEqual([key1, key2]);
+ });
+ it('allows item listing', () => {
+ expect(Object.values(dict)).toEqual([value1, value2]);
+ });
+ it('allows stringification', () => {
+ expect(dict.toString()).toEqual(rawDict.toString());
+ expect({ ...dict }).toEqual({ ...rawDict });
+ });
+ it('allows entry listing', () => {
+ expect(Object.entries(dict)).toEqual(Object.entries(rawDict));
+ });
+ describe('missing key', () => {
+ it('logs error with target, name, and error stack', () => {
+ // eslint-ignore-next-line no-unused-vars
+ const callBadKey = () => dict.fakeKey;
+ callBadKey();
+ expect(window.console.error.mock.calls).toEqual([
+ [{ target: dict, name: 'fakeKey' }],
+ [Error('invalid property "fakeKey"').stack],
+ ]);
+ });
+ it('returns undefined', () => {
+ expect(dict.fakeKey).toEqual(undefined);
+ });
+ });
+});