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); + }); + }); +});