Compare commits

..

2 Commits

Author SHA1 Message Date
Rick Reilly
c5b025aef5 wip 2019-01-02 14:20:39 -05:00
Rick Reilly
0b507c558a wip 2019-01-02 14:20:21 -05:00
33 changed files with 5307 additions and 7113 deletions

View File

@@ -1,4 +1,3 @@
coverage/*
dist/
node_modules/
src/segment.js

29
Dockerfile Executable file
View File

@@ -0,0 +1,29 @@
# Copied from https://github.com/BretFisher/node-docker-good-defaults/blob/master/Dockerfile
FROM node:8.9.3
# Create app directory
RUN mkdir -p /edx/app
ARG NODE_ENV=production
ENV NODE_ENV $NODE_ENV
ARG PORT=80
ENV PORT $PORT
EXPOSE $PORT 1991
WORKDIR /edx
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./
# If you are building your code for production
# RUN npm install --only=production
RUN npm install
ENV PATH /edx/app/node_modules/.bin:$PATH
WORKDIR /edx/app
COPY . /edx/app
ENTRYPOINT npm install && npm run start

View File

@@ -1,9 +1,35 @@
npm-install-%: ## install specified % npm package
npm install $* --save-dev
shell: ## run a shell on the cookie-cutter container
docker exec -it edx.gradebook /bin/bash
build:
docker-compose build
up: ## bring up cookie-cutter container
docker-compose up
up-detached: ## bring up cookie-cutter container in detached mode
docker-compose up -d
logs: ## show logs for cookie-cutter container
docker-compose logs -f
down: ## stop and remove cookie-cutter container
docker-compose down
npm-install-%: ## install specified % npm package on the cookie-cutter container
docker exec npm install $* --save-dev
git add package.json
restart:
make down
make up
restart-detached:
make down
make up-detached
validate-no-uncommitted-package-lock-changes:
git diff --exit-code package-lock.json
test:
npm run test
docker exec -it edx.gradebook jest

View File

@@ -21,20 +21,20 @@ npm i --save @edx/gradebook
## Running the UI Standalone
To install the project please refer to the [`edX Developer Stack`](https://github.com/edx/devstack) instructions.
After cloning the repository, run `make up-detached` in the `gradebook` directory - this will build and start the `gradebook` web application in a docker container.
The web application runs on port **1994**, so when you go to `http://localhost:1994/course-v1:edX+DemoX+Demo_Course` you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
The web application runs on port **1991**, so when you go to `http://localhost:1991/course-v1:edX+DemoX+Demo_Course` you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
If you don't, you can see the log messages for the docker container by executing `make gradebook-logs` in the `devstack` directory.
If you don't, you can see the log messages for the docker container by executing `make logs` in the `gradebook` directory.
Note that starting the container executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
Note that `make up-detached` executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
## Configuring for local use in edx-platform
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
Assuming you've got the UI running at `http://localhost:1991`, you can configure the LMS in edx-platform
to point to your local gradebook from the instructor dashboard by putting this settings in `lms/env/private.py`:
```
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
WRITABLE_GRADEBOOK_URL = 'http://localhost:1991'
```
There are also several edx-platform waffle and feature flags you'll have to enable from the Django admin:

View File

@@ -4,7 +4,6 @@ const path = require('path');
module.exports = {
entry: {
segment: path.resolve(__dirname, '../src/segment.js'),
app: path.resolve(__dirname, '../src/index.jsx'),
},
output: {

View File

@@ -12,7 +12,6 @@ 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: {
@@ -98,7 +97,7 @@ module.exports = Merge.smart(commonConfig, {
}),
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
BASE_URL: 'localhost:1994',
BASE_URL: 'localhost:1991',
LMS_BASE_URL: 'http://localhost:18000',
LOGIN_URL: 'http://localhost:18000/login',
LOGOUT_URL: 'http://localhost:18000/login',
@@ -111,21 +110,6 @@ module.exports = Merge.smart(commonConfig, {
FEATURE_FLAGS: {},
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
CSRF_COOKIE_NAME: 'csrftoken',
SITE_NAME: 'edX',
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
SUPPORT_URL: 'http://localhost:18000/support',
CONTACT_URL: 'http://localhost:18000/contact',
OPEN_SOURCE_URL: 'http://localhost:18000/openedx',
TERMS_OF_SERVICE_URL: 'http://localhost:18000/terms-of-service',
PRIVACY_POLICY_URL: 'http://localhost:18000/privacy-policy',
FACEBOOK_URL: 'https://www.facebook.com',
TWITTER_URL: 'https://twitter.com',
YOU_TUBE_URL: 'https://www.youtube.com',
LINKED_IN_URL: 'https://www.linkedin.com',
GOOGLE_PLUS_URL: 'https://plus.google.com',
REDDIT_URL: 'https://www.reddit.com',
APPLE_APP_STORE_URL: 'https://www.apple.com/ios/app-store/',
GOOGLE_PLAY_URL: 'https://play.google.com/store',
}),
// when the --hot option is not passed in as part of the command
// the HotModuleReplacementPlugin has to be specified in the Webpack configuration
@@ -136,7 +120,7 @@ module.exports = Merge.smart(commonConfig, {
// reloading.
devServer: {
host: '0.0.0.0',
port: 1994,
port: 1991,
historyApiFallback: true,
hot: true,
inline: true,

View File

@@ -126,21 +126,6 @@ module.exports = Merge.smart(commonConfig, {
CSRF_COOKIE_NAME: 'csrftoken',
NEW_RELIC_APP_ID: null,
NEW_RELIC_LICENSE_KEY: null,
SITE_NAME: null,
MARKETING_SITE_BASE_URL: null,
SUPPORT_URL: null,
CONTACT_URL: null,
OPEN_SOURCE_URL: null,
TERMS_OF_SERVICE_URL: null,
PRIVACY_POLICY_URL: null,
FACEBOOK_URL: null,
TWITTER_URL: null,
YOU_TUBE_URL: null,
LINKED_IN_URL: null,
GOOGLE_PLUS_URL: null,
REDDIT_URL: null,
APPLE_APP_STORE_URL: null,
GOOGLE_PLAY_URL: null,
}),
],
});

20
docker-compose.yml Executable file
View File

@@ -0,0 +1,20 @@
version: "2"
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
- NODE_ENV=development
container_name: edx.gradebook
image: edxops/front-end-cookie-cutter:latest
volumes:
- .:/edx/app:delegated
- notused:/edx/app/node_modules
ports:
- "1991:1991"
environment:
- NODE_ENV=development
volumes:
notused:

View File

@@ -1,46 +0,0 @@
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.

View File

@@ -1,8 +0,0 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
nick: grbk
oeps: {}
owner: edx/educator-neem
openedx-release: {ref: master}
track-pulls: true

11311
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,69 +25,66 @@
},
"dependencies": {
"@edx/edx-bootstrap": "^0.4.3",
"@edx/frontend-auth": "^1.3.0",
"@edx/frontend-component-footer": "^1.0.0",
"@edx/paragon": "^3.8.3",
"@redux-beacon/segment": "^1.0.0",
"@edx/frontend-auth": "^1.2.1",
"@edx/paragon": "^3.7.2",
"babel-polyfill": "^6.26.0",
"classnames": "^2.2.6",
"email-prop-type": "^1.1.7",
"classnames": "^2.2.5",
"email-prop-type": "^1.1.5",
"font-awesome": "^4.7.0",
"history": "^4.7.2",
"prop-types": "^15.6.2",
"prop-types": "^15.5.10",
"query-string": "^5.1.1",
"react": "^16.7.0",
"react-dom": "^16.7.0",
"react-redux": "^5.1.1",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-redux": "^5.0.7",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"react-router-redux": "^5.0.0-alpha.9",
"redux": "^3.7.2",
"redux-devtools-extension": "^2.13.7",
"redux-beacon": "^2.0.3",
"redux-devtools-extension": "^2.13.2",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"whatwg-fetch": "^2.0.4"
"redux-thunk": "^2.2.0",
"whatwg-fetch": "^2.0.3"
},
"devDependencies": {
"autoprefixer": "^9.4.5",
"axios-mock-adapter": "^1.16.0",
"autoprefixer": "^9.4.2",
"axios-mock-adapter": "^1.15.0",
"babel-cli": "^6.26.0",
"babel-eslint": "^8.2.6",
"babel-jest": "^22.4.4",
"babel-loader": "^7.1.5",
"babel-eslint": "^8.2.2",
"babel-jest": "^22.4.0",
"babel-loader": "^7.1.2",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.7.0",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"codecov": "^3.1.0",
"css-loader": "^0.28.11",
"enzyme": "^3.8.0",
"enzyme-adapter-react-16": "^1.7.1",
"es-check": "^2.3.0",
"eslint-config-edx": "^4.0.4",
"fetch-mock": "^6.5.2",
"codecov": "^3.0.0",
"css-loader": "^0.28.9",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"es-check": "^2.0.2",
"eslint-config-edx": "^4.0.3",
"fetch-mock": "^6.3.0",
"file-loader": "^1.1.9",
"html-webpack-harddisk-plugin": "^0.2.0",
"html-webpack-plugin": "^3.2.0",
"html-webpack-plugin": "^3.0.3",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0",
"image-webpack-loader": "^4.2.0",
"jest": "^22.4.4",
"jest": "^22.4.0",
"mini-css-extract-plugin": "^0.4.0",
"node-sass": "^4.11.0",
"node-sass": "^4.7.2",
"postcss-loader": "^3.0.0",
"react-dev-utils": "^5.0.3",
"react-test-renderer": "^16.7.0",
"redux-mock-store": "^1.5.3",
"react-dev-utils": "^5.0.0",
"react-test-renderer": "^16.2.0",
"redux-mock-store": "^1.5.1",
"sass-loader": "^6.0.6",
"semantic-release": "^15.13.3",
"style-loader": "^0.20.3",
"travis-deploy-once": "^5.0.11",
"webpack": "^4.28.4",
"webpack-cli": "^3.2.1",
"webpack-dev-server": "^3.1.14",
"webpack-merge": "^4.2.1"
"semantic-release": "^15.10.7",
"style-loader": "^0.20.2",
"travis-deploy-once": "^5.0.9",
"webpack": "^4.25.1",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.0",
"webpack-merge": "^4.1.1"
},
"jest": {
"setupFiles": [
@@ -108,7 +105,6 @@
],
"transformIgnorePatterns": [
"/node_modules/(?!(@edx/paragon)/).*/"
],
"testURL": "http://localhost"
]
}
}

