Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93be5329ca | ||
|
|
80ba7e7152 | ||
|
|
f88526aa3a | ||
|
|
c0f08eee58 | ||
|
|
ef62ea35dc | ||
|
|
34eaa31776 | ||
|
|
a7316e6824 | ||
|
|
c0ab04f20c | ||
|
|
ed72e7c203 | ||
|
|
223d9a00bd | ||
|
|
8379f48e50 |
@@ -1,3 +1,5 @@
|
||||
coverage/*
|
||||
dist/
|
||||
node_modules/
|
||||
src/postcss.config.js
|
||||
src/segment.js
|
||||
|
||||
@@ -23,6 +23,7 @@ before_script: greenkeeper-lockfile-update
|
||||
after_script: greenkeeper-lockfile-upload
|
||||
script:
|
||||
- make validate-no-uncommitted-package-lock-changes
|
||||
- npm run lint
|
||||
- npm run test
|
||||
- npm run build
|
||||
after_success:
|
||||
|
||||
@@ -49,6 +49,13 @@ in which you'd like to enable the gradebook. Add a course override flag using a
|
||||
``grades.writable_gradebook``. Make sure to check the ``enabled`` box. Alternatively, you could add this as a
|
||||
regular waffle flag to enable the gradebook for all courses.
|
||||
|
||||
## Running tests
|
||||
|
||||
1. Assuming that you're operating in the context of the edX devstack,
|
||||
run `gradebook-shell` from your devstack directory. This will start a bash shell inside your
|
||||
running gradebook container.
|
||||
2. Run `make test` (which executes `npm run test`). This will run all of the gradebook tests.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
* `config`
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -47,7 +47,7 @@ module.exports = Merge.smart(commonConfig, {
|
||||
minimize: true,
|
||||
},
|
||||
},
|
||||
'postcss-loader',
|
||||
'postcss-loader', // for autoprefixing, needs to be before the sass loader, not sure why
|
||||
{
|
||||
loader: 'sass-loader', // compiles Sass to CSS
|
||||
options: {
|
||||
|
||||
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"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
InputSelect,
|
||||
@@ -132,8 +133,7 @@ export default class Gradebook extends React.Component {
|
||||
this.props.selectedCohort,
|
||||
selectedTrackSlug,
|
||||
);
|
||||
const updatedQueryStrings = this.updateQueryParams('track', selectedTrackSlug);
|
||||
this.props.history.push(updatedQueryStrings);
|
||||
this.updateQueryParams('track', selectedTrackSlug);
|
||||
};
|
||||
|
||||
updateCohorts = (event) => {
|
||||
@@ -147,8 +147,7 @@ export default class Gradebook extends React.Component {
|
||||
selectedCohortId,
|
||||
this.props.selectedTrack,
|
||||
);
|
||||
const updatedQueryStrings = this.updateQueryParams('cohort', selectedCohortId);
|
||||
this.props.history.push(updatedQueryStrings);
|
||||
this.updateQueryParams('cohort', selectedCohortId);
|
||||
};
|
||||
|
||||
mapSelectedAssignmentTypeEntry = (entry) => {
|
||||
@@ -176,7 +175,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) => {
|
||||
@@ -264,30 +263,34 @@ export default class Gradebook extends React.Component {
|
||||
<div role="radiogroup" aria-labelledby="score-view-group-label">
|
||||
<span id="score-view-group-label">Score View:</span>
|
||||
<span>
|
||||
<input
|
||||
id="score-view-percent"
|
||||
className="ml-2 mr-1"
|
||||
type="radio"
|
||||
name="score-view"
|
||||
value="percent"
|
||||
defaultChecked
|
||||
onClick={() => this.props.toggleFormat('percent')}
|
||||
/>
|
||||
<label className="mr-2" htmlFor="score-view-percent">Percent</label>
|
||||
<label className="mr-2" htmlFor="score-view-percent">
|
||||
<input
|
||||
id="score-view-percent"
|
||||
className="ml-2 mr-1"
|
||||
type="radio"
|
||||
name="score-view"
|
||||
value="percent"
|
||||
defaultChecked
|
||||
onClick={() => this.props.toggleFormat('percent')}
|
||||
/>
|
||||
Percent
|
||||
</label>
|
||||
</span>
|
||||
<span>
|
||||
<input
|
||||
id="score-view-absolute"
|
||||
type="radio"
|
||||
name="score-view"
|
||||
value="absolute"
|
||||
className="mr-1"
|
||||
onClick={() => this.props.toggleFormat('absolute')}
|
||||
/>
|
||||
<label htmlFor="score-view-absolute">Absolute</label>
|
||||
<label htmlFor="score-view-absolute">
|
||||
<input
|
||||
id="score-view-absolute"
|
||||
type="radio"
|
||||
name="score-view"
|
||||
value="absolute"
|
||||
className="mr-1"
|
||||
onClick={() => this.props.toggleFormat('absolute')}
|
||||
/>
|
||||
Absolute
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
{ this.props.assignmnetTypes.length > 0 &&
|
||||
{ this.props.assignmentTypes.length > 0 &&
|
||||
<div className="student-filters">
|
||||
<span className="label">
|
||||
Assignment Types:
|
||||
@@ -295,8 +298,8 @@ export default class Gradebook extends React.Component {
|
||||
<InputSelect
|
||||
name="assignment-types"
|
||||
ariaLabel="Assignment Types"
|
||||
value={this.mapSelectedTrackEntry(this.props.selectedAssignmentType)}
|
||||
options={this.mapAssignmentTypeEntries(this.props.assignmnetTypes)}
|
||||
value="All"
|
||||
options={this.mapAssignmentTypeEntries(this.props.assignmentTypes)}
|
||||
onChange={this.updateAssignmentTypes}
|
||||
/>
|
||||
</div>
|
||||
@@ -328,9 +331,22 @@ export default class Gradebook extends React.Component {
|
||||
<a href={`${this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}#view-data_download`}>Generate Grade Report</a>
|
||||
</div>
|
||||
<SearchField
|
||||
onSubmit={value => this.props.searchForUser(this.props.match.params.courseId, value, this.props.selectedCohort, this.props.selectedTrack)}
|
||||
onSubmit={value =>
|
||||
this.props.searchForUser(
|
||||
this.props.match.params.courseId,
|
||||
value,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
)
|
||||
}
|
||||
onChange={filterValue => this.setState({ filterValue })}
|
||||
onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)}
|
||||
onClear={() =>
|
||||
this.props.getUserGrades(
|
||||
this.props.match.params.courseId,
|
||||
this.props.selectedCohort,
|
||||
this.props.selectedTrack,
|
||||
)
|
||||
}
|
||||
value={this.state.filterValue}
|
||||
/>
|
||||
</div>
|
||||
@@ -346,7 +362,10 @@ export default class Gradebook extends React.Component {
|
||||
<div className="gbook">
|
||||
<Table
|
||||
columns={this.props.headings}
|
||||
data={this.formatter[this.props.format](this.props.grades, this.props.areGradesFrozen)}
|
||||
data={this.formatter[this.props.format](
|
||||
this.props.grades,
|
||||
this.props.areGradesFrozen,
|
||||
)}
|
||||
tableSortable
|
||||
defaultSortDirection="asc"
|
||||
defaultSortedColumn="username"
|
||||
@@ -390,3 +409,76 @@ export default class Gradebook extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
Gradebook.defaultProps = {
|
||||
areGradesFrozen: false,
|
||||
assignmentTypes: [],
|
||||
canUserViewGradebook: false,
|
||||
cohorts: [],
|
||||
grades: [],
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
match: {
|
||||
params: {
|
||||
courseId: '',
|
||||
},
|
||||
},
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
showSpinner: false,
|
||||
tracks: [],
|
||||
};
|
||||
|
||||
Gradebook.propTypes = {
|
||||
areGradesFrozen: PropTypes.bool,
|
||||
assignmentTypes: PropTypes.arrayOf(PropTypes.string),
|
||||
canUserViewGradebook: PropTypes.bool,
|
||||
cohorts: PropTypes.arrayOf(PropTypes.string),
|
||||
filterColumns: PropTypes.func.isRequired,
|
||||
format: PropTypes.string.isRequired,
|
||||
getRoles: PropTypes.func.isRequired,
|
||||
getUserGrades: PropTypes.func.isRequired,
|
||||
grades: PropTypes.arrayOf(PropTypes.shape({
|
||||
percent: PropTypes.number,
|
||||
section_breakdown: PropTypes.arrayOf(PropTypes.shape({
|
||||
attempted: PropTypes.bool,
|
||||
category: PropTypes.string,
|
||||
is_graded: PropTypes.bool,
|
||||
label: PropTypes.string,
|
||||
module_id: PropTypes.string,
|
||||
percent: PropTypes.number,
|
||||
scoreEarned: PropTypes.number,
|
||||
scorePossible: PropTypes.number,
|
||||
subsection_name: PropTypes.string,
|
||||
})),
|
||||
user_id: PropTypes.number,
|
||||
user_name: PropTypes.string,
|
||||
})),
|
||||
headings: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
key: PropTypes.string,
|
||||
columnSortable: PropTypes.bool,
|
||||
onSort: PropTypes.func,
|
||||
})).isRequired,
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string,
|
||||
}),
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseId: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
searchForUser: PropTypes.func.isRequired,
|
||||
selectedCohort: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
}),
|
||||
selectedTrack: PropTypes.string,
|
||||
showSpinner: PropTypes.bool,
|
||||
showSuccess: PropTypes.bool.isRequired,
|
||||
toggleFormat: PropTypes.func.isRequired,
|
||||
tracks: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
})),
|
||||
updateBanner: PropTypes.func.isRequired,
|
||||
updateGrades: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -4,13 +4,6 @@ import { Hyperlink } from '@edx/paragon';
|
||||
import EdxLogo from '../../../assets/edx-sm.png';
|
||||
|
||||
export default class Header extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
mobileNavOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
renderLogo() {
|
||||
return (
|
||||
<img src={EdxLogo} alt="edX logo" height="30" width="60" />
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
|
||||
export default function PageButtons({prevPage, nextPage, selectedTrack, selectedCohort, getPrevNextGrades}) {
|
||||
export default function PageButtons({
|
||||
prevPage, nextPage, selectedTrack, selectedCohort, getPrevNextGrades, match,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="d-flex justify-content-center"
|
||||
@@ -15,16 +16,57 @@ export default function PageButtons({prevPage, nextPage, selectedTrack, selected
|
||||
style={{ margin: '20px' }}
|
||||
buttonType="primary"
|
||||
disabled={!prevPage}
|
||||
onClick={() => getPrevNextGrades(prevPage, selectedCohort, selectedTrack)}
|
||||
onClick={() =>
|
||||
getPrevNextGrades(
|
||||
prevPage,
|
||||
selectedCohort,
|
||||
selectedTrack,
|
||||
match.params.courseId,
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
label="Next Page"
|
||||
style={{ margin: '20px' }}
|
||||
buttonType="primary"
|
||||
disabled={!nextPage}
|
||||
onClick={() => getPrevNextGrades(nextPage, selectedCohort, selectedTrack)}
|
||||
onClick={() =>
|
||||
getPrevNextGrades(
|
||||
nextPage,
|
||||
selectedCohort,
|
||||
selectedTrack,
|
||||
match.params.courseId,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PageButtons.defaultProps = {
|
||||
match: {
|
||||
params: {
|
||||
courseId: '',
|
||||
},
|
||||
},
|
||||
nextPage: '',
|
||||
prevPage: '',
|
||||
selectedCohort: null,
|
||||
selectedTrack: null,
|
||||
};
|
||||
|
||||
PageButtons.propTypes = {
|
||||
getPrevNextGrades: PropTypes.func.isRequired,
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseId: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
nextPage: PropTypes.string,
|
||||
prevPage: PropTypes.string,
|
||||
selectedCohort: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
}),
|
||||
selectedTrack: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,15 @@ import { fetchTracks } from '../../data/actions/tracks';
|
||||
import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes';
|
||||
import { getRoles } from '../../data/actions/roles';
|
||||
|
||||
function shouldShowSpinner(state) {
|
||||
if (state.roles.canUserViewGradebook === true) {
|
||||
return state.grades.showSpinner;
|
||||
} else if (state.roles.canUserViewGradebook === false) {
|
||||
return false;
|
||||
} // canUserViewGradebook === null
|
||||
return true;
|
||||
}
|
||||
|
||||
const mapStateToProps = state => (
|
||||
{
|
||||
grades: state.grades.results,
|
||||
@@ -27,23 +36,13 @@ const mapStateToProps = state => (
|
||||
showSuccess: state.grades.showSuccess,
|
||||
prevPage: state.grades.prevPage,
|
||||
nextPage: state.grades.nextPage,
|
||||
assignmnetTypes: state.assignmentTypes.results,
|
||||
assignmentTypes: state.assignmentTypes.results,
|
||||
areGradesFrozen: state.assignmentTypes.areGradesFrozen,
|
||||
showSpinner: shouldShowSpinner(state),
|
||||
canUserViewGradebook: state.roles.canUserViewGradebook
|
||||
canUserViewGradebook: state.roles.canUserViewGradebook,
|
||||
}
|
||||
);
|
||||
|
||||
function shouldShowSpinner (state) {
|
||||
if (state.roles.canUserViewGradebook === true){
|
||||
return state.grades.showSpinner;
|
||||
} else if (state.roles.canUserViewGradebook === false){
|
||||
return false;
|
||||
} else { // canUserViewGradebook === null
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => (
|
||||
{
|
||||
getUserGrades: (courseId, cohort, track) => {
|
||||
@@ -52,8 +51,8 @@ const mapDispatchToProps = dispatch => (
|
||||
searchForUser: (courseId, searchText, cohort, track) => {
|
||||
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, false));
|
||||
},
|
||||
getPrevNextGrades: (endpoint, cohort, track) => {
|
||||
dispatch(fetchPrevNextGrades(endpoint, cohort, track));
|
||||
getPrevNextGrades: (endpoint, cohort, track, courseId) => {
|
||||
dispatch(fetchPrevNextGrades(endpoint, cohort, track, courseId));
|
||||
},
|
||||
getCohorts: (courseId) => {
|
||||
dispatch(fetchCohorts(courseId));
|
||||
|
||||
@@ -32,7 +32,7 @@ const sortGrades = (columnName, direction) => {
|
||||
const startedFetchingGrades = () => ({ type: STARTED_FETCHING_GRADES });
|
||||
const finishedFetchingGrades = () => ({ type: FINISHED_FETCHING_GRADES });
|
||||
const errorFetchingGrades = () => ({ type: ERROR_FETCHING_GRADES });
|
||||
const gotGrades = (grades, cohort, track, headings, prev, next) => ({
|
||||
const gotGrades = (grades, cohort, track, headings, prev, next, courseId) => ({
|
||||
type: GOT_GRADES,
|
||||
grades,
|
||||
cohort,
|
||||
@@ -40,15 +40,18 @@ const gotGrades = (grades, cohort, track, headings, prev, next) => ({
|
||||
headings,
|
||||
prev,
|
||||
next,
|
||||
courseId,
|
||||
});
|
||||
|
||||
const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST });
|
||||
const gradeUpdateSuccess = responseData => ({
|
||||
const gradeUpdateSuccess = (courseId, responseData) => ({
|
||||
type: GRADE_UPDATE_SUCCESS,
|
||||
courseId,
|
||||
payload: { responseData },
|
||||
});
|
||||
const gradeUpdateFailure = error => ({
|
||||
const gradeUpdateFailure = (courseId, error) => ({
|
||||
type: GRADE_UPDATE_FAILURE,
|
||||
courseId,
|
||||
payload: { error },
|
||||
});
|
||||
|
||||
@@ -77,6 +80,7 @@ const fetchGrades = (courseId, cohort, track, showSuccess) => (
|
||||
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
|
||||
data.previous,
|
||||
data.next,
|
||||
courseId,
|
||||
));
|
||||
dispatch(finishedFetchingGrades());
|
||||
dispatch(updateBanner(!!showSuccess));
|
||||
@@ -100,6 +104,7 @@ const fetchMatchingUserGrades = (courseId, searchText, cohort, track, showSucces
|
||||
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
|
||||
data.previous,
|
||||
data.next,
|
||||
courseId,
|
||||
));
|
||||
dispatch(finishedFetchingGrades());
|
||||
dispatch(updateBanner(showSuccess));
|
||||
@@ -110,7 +115,7 @@ const fetchMatchingUserGrades = (courseId, searchText, cohort, track, showSucces
|
||||
}
|
||||
);
|
||||
|
||||
const fetchPrevNextGrades = (endpoint, cohort, track) => (
|
||||
const fetchPrevNextGrades = (endpoint, cohort, track, courseId) => (
|
||||
(dispatch) => {
|
||||
dispatch(startedFetchingGrades());
|
||||
return apiClient.get(endpoint)
|
||||
@@ -123,6 +128,7 @@ const fetchPrevNextGrades = (endpoint, cohort, track) => (
|
||||
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
|
||||
data.previous,
|
||||
data.next,
|
||||
courseId,
|
||||
));
|
||||
dispatch(finishedFetchingGrades());
|
||||
})
|
||||
@@ -139,11 +145,11 @@ const updateGrades = (courseId, updateData, searchText, cohort, track) => (
|
||||
return LmsApiService.updateGradebookData(courseId, updateData)
|
||||
.then(response => response.data)
|
||||
.then((data) => {
|
||||
dispatch(gradeUpdateSuccess(data));
|
||||
dispatch(gradeUpdateSuccess(courseId, data));
|
||||
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, true));
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch(gradeUpdateFailure(error));
|
||||
dispatch(gradeUpdateFailure(courseId, error));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('actions', () => {
|
||||
],
|
||||
prev: responseData.previous,
|
||||
next: responseData.next,
|
||||
courseId,
|
||||
},
|
||||
{ type: FINISHED_FETCHING_GRADES },
|
||||
{ type: UPDATE_BANNER, showSuccess: false },
|
||||
@@ -119,9 +120,10 @@ describe('actions', () => {
|
||||
axiosMock.onGet(fetchGradesURL)
|
||||
.replyOnce(200, JSON.stringify(responseData));
|
||||
|
||||
return store.dispatch(fetchGrades(courseId, expectedCohort, expectedTrack, false)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
return store.dispatch(fetchGrades(courseId, expectedCohort, expectedTrack, false))
|
||||
.then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches failure action after fetching grades', () => {
|
||||
@@ -134,9 +136,10 @@ describe('actions', () => {
|
||||
axiosMock.onGet(fetchGradesURL)
|
||||
.replyOnce(500, JSON.stringify({}));
|
||||
|
||||
return store.dispatch(fetchGrades(courseId, expectedCohort, expectedTrack, false)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
return store.dispatch(fetchGrades(courseId, expectedCohort, expectedTrack, false))
|
||||
.then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,10 @@ 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 +69,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 +78,10 @@ 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 +90,17 @@ 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 +109,15 @@ 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 +126,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 +135,10 @@ 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);
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('actions', () => {
|
||||
|
||||
describe('fetchTracks', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const trackUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}?include_expired=1`;
|
||||
|
||||
it('dispatches success action after fetching tracks', () => {
|
||||
const responseData = {
|
||||
@@ -54,7 +55,7 @@ describe('actions', () => {
|
||||
];
|
||||
const store = mockStore();
|
||||
|
||||
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}`)
|
||||
axiosMock.onGet(trackUrl)
|
||||
.replyOnce(200, JSON.stringify(responseData));
|
||||
|
||||
return store.dispatch(fetchTracks(courseId)).then(() => {
|
||||
@@ -69,7 +70,7 @@ describe('actions', () => {
|
||||
];
|
||||
const store = mockStore();
|
||||
|
||||
axiosMock.onGet(`${configuration.LMS_BASE_URL}/api/enrollment/v1/course/${courseId}`)
|
||||
axiosMock.onGet(trackUrl)
|
||||
.replyOnce(500, JSON.stringify({}));
|
||||
|
||||
return store.dispatch(fetchTracks(courseId)).then(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const GOT_ROLES = 'GOT_ROLES';
|
||||
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES'
|
||||
const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES';
|
||||
|
||||
export {
|
||||
GOT_ROLES,
|
||||
|
||||
@@ -35,6 +35,7 @@ const grades = (state = initialState, action) => {
|
||||
prevPage: action.prev,
|
||||
nextPage: action.next,
|
||||
showSpinner: false,
|
||||
courseId: action.courseId,
|
||||
};
|
||||
case STARTED_FETCHING_GRADES:
|
||||
return {
|
||||
|
||||
@@ -113,6 +113,7 @@ describe('grades reducer', () => {
|
||||
prevPage: expectedPrev,
|
||||
nextPage: expectedNext,
|
||||
showSpinner: false,
|
||||
courseId,
|
||||
};
|
||||
expect(grades(undefined, {
|
||||
type: GOT_GRADES,
|
||||
@@ -123,6 +124,7 @@ describe('grades reducer', () => {
|
||||
track: expectedTrack,
|
||||
cohort: expectedCohortId,
|
||||
showSpinner: true,
|
||||
courseId,
|
||||
})).toEqual(expected);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import {
|
||||
GOT_ROLES,
|
||||
ERROR_FETCHING_ROLES,
|
||||
} from '../constants/actionTypes/roles';
|
||||
GOT_ROLES,
|
||||
ERROR_FETCHING_ROLES,
|
||||
} from '../constants/actionTypes/roles';
|
||||
|
||||
const initialState = {
|
||||
canUserViewGradebook: null,
|
||||
};
|
||||
const initialState = {
|
||||
canUserViewGradebook: null,
|
||||
};
|
||||
|
||||
const roles = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case GOT_ROLES:
|
||||
return {
|
||||
...state,
|
||||
canUserViewGradebook: action.canUserViewGradebook,
|
||||
};
|
||||
case ERROR_FETCHING_ROLES:
|
||||
return {
|
||||
...state,
|
||||
canUserViewGradebook: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}};
|
||||
const roles = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case GOT_ROLES:
|
||||
return {
|
||||
...state,
|
||||
canUserViewGradebook: action.canUserViewGradebook,
|
||||
};
|
||||
case ERROR_FETCHING_ROLES:
|
||||
return {
|
||||
...state,
|
||||
canUserViewGradebook: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default roles;
|
||||
export default roles;
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('tracks reducer', () => {
|
||||
it('updates canUserViewGradebook to true', () => {
|
||||
const expected = {
|
||||
...initialState,
|
||||
canUserViewGradebook: true
|
||||
canUserViewGradebook: true,
|
||||
};
|
||||
expect(roles(undefined, {
|
||||
type: GOT_ROLES,
|
||||
@@ -27,7 +27,7 @@ describe('tracks reducer', () => {
|
||||
it('updates canUserViewGradebook to false', () => {
|
||||
const expected = {
|
||||
...initialState,
|
||||
canUserViewGradebook: false
|
||||
canUserViewGradebook: false,
|
||||
};
|
||||
expect(roles(undefined, {
|
||||
type: GOT_ROLES,
|
||||
|
||||
@@ -25,7 +25,10 @@ class LmsApiService {
|
||||
/*
|
||||
updateData is expected to be a list of objects with the keys 'user_id' (an integer),
|
||||
'usage_id' (a string) and 'grade', which is an object with the keys:
|
||||
'earned_all_override', 'possible_all_override', 'earned_graded_override', and 'possible_graded_override',
|
||||
'earned_all_override',
|
||||
'possible_all_override',
|
||||
'earned_graded_override',
|
||||
and 'possible_graded_override',
|
||||
each of which should be an integer.
|
||||
Example:
|
||||
[
|
||||
@@ -46,7 +49,7 @@ class LmsApiService {
|
||||
}
|
||||
|
||||
static fetchTracks(courseId) {
|
||||
const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}`;
|
||||
const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}?include_expired=1`;
|
||||
return apiClient.get(trackUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,56 @@ 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 trackingCategory = 'gradebook';
|
||||
|
||||
const eventsMap = {
|
||||
[GOT_ROLES]: trackPageView(action => ({
|
||||
category: trackingCategory,
|
||||
page: action.courseId,
|
||||
})),
|
||||
[GOT_GRADES]: trackEvent(action => ({
|
||||
name: 'Grades displayed or paginated',
|
||||
properties: {
|
||||
category: trackingCategory,
|
||||
courseId: action.courseId,
|
||||
track: action.track,
|
||||
cohort: action.cohort,
|
||||
prev: action.prev,
|
||||
next: action.next,
|
||||
},
|
||||
})),
|
||||
[GRADE_UPDATE_SUCCESS]: trackEvent(action => ({
|
||||
name: 'Grades Updated',
|
||||
properties: {
|
||||
category: trackingCategory,
|
||||
courseId: action.courseId,
|
||||
updatedGrades: action.payload.responseData,
|
||||
},
|
||||
})),
|
||||
[GRADE_UPDATE_FAILURE]: trackEvent(action => ({
|
||||
name: 'Grades Fail to Update',
|
||||
properties: {
|
||||
category: trackingCategory,
|
||||
courseId: action.courseId,
|
||||
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;
|
||||
|
||||
@@ -12,8 +12,6 @@ import store from './data/store';
|
||||
import FooterLogo from '../assets/edx-footer.png';
|
||||
import './App.scss';
|
||||
|
||||
var courseId = window.location.pathname.substring(1);
|
||||
|
||||
const App = () => (
|
||||
<Provider store={store}>
|
||||
<Router>
|
||||
|
||||
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