Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34eaa31776 | ||
|
|
a7316e6824 | ||
|
|
c0ab04f20c | ||
|
|
ed72e7c203 | ||
|
|
223d9a00bd | ||
|
|
8379f48e50 |
@@ -1,3 +1,4 @@
|
||||
coverage/*
|
||||
dist/
|
||||
node_modules/
|
||||
src/segment.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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
46
documentation/decisions/0001-update-api-usage.rst
Normal file
46
documentation/decisions/0001-update-api-usage.rst
Normal file
@@ -0,0 +1,46 @@
|
||||
Usage of the bulk-update API
|
||||
============================
|
||||
|
||||
Context
|
||||
=======
|
||||
|
||||
The LMS Grades API exposes a set of Gradebook-related endpoints:
|
||||
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/api/v1/gradebook_views.py
|
||||
The ``bulk-update`` endpoint defined therein allows for the creation/modification of subsection
|
||||
grades for multiple users and sections in a single request. This allows clients of the API to limit
|
||||
the number of network requests made and to more easily manage client-side data. Moreover,
|
||||
the course grade updates that occur during calls to this API are synchronous - the entire update operation
|
||||
is completed before a response is given to the client.
|
||||
|
||||
For decisions made about the implementation of this API, see:
|
||||
https://github.com/edx/edx-platform/blob/master/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst
|
||||
|
||||
Decision
|
||||
========
|
||||
|
||||
The Gradebook front-end will post data about a single subsection and user in a single request
|
||||
to the ``bulk-update`` API. That is, we currently need only the "update" aspect of this
|
||||
endpoint, and not the "bulk" aspect, for satisfying the requirements of the current UX.
|
||||
|
||||
Status
|
||||
======
|
||||
|
||||
Accepted (circa December 2018)
|
||||
|
||||
Consequences
|
||||
============
|
||||
|
||||
This is a scenario in which the implementation of the API is coupled to the
|
||||
UX that depends on the API. Because the course grade update is synchronous, it means
|
||||
the API response can contain the updated subsection and course grade data. Because
|
||||
a response from the API contains this data, the UI can operate in a very familiar way:
|
||||
|
||||
- A user clicks a button to submit a request with grade update data to the update endpoint.
|
||||
- On the server, the subsection and course grades are modified.
|
||||
- In the meantime, the client-side user looks at a spinner.
|
||||
- A response is returned with updated data and the spinner goes away.
|
||||
- Updated data is displayed to the user, along with a message indicative of the update.
|
||||
|
||||
If the update becomes asynchronous, the user experience outlined above has to change.
|
||||
Because a single call to this endpoint updates grades data for only a single user,
|
||||
the endpoint does not necessarily have to utilize an asynchronous operation at this time.
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -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",
|
||||
@@ -22190,6 +22194,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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -176,7 +176,7 @@ export default class Gradebook extends React.Component {
|
||||
return 'Tracks';
|
||||
};
|
||||
|
||||
roundGrade = percent => parseFloat(percent.toFixed(DECIMAL_PRECISION));
|
||||
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
|
||||
|
||||
formatter = {
|
||||
percent: (entries, areGradesFrozen) => entries.map((entry) => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
85
src/segment.js
Normal file
85
src/segment.js
Normal file
@@ -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);
|
||||
}());
|
||||
Reference in New Issue
Block a user