View File

@@ -5,7 +5,8 @@ $fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
@import "~@edx/paragon/src/SearchField/SearchField";
@import "~@edx/frontend-component-footer/src/lib/scss/site-footer";
@import "./components/Gradebook/gradebook";
@import "./components/Gradebook/gradebook";
@import "./components/Gradebook/footer";
@import "./components/Header/header";

View File

@@ -0,0 +1,122 @@
import React from 'react';
import { Hyperlink, Icon } from '@edx/paragon';
import EdXFooterLogo from '../../../assets/edx-footer.png';
export default function Footer() {
function renderLogo() {
return (
<img src={EdXFooterLogo} alt="edX logo" />
);
}
return (
<footer
role="contentinfo"
aria-label="Page Footer"
className="footer d-flex justify-content-center border-top py-3 px-4"
>
<div className="max-width-1180 d-grid">
<div className="area-1">
<Hyperlink destination="https://www.edx.org/" content={renderLogo()} aria-label="edX Home" />
</div>
<div className="area-2">
<h2>edX</h2>
<ul className="list-unstyled p-0 m-0">
<li><a href="https://www.edx.org/about-us">About</a></li>
<li><a href="https://www.edx.org/enterprise">edX for Business</a></li>
<li><a href="https://www.edx.org/affiliate-program">Affiliates</a></li>
<li><a href="http://open.edx.org">Open edX</a></li>
<li><a href="https://www.edx.org/careers">Careers</a></li>
<li><a href="https://www.edx.org/news-announcements">News</a></li>
</ul>
</div>
<div className="area-3">
<h2>Legal</h2>
<ul className="list-unstyled p-0 m-0">
<li><a href="https://www.edx.org/edx-terms-service">Terms of Service &amp; Honor Code</a></li>
<li><a href="https://www.edx.org/edx-privacy-policy">Privacy Policy</a></li>
<li><a href="https://www.edx.org/accessibility">Accessibility Policy</a></li>
<li><a href="https://www.edx.org/trademarks">Trademark Policy</a></li>
<li><a href="https://www.edx.org/sitemap">Sitemap</a></li>
</ul>
</div>
<div className="area-4">
<h2>Connect</h2>
<ul className="list-unstyled p-0 m-0">
<li><a href="https://www.edx.org/blog">Blog</a></li>
<li><a href="https://courses.edx.org/support/contact_us">Contact Us</a></li>
<li><a href="https://support.edx.org">Help Center</a></li>
<li><a href="https://www.edx.org/media-kit">Media Kit</a></li>
<li><a href="https://www.edx.org/donate">Donate</a></li>
</ul>
</div>
<div className="area-5">
<ul
className="d-flex flex-row justify-content-between list-unstyled max-width-222 p-0 mb-4"
>
{/* TODO: Use Paragon HyperLink with Icon. */}
{/* Would need to add rel to paragon if we still need it. */}
<li>
<a href="http://www.facebook.com/EdxOnline" title="Facebook" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-facebook-square', 'fa-2x']} screenReaderText="Like edX on Facebook" />
</a>
</li>
<li>
<a href="https://twitter.com/edXOnline" title="Twitter" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-twitter-square', 'fa-2x']} screenReaderText="Follow edX on Twitter" />
</a>
</li>
<li>
<a href="https://www.youtube.com/user/edxonline" title="Youtube" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-youtube-square', 'fa-2x']} screenReaderText="Subscribe to the edX YouTube channel" />
</a>
</li>
<li>
<a href="https://www.linkedin.com/company/edx" title="LinkedIn" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-linkedin-square', 'fa-2x']} screenReaderText="Follow edX on LinkedIn" />
</a>
</li>
<li>
<a href="https://plus.google.com/+edXOnline" title="Google+" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-google-plus-square', 'fa-2x']} screenReaderText="Follow edX on Google+" />
</a>
</li>
<li>
<a href="https://www.reddit.com/r/edx" title="Reddit" rel="noopener noreferrer" target="_blank">
<Icon className={['fa', 'fa-reddit-square', 'fa-2x']} screenReaderText="Subscribe to the edX subreddit" />
</a>
</li>
</ul>
<ul className="d-flex flex-row justify-content-between list-unstyled max-width-264 p-0 mb-5">
<li>
<a href="https://itunes.apple.com/us/app/edx/id945480667?mt=8" rel="noopener noreferrer" target="_blank">
<img
className="max-height-39"
alt="Download the edX mobile app from the Apple App Store"
src="https://prod-edxapp.edx-cdn.org/static/images/app/app_store_badge_135x40.d0558d910630.svg"
/>
</a>
</li>
<li>
<a href="https://play.google.com/store/apps/details?id=org.edx.mobile" rel="noopener noreferrer" target="_blank">
<img
className="max-height-39"
alt="Download the edX mobile app from Google Play"
src="https://prod-edxapp.edx-cdn.org/static/images/app/google_play_badge_45.6ea466e328da.png"
/>
</a>
</li>
</ul>
<p>
© 2012{(new Date().getFullYear())} edX Inc.
<br />
EdX, Open edX, and MicroMasters are registered trademarks of edX Inc.
| 粤ICP备17044299号-2
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,165 @@
.max-width-222 {
max-width: 222px;
}
.max-width-264 {
max-width: 264px;
}
.max-width-1180 {
max-width: 1180px;
}
.max-height-39 {
max-height: 39px;
}
.d-grid {
display: grid;
}
$gray-footer: #fcfcfc;
$border-1: 1px solid $gray-200;
.footer {
background-color: $gray-footer;
.area-1 {
grid-column: 1;
grid-row: 1;
border-bottom: $border-1;
padding-bottom: 1rem;
}
.area-2 {
grid-column: 1;
grid-row: 2;
border-bottom: $border-1;
padding: 1rem 0;
}
.area-3 {
grid-column: 1;
grid-row: 3;
border-bottom: $border-1;
padding: 1rem 0;
}
.area-4 {
grid-column: 1;
grid-row: 4;
border-bottom: $border-1;
padding: 1rem 0;
}
.area-5 {
grid-column: 1;
grid-row: 5;
padding: 1rem 0;
}
@media only screen and (min-width: 717px) {
.area-1 {
grid-column: 1 / span 2;
grid-row: 1;
border-bottom: none;
padding: 1rem 0;
}
.area-2 {
grid-column: 1;
grid-row: 2;
}
.area-3 {
grid-column: 1;
grid-row: 3;
}
.area-4 {
grid-column: 1;
grid-row: 4;
border-bottom: none;
}
.area-5 {
grid-column: 2;
grid-row: 2 / span 3;
border-left: $border-1;
padding-left: 1rem;
margin-left: 1rem;
}
}
@media only screen and (min-width: 870px) {
.area-1 {
grid-column: 1;
grid-row: 1 / span 3;
border-right: $border-1;
padding-right: 1rem;
margin-right: 1rem;
}
.area-2 {
grid-column: 2;
grid-row: 1;
border-bottom: none;
border-right: $border-1;
padding-right: 1rem;
margin-right: 1rem;
}
.area-3 {
grid-column: 3;
grid-row: 1;
border-bottom: none;
border-right: $border-1;
padding-right: 1rem;
margin-right: 1rem;
}
.area-4 {
grid-column: 4;
grid-row: 1;
}
.area-5 {
grid-column: 2 / span 3;
grid-row: 2;
border: none;
margin-left: 0;
padding-left: 0;
}
}
@media only screen and (min-width: 1188px) {
.area-1 {
grid-column: 1 / span 1;
grid-row: 1;
}
.area-2 {
grid-column: 2;
grid-row: 1;
}
.area-3 {
grid-column: 3;
grid-row: 1;
}
.area-4 {
grid-column: 4;
grid-row: 1;
border-right: $border-1;
padding-right: 1rem;
margin-right: 1rem;
}
.area-5 {
grid-column: 5 / span 1;
grid-row: 1;
max-width: 372px;
}
}
}

View File

@@ -68,10 +68,6 @@
padding-left: 170px;
}
.table tbody th {
font-weight: normal;
}
.link-style {
color: #0075b4;
&:hover, &:focus {

View File

@@ -10,7 +10,6 @@ import {
} from '@edx/paragon';
import queryString from 'query-string';
import { configuration } from '../../config';
import PageButtons from '../PageButtons';
const DECIMAL_PRECISION = 2;
@@ -176,7 +175,7 @@ export default class Gradebook extends React.Component {
return 'Tracks';
};
roundGrade = percent => parseFloat((percent || 0).toFixed(DECIMAL_PRECISION));
roundGrade = percent => parseFloat(percent.toFixed(DECIMAL_PRECISION));
formatter = {
percent: (entries, areGradesFrozen) => entries.map((entry) => {
@@ -244,7 +243,7 @@ export default class Gradebook extends React.Component {
href={this.lmsInstructorDashboardUrl(this.props.match.params.courseId)}
className="mb-3"
>
<span aria-hidden="true">{'<< '}</span> {'Back to Dashboard'}
{'<< Back to Dashboard'}
</a>
<h1>Gradebook</h1>
<h3> {this.props.match.params.courseId}</h3>
@@ -253,16 +252,16 @@ export default class Gradebook extends React.Component {
The grades for this course are now frozen. Editing of grades is no longer allowed.
</div>
}
{ (this.props.canUserViewGradebook === false) &&
{ !this.props.canUserViewGradebook &&
<div className="alert alert-warning" role="alert" >
You are not authorized to view the gradebook for this course.
You are not authorized to view the gradebook for this course. If you have a global role, please enroll in this course and try again.
</div>
}
<hr />
<div className="d-flex justify-content-between" >
<div>
<div role="radiogroup" aria-labelledby="score-view-group-label">
<span id="score-view-group-label">Score View:</span>
<div>
Score View:
<span>
<input
id="score-view-percent"
@@ -294,7 +293,6 @@ export default class Gradebook extends React.Component {
</span>
<InputSelect
name="assignment-types"
ariaLabel="Assignment Types"
value={this.mapSelectedTrackEntry(this.props.selectedAssignmentType)}
options={this.mapAssignmentTypeEntries(this.props.assignmnetTypes)}
onChange={this.updateAssignmentTypes}
@@ -307,7 +305,6 @@ export default class Gradebook extends React.Component {
</span>
<InputSelect
name="Tracks"
ariaLabel="Tracks"
disabled={this.props.tracks.length === 0}
value={this.mapSelectedTrackEntry(this.props.selectedTrack)}
options={this.mapTracksEntries(this.props.tracks)}
@@ -315,7 +312,6 @@ export default class Gradebook extends React.Component {
/>
<InputSelect
name="Cohorts"
ariaLabel="Cohorts"
disabled={this.props.cohorts.length === 0}
value={this.mapSelectedCohortEntry(this.props.selectedCohort)}
options={this.mapCohortsEntries(this.props.cohorts)}
@@ -333,6 +329,21 @@ export default class Gradebook extends React.Component {
onClear={() => this.props.getUserGrades(this.props.match.params.courseId, this.props.selectedCohort, this.props.selectedTrack)}
value={this.state.filterValue}
/>
<div className="d-flex justify-content-end" style={{ marginTop: '20px' }}>
<Button
label="Previous"
buttonType="primary"
style={{ visibility: (!this.props.prevPage ? 'hidden' : 'visible') }}
onClick={() => this.props.getPrevNextGrades(this.props.prevPage, this.props.selectedCohort, this.props.selectedTrack)}
/>
<div style={{ width: '10px' }} />
<Button
label="Next"
buttonType="primary"
style={{ visibility: (!this.props.nextPage ? 'hidden' : 'visible') }}
onClick={() => this.props.getPrevNextGrades(this.props.nextPage, this.props.selectedCohort, this.props.selectedTrack)}
/>
</div>
</div>
</div>
<br />
@@ -342,7 +353,6 @@ export default class Gradebook extends React.Component {
onClose={() => this.props.updateBanner(false)}
open={this.props.showSuccess}
/>
{PageButtons(this.props)}
<div className="gbook">
<Table
columns={this.props.headings}
@@ -350,10 +360,8 @@ export default class Gradebook extends React.Component {
tableSortable
defaultSortDirection="asc"
defaultSortedColumn="username"
rowHeaderColumnKey="username"
/>
</div>
{PageButtons(this.props)}
<Modal
open={this.state.modalOpen}
title="Edit Grades"

View File

@@ -0,0 +1,24 @@
.color-gray-dark {
color: #767676;
}
.weight-bold {
font-weight: 600;
}
.size-16 {
font-size: 16px;
}
.border-bottom-blue {
border-bottom: 1px solid #0075b4;
}
.border-bottom-gray {
border-bottom: 1px solid #e7e7e7;
}
.nav-link::after {
content: '\00BB';
padding-left: 4px;
}

View File

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Hyperlink } from '@edx/paragon';
import { Hyperlink, Icon } from '@edx/paragon';
import { configuration } from '../../config';
import EdxLogo from '../../../assets/edx-sm.png';
@@ -24,6 +25,15 @@ export default class Header extends React.Component {
<Hyperlink content={this.renderLogo()} destination="https://www.edx.org" />
<div />
</header>
{this.state.mobileNavOpen &&
<nav className="d-flex flex-column weight-bold size-16">
<a href="https://www.google.com" className="nav-link border-bottom-gray">Rick</a>
<a href="https://www.google.com" className="nav-link border-bottom-gray">Alex</a>
<a href="https://www.google.com" className="nav-link border-bottom-gray">Jasen</a>
<a href="https://www.google.com" className="nav-link border-bottom-gray">Doug</a>
<a href="https://www.google.com" className="nav-link border-bottom-gray">Simon</a>
</nav>
}
</div>
);
}

View File

@@ -1,36 +0,0 @@
import renderer from 'react-test-renderer';
import PageButtons from '.';
const createInput = function createInput(prevPage, nextPage) {
return {
prevPage,
nextPage,
selectedTrack: 't',
selectedCohort: 'c',
getPrevNextGrades() {},
};
};
describe('PageButtons', () => {
const assertPageButtonsSnapshot = function assertPageButtonsSnapshot(input) {
const pb = renderer.create(PageButtons(input));
const tree = pb.toJSON();
expect(tree).toMatchSnapshot();
};
it('prev null, next null', () => {
assertPageButtonsSnapshot(createInput(null, null));
});
it('prev null, next not null', () => {
assertPageButtonsSnapshot(createInput(null, 'np'));
});
it('prev not null, next null', () => {
assertPageButtonsSnapshot(createInput('pp', null));
});
it('prev not null, next not null', () => {
assertPageButtonsSnapshot(createInput('pp', 'np'));
});
});

View File

@@ -1,169 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PageButtons prev not null, next not null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;
exports[`PageButtons prev not null, next null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;
exports[`PageButtons prev null, next not null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-primary"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;
exports[`PageButtons prev null, next null 1`] = `
<div
className="d-flex justify-content-center"
style={
Object {
"paddingBottom": "20px",
}
}
>
<button
className="btn btn-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Previous Page
</button>
<button
className="btn btn-primary"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
style={
Object {
"margin": "20px",
}
}
type="button"
>
Next Page
</button>
</div>
`;

View File

@@ -1,30 +0,0 @@
import React from 'react';
import { Button } from '@edx/paragon';
export default function PageButtons({
prevPage, nextPage, selectedTrack, selectedCohort, getPrevNextGrades, match,
}) {
return (
<div
className="d-flex justify-content-center"
style={{ paddingBottom: '20px' }}
>
<Button
label="Previous Page"
style={{ margin: '20px' }}
buttonType="primary"
disabled={!prevPage}
onClick={() => getPrevNextGrades(prevPage, selectedCohort, selectedTrack, match.params.courseId)}
/>
<Button
label="Next Page"
style={{ margin: '20px' }}
buttonType="primary"
disabled={!nextPage}
onClick={() => getPrevNextGrades(nextPage, selectedCohort, selectedTrack, match.params.courseId)}
/>
</div>
);
}

View File

@@ -30,17 +30,18 @@ const mapStateToProps = state => (
assignmnetTypes: 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) {
function shouldShowSpinner (state) {
if (state.roles.canUserViewGradebook === true){
return state.grades.showSpinner;
} else if (state.roles.canUserViewGradebook === false) {
} else if (state.roles.canUserViewGradebook === false){
return false;
} // canUserViewGradebook === null
return true;
} else { // canUserViewGradebook === null
return true;
}
}
const mapDispatchToProps = dispatch => (
@@ -51,8 +52,8 @@ const mapDispatchToProps = dispatch => (
searchForUser: (courseId, searchText, cohort, track) => {
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, false));
},
getPrevNextGrades: (endpoint, cohort, track, courseId) => {
dispatch(fetchPrevNextGrades(endpoint, cohort, track, courseId));
getPrevNextGrades: (endpoint, cohort, track) => {
dispatch(fetchPrevNextGrades(endpoint, cohort, track));
},
getCohorts: (courseId) => {
dispatch(fetchCohorts(courseId));

View File

@@ -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, courseId) => ({
const gotGrades = (grades, cohort, track, headings, prev, next) => ({
type: GOT_GRADES,
grades,
cohort,
@@ -40,18 +40,15 @@ const gotGrades = (grades, cohort, track, headings, prev, next, courseId) => ({
headings,
prev,
next,
courseId,
});
const gradeUpdateRequest = () => ({ type: GRADE_UPDATE_REQUEST });
const gradeUpdateSuccess = (courseId, responseData) => ({
const gradeUpdateSuccess = responseData => ({
type: GRADE_UPDATE_SUCCESS,
courseId,
payload: { responseData },
});
const gradeUpdateFailure = (courseId, error) => ({
const gradeUpdateFailure = error => ({
type: GRADE_UPDATE_FAILURE,
courseId,
payload: { error },
});
@@ -80,7 +77,6 @@ const fetchGrades = (courseId, cohort, track, showSuccess) => (
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
data.previous,
data.next,
courseId,
));
dispatch(finishedFetchingGrades());
dispatch(updateBanner(!!showSuccess));
@@ -104,7 +100,6 @@ const fetchMatchingUserGrades = (courseId, searchText, cohort, track, showSucces
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
data.previous,
data.next,
courseId,
));
dispatch(finishedFetchingGrades());
dispatch(updateBanner(showSuccess));
@@ -115,7 +110,7 @@ const fetchMatchingUserGrades = (courseId, searchText, cohort, track, showSucces
}
);
const fetchPrevNextGrades = (endpoint, cohort, track, courseId) => (
const fetchPrevNextGrades = (endpoint, cohort, track) => (
(dispatch) => {
dispatch(startedFetchingGrades());
return apiClient.get(endpoint)
@@ -128,7 +123,6 @@ const fetchPrevNextGrades = (endpoint, cohort, track, courseId) => (
headingMapper(defaultAssignmentFilter)(dispatch, data.results[0]),
data.previous,
data.next,
courseId,
));
dispatch(finishedFetchingGrades());
})
@@ -145,11 +139,11 @@ const updateGrades = (courseId, updateData, searchText, cohort, track) => (
return LmsApiService.updateGradebookData(courseId, updateData)
.then(response => response.data)
.then((data) => {
dispatch(gradeUpdateSuccess(courseId, data));
dispatch(gradeUpdateSuccess(data));
dispatch(fetchMatchingUserGrades(courseId, searchText, cohort, track, true));
})
.catch((error) => {
dispatch(gradeUpdateFailure(courseId, error));
dispatch(gradeUpdateFailure(error));
});
}
);

View File

@@ -110,7 +110,6 @@ describe('actions', () => {
],
prev: responseData.previous,
next: responseData.next,
courseId,
},
{ type: FINISHED_FETCHING_GRADES },
{ type: UPDATE_BANNER, showSuccess: false },

View File

@@ -10,11 +10,7 @@ import LmsApiService from '../services/LmsApiService';
const allowedRoles = ['staff', 'instructor', 'support'];
const gotRoles = (canUserViewGradebook, courseId) => ({
type: GOT_ROLES,
canUserViewGradebook,
courseId,
});
const gotRoles = canUserViewGradebook => ({ type: GOT_ROLES, canUserViewGradebook });
const errorFetchingRoles = () => ({ type: ERROR_FETCHING_ROLES });
const getRoles = (courseId, urlQuery) => (
@@ -24,7 +20,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, courseId));
dispatch(gotRoles(canUserViewGradebook));
if (canUserViewGradebook) {
dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track));
dispatch(fetchTracks(courseId));

View File

@@ -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, courseId: course1Id },
{ type: GOT_ROLES, canUserViewGradebook: true },
{ 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, courseId: course1Id },
{ type: GOT_ROLES, canUserViewGradebook: true },
{ 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,14 +84,12 @@ describe('actions', () => {
it('dispatches got_roles action and no other actions after fetching role that disallows gradebook', () => {
const expectedActions = [
{
type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id,
},
{ type: GOT_ROLES, canUserViewGradebook: false },
];
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);
@@ -100,12 +98,12 @@ describe('actions', () => {
it('dispatches got_roles action and no other actions after fetching empty roles', () => {
const expectedActions = [
{ type: GOT_ROLES, canUserViewGradebook: false, courseId: course1Id },
{ type: GOT_ROLES, canUserViewGradebook: false },
];
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);
@@ -114,7 +112,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, courseId: course1Id },
{ type: GOT_ROLES, canUserViewGradebook: true },
{ type: STARTED_FETCHING_GRADES },
{ type: STARTED_FETCHING_TRACKS },
{ type: STARTED_FETCHING_COHORTS },
@@ -123,7 +121,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);

View File

@@ -35,7 +35,6 @@ const grades = (state = initialState, action) => {
prevPage: action.prev,
nextPage: action.next,
showSpinner: false,
courseId: action.courseId,
};
case STARTED_FETCHING_GRADES:
return {

View File

@@ -113,7 +113,6 @@ describe('grades reducer', () => {
prevPage: expectedPrev,
nextPage: expectedNext,
showSpinner: false,
courseId,
};
expect(grades(undefined, {
type: GOT_GRADES,
@@ -124,7 +123,6 @@ describe('grades reducer', () => {
track: expectedTrack,
cohort: expectedCohortId,
showSpinner: true,
courseId,
})).toEqual(expected);
});

View File

@@ -2,56 +2,14 @@ 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, segmentMiddleware)),
composeWithDevTools(applyMiddleware(thunkMiddleware, loggerMiddleware)),
);
export default store;

View File

@@ -3,13 +3,12 @@ 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 SiteFooter from '@edx/frontend-component-footer';
import apiClient from './data/apiClient';
import Footer from './components/Gradebook/footer';
import GradebookPage from './containers/GradebookPage';
import Header from './components/Header';
import store from './data/store';
import FooterLogo from '../assets/edx-footer.png';
import './App.scss';
var courseId = window.location.pathname.substring(1);
@@ -24,24 +23,7 @@ const App = () => (
<Route exact path="/:courseId" component={GradebookPage} />
</Switch>
</main>
<SiteFooter
siteName={process.env.SITE_NAME}
siteLogo={FooterLogo}
marketingSiteBaseUrl={process.env.MARKETING_SITE_BASE_URL}
supportUrl={process.env.SUPPORT_URL}
contactUrl={process.env.CONTACT_URL}
openSourceUrl={process.env.OPEN_SOURCE_URL}
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
facebookUrl={process.env.FACEBOOK_URL}
twitterUrl={process.env.TWITTER_URL}
youTubeUrl={process.env.YOU_TUBE_URL}
linkedInUrl={process.env.LINKED_IN_URL}
googlePlusUrl={process.env.GOOGLE_PLUS_URL}
redditUrl={process.env.REDDIT_URL}
appleAppStoreUrl={process.env.APPLE_APP_STORE_URL}
googlePlayUrl={process.env.GOOGLE_PLAY_URL}
/>
<Footer />
</div>
</Router>
</Provider>

View File

@@ -1,85 +0,0 @@
// 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);
}());