diff --git a/.eslintignore b/.eslintignore index 846b63b..65588a7 100755 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ coverage/* dist/ node_modules/ +src/segment.js diff --git a/config/webpack.common.config.js b/config/webpack.common.config.js index c28c753..c2d341d 100755 --- a/config/webpack.common.config.js +++ b/config/webpack.common.config.js @@ -4,6 +4,7 @@ const path = require('path'); module.exports = { entry: { + segment: path.resolve(__dirname, '../src/segment.js'), app: path.resolve(__dirname, '../src/index.jsx'), }, output: { diff --git a/config/webpack.dev.config.js b/config/webpack.dev.config.js index b124f0f..bc29eb2 100755 --- a/config/webpack.dev.config.js +++ b/config/webpack.dev.config.js @@ -12,6 +12,7 @@ module.exports = Merge.smart(commonConfig, { entry: [ // enable react's custom hot dev client so we get errors reported in the browser require.resolve('react-dev-utils/webpackHotDevClient'), + path.resolve(__dirname, '../src/segment.js'), path.resolve(__dirname, '../src/index.jsx'), ], module: { diff --git a/package-lock.json b/package-lock.json index eb97258..870eb31 100755 --- a/package-lock.json +++ b/package-lock.json @@ -3160,6 +3160,11 @@ "url-template": "^2.0.8" } }, + "@redux-beacon/segment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redux-beacon/segment/-/segment-1.1.0.tgz", + "integrity": "sha512-NLRoP3Jfx5z99YX6TFFznwXIMjqjD6/qdMZIKFRgGO8NtMWrCruA8EeQYPJZUBnuOjw6RtOA1UdjbqyRmdhc/Q==" + }, "@sambego/storybook-styles": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@sambego/storybook-styles/-/storybook-styles-1.0.0.tgz", @@ -4055,10 +4060,9 @@ "dev": true }, "array-flatten": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", - "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", - "dev": true + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.1.tgz", + "integrity": "sha1-Qmu52oQJDBg42BLIFQryCoMx4pY=" }, "array-ify": { "version": "1.0.0", @@ -10465,12 +10469,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -10490,7 +10496,8 @@ "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", @@ -10638,6 +10645,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -22190,6 +22198,14 @@ "symbol-observable": "^1.0.3" } }, + "redux-beacon": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/redux-beacon/-/redux-beacon-2.0.5.tgz", + "integrity": "sha512-h2XCqu72+TWz2HHUDKSgp3y4OlnnmMsp9EOfdI5+BWNcch/kxaJbm+rt3SSqjOPdP9CL3aqSISZ4VD4Ev85xcw==", + "requires": { + "array-flatten": "2.1.1" + } + }, "redux-devtools-extension": { "version": "2.13.7", "resolved": "https://registry.npmjs.org/redux-devtools-extension/-/redux-devtools-extension-2.13.7.tgz", diff --git a/package.json b/package.json index 497a529..7ec787a 100755 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@edx/frontend-auth": "^1.3.0", "@edx/frontend-component-footer": "^1.0.0", "@edx/paragon": "^3.8.3", + "@redux-beacon/segment": "^1.0.0", "babel-polyfill": "^6.26.0", "classnames": "^2.2.6", "email-prop-type": "^1.1.7", @@ -43,6 +44,7 @@ "react-router-redux": "^5.0.0-alpha.9", "redux": "^3.7.2", "redux-devtools-extension": "^2.13.7", + "redux-beacon": "^2.0.3", "redux-logger": "^3.0.6", "redux-thunk": "^2.3.0", "whatwg-fetch": "^2.0.4" diff --git a/src/data/actions/roles.js b/src/data/actions/roles.js index ce37ecd..79af33a 100644 --- a/src/data/actions/roles.js +++ b/src/data/actions/roles.js @@ -10,7 +10,11 @@ import LmsApiService from '../services/LmsApiService'; const allowedRoles = ['staff', 'instructor', 'support']; -const gotRoles = canUserViewGradebook => ({ type: GOT_ROLES, canUserViewGradebook }); +const gotRoles = (canUserViewGradebook, courseId) => ({ + type: GOT_ROLES, + canUserViewGradebook, + courseId, +}); const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES }); const getRoles = (courseId, urlQuery) => ( @@ -20,7 +24,7 @@ const getRoles = (courseId, urlQuery) => ( const canUserViewGradebook = response.is_staff || (response.roles.some(role => (role.course_id === courseId) && allowedRoles.includes(role.role))); - dispatch(gotRoles(canUserViewGradebook)); + dispatch(gotRoles(canUserViewGradebook, courseId)); if (canUserViewGradebook) { dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track)); dispatch(fetchTracks(courseId)); diff --git a/src/data/actions/roles.test.js b/src/data/actions/roles.test.js index 1e96752..528b25a 100644 --- a/src/data/actions/roles.test.js +++ b/src/data/actions/roles.test.js @@ -22,23 +22,23 @@ const course1Id = 'course-v1:edX+DemoX+Demo_Course'; const course2Id = 'course-v1:edX+DemoX+Demo_Course_2'; const rolesUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(course1Id)}`; -function makeRoleListObj(roles, isGlobalStaff){ +function makeRoleListObj(roles, isGlobalStaff) { return { - roles: roles, + roles, is_staff: isGlobalStaff, - } + }; } function makeRoleObj(courseId, role) { return { course_id: courseId, - role: role, - } -}; + role, + }; +} -const course1StaffRole = makeRoleObj(course1Id, "staff"); -const course1DummyRole = makeRoleObj(course1Id, "dummy"); -const course2StaffRole = makeRoleObj(course2Id, "staff"); -const course2DummyRole = makeRoleObj(course2Id, "dummy"); +const course1StaffRole = makeRoleObj(course1Id, 'staff'); +const course1DummyRole = makeRoleObj(course1Id, 'dummy'); +const course2StaffRole = makeRoleObj(course2Id, 'staff'); +const course2DummyRole = makeRoleObj(course2Id, 'dummy'); const urlParams = { cohort: null, track: null }; describe('actions', () => { @@ -49,7 +49,7 @@ describe('actions', () => { describe('getRoles', () => { it('dispatches got_roles action and subsequent actions after fetching role that allows gradebook', () => { const expectedActions = [ - { type: GOT_ROLES, canUserViewGradebook: true }, + { type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id }, { type: STARTED_FETCHING_GRADES }, { type: STARTED_FETCHING_TRACKS }, { type: STARTED_FETCHING_COHORTS }, @@ -57,7 +57,7 @@ describe('actions', () => { ]; const store = mockStore(); axiosMock.onGet(rolesUrl) - .replyOnce(200, JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false))); + .replyOnce(200, JSON.stringify(makeRoleListObj([course1StaffRole, course2DummyRole], false))); return store.dispatch(getRoles(course1Id, urlParams)).then(() => { expect(store.getActions()).toEqual(expectedActions); @@ -66,7 +66,7 @@ describe('actions', () => { it('dispatches got_roles action and other actions after fetching irrelevent roles but user is global staff', () => { const expectedActions = [ - { type: GOT_ROLES, canUserViewGradebook: true }, + { type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id }, { type: STARTED_FETCHING_GRADES }, { type: STARTED_FETCHING_TRACKS }, { type: STARTED_FETCHING_COHORTS }, @@ -75,7 +75,7 @@ describe('actions', () => { const store = mockStore(); axiosMock.onGet(rolesUrl) - .replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true))); + .replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2DummyRole], true))); return store.dispatch(getRoles(course1Id, urlParams)).then(() => { expect(store.getActions()).toEqual(expectedActions); @@ -84,12 +84,14 @@ describe('actions', () => { it('dispatches got_roles action and no other actions after fetching role that disallows gradebook', () => { const expectedActions = [ - { type: GOT_ROLES, canUserViewGradebook: false }, + { + type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id, + }, ]; const store = mockStore(); axiosMock.onGet(rolesUrl) - .replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false))); + .replyOnce(200, JSON.stringify(makeRoleListObj([course1DummyRole, course2StaffRole], false))); return store.dispatch(getRoles(course1Id, urlParams)).then(() => { expect(store.getActions()).toEqual(expectedActions); @@ -98,12 +100,12 @@ describe('actions', () => { it('dispatches got_roles action and no other actions after fetching empty roles', () => { const expectedActions = [ - { type: GOT_ROLES, canUserViewGradebook: false }, + { type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id }, ]; const store = mockStore(); axiosMock.onGet(rolesUrl) - .replyOnce(200, JSON.stringify(makeRoleListObj([], false))); + .replyOnce(200, JSON.stringify(makeRoleListObj([], false))); return store.dispatch(getRoles(course1Id, urlParams)).then(() => { expect(store.getActions()).toEqual(expectedActions); @@ -112,7 +114,7 @@ describe('actions', () => { it('dispatches got_roles action and other actions after fetching empty roles but user is global staff', () => { const expectedActions = [ - { type: GOT_ROLES, canUserViewGradebook: true }, + { type: GOT_ROLES, canUserViewGradebook: true, courseId: course1Id }, { type: STARTED_FETCHING_GRADES }, { type: STARTED_FETCHING_TRACKS }, { type: STARTED_FETCHING_COHORTS }, @@ -121,7 +123,7 @@ describe('actions', () => { const store = mockStore(); axiosMock.onGet(rolesUrl) - .replyOnce(200, JSON.stringify(makeRoleListObj([], true))); + .replyOnce(200, JSON.stringify(makeRoleListObj([], true))); return store.dispatch(getRoles(course1Id, urlParams)).then(() => { expect(store.getActions()).toEqual(expectedActions); diff --git a/src/data/store.js b/src/data/store.js index e48a079..62526a3 100755 --- a/src/data/store.js +++ b/src/data/store.js @@ -2,14 +2,48 @@ import { applyMiddleware, createStore } 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 { GOT_ROLES } from './constants/actionTypes/roles'; +import { GOT_GRADES, GRADE_UPDATE_SUCCESS, GRADE_UPDATE_FAILURE } from './constants/actionTypes/grades'; import reducers from './reducers'; const loggerMiddleware = createLogger(); +const eventsMap = { + [GOT_ROLES]: trackPageView(action => ({ + page: action.courseId, + })), + [GOT_GRADES]: trackEvent(action => ({ + name: 'Grades displayed or paginated', + properties: { + track: action.track, + cohort: action.cohort, + prev: action.prev, + next: action.next, + }, + })), + [GRADE_UPDATE_SUCCESS]: trackEvent(action => ({ + name: 'Grades Updated', + properties: { + updatedGrades: action.payload.responseData, + }, + })), + [GRADE_UPDATE_FAILURE]: trackEvent(action => ({ + name: 'Grades Fail to Update', + properties: { + error: action.payload.error, + }, + })), +}; + +const segmentMiddleware = createMiddleware(eventsMap, Segment()); + + const store = createStore( reducers, - composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware)), + composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware, segmentMiddleware)), ); export default store; diff --git a/src/segment.js b/src/segment.js new file mode 100644 index 0000000..c2eb562 --- /dev/null +++ b/src/segment.js @@ -0,0 +1,85 @@ +// The code in this file is from Segment's website: +// https://segment.com/docs/sources/website/analytics.js/quickstart/ +import { configuration } from './config'; + +(function () { + // Create a queue, but don't obliterate an existing one! + const analytics = window.analytics = window.analytics || []; + + // If the real analytics.js is already on the page return. + if (analytics.initialize) return; + + // If the snippet was invoked already show an error. + if (analytics.invoked) { + if (window.console && console.error) { + console.error('Segment snippet included twice.'); + } + return; + } + + // Invoked flag, to make sure the snippet + // is never invoked twice. + analytics.invoked = true; + + // A list of the methods in Analytics.js to stub. + analytics.methods = [ + 'trackSubmit', + 'trackClick', + 'trackLink', + 'trackForm', + 'pageview', + 'identify', + 'reset', + 'group', + 'track', + 'ready', + 'alias', + 'debug', + 'page', + 'once', + 'off', + 'on', + ]; + + // Define a factory to create stubs. These are placeholders + // for methods in Analytics.js so that you never have to wait + // for it to load to actually record data. The `method` is + // stored as the first argument, so we can replay the data. + analytics.factory = function (method) { + return function () { + const args = Array.prototype.slice.call(arguments); + args.unshift(method); + analytics.push(args); + return analytics; + }; + }; + + // For each of our methods, generate a queueing stub. + for (let i = 0; i < analytics.methods.length; i++) { + const key = analytics.methods[i]; + analytics[key] = analytics.factory(key); + } + + // Define a method to load Analytics.js from our CDN, + // and that will be sure to only ever load it once. + analytics.load = function (key, options) { + // Create an async script element based on your key. + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.async = true; + script.src = `https://cdn.segment.com/analytics.js/v1/${ + key}/analytics.min.js`; + + // Insert our script next to the first script element. + const first = document.getElementsByTagName('script')[0]; + first.parentNode.insertBefore(script, first); + analytics._loadOptions = options; + }; + + // Add a version to keep track of what's in the wild. + analytics.SNIPPET_VERSION = '4.1.0'; + + // Load Analytics.js with your key, which will automatically + // load the tools you've enabled for your account. Boosh! + analytics.load(configuration.SEGMENT_KEY); +}());