Compare commits

..

10 Commits

Author SHA1 Message Date
ihor-romaniuk
88d6ecd405 feat: add a simple validation to the adjusted grade field 2023-01-27 10:27:48 -05:00
ihor-romaniuk
3e0879e66e fix: add an asterisk to the 'Email' name of column 2022-12-02 10:38:42 -05:00
Michael Vlasov
1eb08d7ea0 fix: gradebook table rtl tooltip position (#229)
Co-authored-by: vlasovmichael <mykhailo.vlasov@raccoongang.com>
2022-02-11 13:23:59 -05:00
Michael Vlasov
7aef4ca9a7 feat: gradebook main container classname (#228)
Co-authored-by: vlasovmichael <mykhailo.vlasov@raccoongang.com>
2022-02-11 11:30:38 -05:00
Sofiane Bébert
edbbe42f3b Merge pull request #222 from eduNEXT/Jhony/gradebook_header_override_backport
feat: enabling header override
2021-12-20 17:20:03 +00:00
Jhony Avella
3ee7e40ecd feat: enabling header override
feat: new versions of package.json and lock

fix: tests after adding header component

Fixing tests

change package files

Signed-off-by: Jhony Avella <jhony.avella@edunext.co>
2021-12-20 12:10:27 -05:00
Sofiane Bébert
310d0844fd Merge pull request #221 from eduNEXT/Jhony/fix_course_id_extraction_backport
fix: build the course_id properly when MFE is deployed in a subdirectory
2021-12-20 17:08:42 +00:00
Jhony Avella
7fd06c2373 fix: build the course id properly when the MFE is deployed in a subdirectory 2021-12-20 11:58:26 -05:00
Sofiane Bébert
45b922d6e6 Merge pull request #218 from regisb/regisb/transifex
feat: add compatibility with transifex
2021-12-06 14:43:21 +00:00
Régis Behmo
12574a7561 feat: add compatibility with transifex
Now that the frontend-app-gradebook resource was added to Transifex, we can
start pulling strings fromt there. For now, the project contains very few
translated string.

Strings were pulled by running: make pull_translations

This is for https://github.com/openedx/build-test-release-wg/issues/107
2021-11-30 11:13:05 +01:00
85 changed files with 16650 additions and 47884 deletions

2
.env
View File

@@ -30,5 +30,3 @@ ENTERPRISE_MARKETING_URL=''
ENTERPRISE_MARKETING_UTM_SOURCE=''
ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
APP_ID=''
MFE_CONFIG_API_URL=''

View File

@@ -37,5 +37,3 @@ ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
APP_ID=''
MFE_CONFIG_API_URL=''

2
.github/CODEOWNERS vendored
View File

@@ -3,4 +3,4 @@
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence, they will
# be requested for review when someone opens a pull request.
* @openedx/content-aurora
* @edx/masters-devs-gta

View File

@@ -26,4 +26,4 @@ Collectively, these should be completed by reviewers of this PR:
- [ ] I've tested the new functionality
FYI: @openedx/content-aurora
FYI: @edx/masters-devs-gta

View File

@@ -1,19 +0,0 @@
# Run the workflow that adds new tickets that are either:
# - labelled "DEPR"
# - title starts with "[DEPR]"
# - body starts with "Proposal Date" (this is the first template field)
# to the org-wide DEPR project board
name: Add newly created DEPR issues to the DEPR project board
on:
issues:
types: [opened]
jobs:
routeissue:
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -1,64 +0,0 @@
name: node_js CI
on:
push:
branches:
- master
pull_request:
branches:
- '**'
jobs:
test:
runs-on: ubuntu-20.04
strategy:
matrix:
node: [16]
npm: [8.5.x]
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup Nodejs
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- name: Install npm 8.5.x
run: npm install -g npm@${{ matrix.npm }}
- name: Install dependencies
run: npm ci
- name: Unit Tests
run: npm run test
- name: Validate Package Lock
run: make validate-no-uncommitted-package-lock-changes
- name: Run Lint
run: npm run lint
- name: Run Test
run: npm run test
- name: Run Build
run: npm run build
- name: Run Coverage
uses: codecov/codecov-action@v2
- name: Send failure notification
if: ${{ failure() }}
uses: dawidd6/action-send-mail@v3
with:
server_address: email-smtp.us-east-1.amazonaws.com
server_port: 465
username: ${{secrets.EDX_SMTP_USERNAME}}
password: ${{secrets.EDX_SMTP_PASSWORD}}
subject: CI workflow failed in ${{github.repository}}
to: masters-grades@edx.org
from: github-actions <github-actions@edx.org>
body: CI workflow in ${{github.repository}} failed! For details see "github.com/${{
github.repository }}/actions/runs/${{ github.run_id }}"

View File

@@ -1,13 +0,0 @@
#check package-lock file version
name: Lockfile Version check
on:
push:
branches:
- master
pull_request:
jobs:
version-check:
uses: edx/.github/.github/workflows/lockfileversion-check.yml@master

View File

@@ -1,33 +0,0 @@
name: Release CI
on:
push:
tags:
- '*'
jobs:
release:
name: Release
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: 12
- name: Install dependencies
run: npm ci
- name: Create Build
run: npm run build
- name: Release Package
env:
GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.SEMANTIC_RELEASE_NPM_TOKEN }}
run: npm semantic-release

View File

@@ -1,6 +1,7 @@
.eslintignore
.eslintrc.json
.gitignore
.travis.yml
docker-compose.yml
Dockerfile
Makefile

28
.travis.yml Executable file
View File

@@ -0,0 +1,28 @@
language: node_js
node_js: 12
notifications:
email:
recipients:
- masters-grades@edx.org
on_success: never
on_failure: always
webhooks: https://www.travisbuddy.com/
on_success: never
before_install:
- npm install -g greenkeeper-lockfile@1.14.0
install:
- npm ci
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:
- npm run travis-deploy-once "npm run semantic-release"
- npm run coveralls
env:
global:
- secure: bBLQZVw1aVUxB7GFNXGrdKeztyFrCCJusVgFcSuej9S4qmj9/jrVsEc9dEcH+BMS+b49+SvILoxzd6ZYLaRygQLzevnO1/dX596DeCKVK48PTTZRsNyafaSMCkxNKqEmRcA9hYL52xJJ5GpKo7ViWsFy8VFgUfZEJxQi8/lYbfQ1vlXRpo2LJfJh09v85roSXdQmajyGJ1Dz6elcwUX5B+BgXmIHizJXUMfFci61xTEZmgKtfeCiwFQA5pCvVMHBQhgySqT2N3eRESzRt2jAfAdcRKBYXS0rwKymdlL1ZF349Jm8xwtqm19Fwsut21181Lnn6FmccMWhQ7man3WH1xfT0ahmHNs1KJMyZcwRJd/gDfbd6iD3LB9Pt9hEQ00Qh/m7MYeahMxTEL9bp2TyILi8cTP91jeBUHCExCdv2jRrUQEnUS5vZUYRdM8CR2DLoLmNh3APndKzwgr5U8rh6RdhbQBJp97Hb/YYVrBiP2atLJAaYPY/xEQHK/YoXelQgiZ6wHBMV+tF/L0ZRn7KyVWdkbBKWfbEjRKbEJD9WD+V7HayMR81tm5CSqlrG8mTvSy2boIGiX14GV11ZEfMj5bjb6W41BW+QGqQerZvmwk/4ywe304X85PD0OBhIYPRzeLIi0Gt6lD1aOpVxgm4M03tdgYQzCPWRPq32CB+1IA=
- secure: w1d/E+cc4+Bf017Jpp9YsKBzLSZw9sqKZGeM2tNrO6eJZbMJqfKTmfUrRw8BoLh1Z8YRkHF7RADDy3ln7XEdeAX3j9OoC3Cz0zN6iDX6TPcI461NuOIscJYb4tyFcuWm6FhgVlBAlo/BI3q+zqKwjfWuDaORpk6+haacCmvTe5V0vWhY+MYT7M+LfnKeKVzhI4magGt8jPTE21oziIFwCqCCjJc4+AmsWoWTzU0Q7Db0DZiJnLXFfXybLbkedAgJmcSgEGZCSpaZIOkX0/Lbazsz1Ky4KASfkrYT1Z5iKQ8TE3skmx1IIu+1egN8iBbdrY+NhvV24RkT+rpUvD7TBIHTrjQ5JYLe0kGjN70vG7YlKgjNSyTjkrEd7fCKpuIol3DVjBRz3tV5aCl0t/A8mIPqKyNI94MamWsExpqsxgcb9vBVno5caZvD8ZXNrGNqanB3MSoLGxZTLKif9u+AZfLnB3xtjaiJg3/BNoWaOBPlp/M6BvGIGHElwvLrAhUvl8wzrwJcQQWpmRMh0b6enr6Y7ox/mGGs7NBCT+CNKEsWeCfY4thZzgi6/GocXyqdTpXMkNSI1PDoPmi+vKafBd+7aAYbcUlJBTU6TAxyncln0tF2JF+ghTZ0v8nNzEQ9VmV4ddyoOHx6YnHvEcenWZGMROQnMCVifyDbaHpPbPI=

View File

@@ -1,9 +1,9 @@
[main]
host = https://www.transifex.com
[o:open-edx:p:edx-platform:r:frontend-app-gradebook]
[edx-platform.frontend-app-gradebook]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON
type = KEYVALUEJSON

View File

@@ -57,9 +57,9 @@ push_translations:
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by CI.
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes...
git diff --exit-code package-lock.json

View File

@@ -1,4 +1,4 @@
[![Build Status](https://api.travis-ci.com/edx/frontend-app-gradebook.svg?branch=master)](https://travis-ci.com/edx/frontend-app-gradebook)
[![Build Status](https://api.travis-ci.com/edx/frontend-app-gradebook.svg?branch=master)](https://travis-ci.com/edx/frontend-app-gradebook) [![Coveralls](https://img.shields.io/coveralls/edx/frontend-app-gradebook.svg?branch=master)](https://coveralls.io/github/edx/frontend-app-gradebook)
[![npm_version](https://img.shields.io/npm/v/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![npm_downloads](https://img.shields.io/npm/dt/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)
[![license](https://img.shields.io/npm/l/@edx/frontend-app-gradebook.svg)](@edx/frontend-app-gradebook)

77
documentation/.travis.yml.md Executable file
View File

@@ -0,0 +1,77 @@
# Travis Configuration
Your project might have different build requirements - however, this project's `.travis.yml` configuration is supposed to represent a good starting point.
## Node JS Version
The minimum `Node` and `npm` versions that edX supports is `8.9.3` and `5.5.1`, respectively.
## Caching node_modules
While [the `Travis` blog](https://blog.travis-ci.com/2016-11-21-travis-ci-now-supports-yarn) recommends
```yaml
cache:
directories:
- node_modules
```
this causes issues when testing different versions of `Node` because [`node_modules` will store the compiled native modules](https://stackoverflow.com/a/42523517/5225575).
Caching the `~/.npm` directory avoids storing these native modules.
## Notifications
This project uses a service called [`TravisBuddy`](https://www.travisbuddy.com/), which provides Travis build context within a PR via webhooks (configured only to add feedback for build failures).
![travis-buddy](https://i.imgur.com/VsR2TTs.png)
## Installing `greenkeeper-lockfile`
As explained in [the `Greenkeeper` documentation](https://greenkeeper.io/docs.html#greenkeeper-step-by-step), `Greenkeeper` is a service that keeps track of your project's dependencies, and will, for example, automatically open PRs with an updated `package.json` file when the latest version of a dependency is a major version ahead of the existing dependency version in your `package.json` file.
This automated updating is great, but `Greenkeeper` does not update your `package-lock.json` file, just your `package.json` file. This makes sense, as the only way to update the `package-lock.json` file would be to run `npm install` when building your project, using the latest `package.json`, and then committing the updated `package-lock.json` file.
This is essentially what you have to do manually when `Greenkeeper` opens a PR - `git checkout` the branch, `npm install` locally, `git commit` the `package-lock.json` changes, and then `git push` those changes to the `Greenkeeper` branch on `origin`. It's fun probably only the first time, and even then it gets old, fast.
What [`greenkeeper-lockfile`](https://github.com/greenkeeperio/greenkeeper-lockfile) does is that it automates the previous steps as part of the build process.
It will
* Check that the branch is a `Greenkeeper` branch
* Update the lockfile
* Push a commit with the updated lockfile back to the Greenkeeper branch
This is why it's important to install `greenkeeper-lockfile` in the `before_install` step, and since it's used exclusively only in the Travis Build, why it's not part of the package's dependencies.
## Scripts
Most of the `script`s are self-explanatory - you probably want to fail a build if there are linting violations, or if any tests don't pass, or if it cannot compile your files.
However, there are a couple additional `script`s that might seem less self-explanatory.
### What the heck is `make validate-no-uncommitted-package-lock-changes`?
There are only two requirements for a good `make target` name
1. Definitely make it really verbose so people can't remember what it's called
2. Definitely don't not use a double-negative
What `make validate-no-uncommitted-package-lock-changes` does is `git diff`s for any `package-lock.json` file changes in your project. It's important to remember that all build `script`s are executed in Travis *after* the `install` step (aka post-`npm install`).
This is important because `npm` uses the pinned dependencies in your `package-lock.json` file to build the `node_modules` directory. However, the dependencies defined within the `package.json` file can be modified manually, for example, to become misaligned with the dependencies defined within the `package-lock.json`. So when `npm install` executes, the `package-lock.json` file will be updated to mirror the modified `package.json` changes.
However, when these changes surface within a Travis build, this indicates differing dependency expectations between the committed `package.json` file and the `package-lock.json` file, which is a good reason to fail a build.
### What is this `npm run is-es5` check?
This project outputs production files to the `dist` folder. The `npm script`, `npm run is-es5`, checks the JavaScript files in the `dist` folder to make sure that they are `ES5`-compliant.
This check is important because `ES5` JavaScript has [greater browser compatibility](http://kangax.github.io/compat-table/es5/) than [`ES2015+`](http://kangax.github.io/compat-table/es6/) - particularly for `IE11`.
### `deploy` step
How your project deploys will probably differ between the cookie cutter and your own application.
For demonstrational purposes, the cookie cutter deploys to GitHub pages using [`Travis`'s GitHub pages configuration](https://docs.travis-ci.com/user/deployment/pages/).
Your application might deploy to an `S3` bucket or to `npm`.

View File

@@ -1,40 +0,0 @@
# CI Configuration
Your project might have different build requirements - however, this project's `.github/ci.yml` configuration is supposed to represent a good starting point.
## Node JS Version
The minimum `Node` and `npm` versions that edX supports is `8.9.3` and `5.5.1`, respectively.
## Scripts
Most of the `script`s are self-explanatory - you probably want to fail a build if there are linting violations, or if any tests don't pass, or if it cannot compile your files.
However, there are a couple additional `script`s that might seem less self-explanatory.
### What the heck is `make validate-no-uncommitted-package-lock-changes`?
There are only two requirements for a good `make target` name
1. Definitely make it really verbose so people can't remember what it's called
2. Definitely don't not use a double-negative
What `make validate-no-uncommitted-package-lock-changes` does is `git diff`s for any `package-lock.json` file changes in your project. It's important to remember that all build `script`s are executed in CI *after* the `install` step (aka post-`npm install`).
This is important because `npm` uses the pinned dependencies in your `package-lock.json` file to build the `node_modules` directory. However, the dependencies defined within the `package.json` file can be modified manually, for example, to become misaligned with the dependencies defined within the `package-lock.json`. So when `npm install` executes, the `package-lock.json` file will be updated to mirror the modified `package.json` changes.
However, when these changes surface within a CI build, this indicates differing dependency expectations between the committed `package.json` file and the `package-lock.json` file, which is a good reason to fail a build.
### What is this `npm run is-es5` check?
This project outputs production files to the `dist` folder. The `npm script`, `npm run is-es5`, checks the JavaScript files in the `dist` folder to make sure that they are `ES5`-compliant.
This check is important because `ES5` JavaScript has [greater browser compatibility](http://kangax.github.io/compat-table/es5/) than [`ES2015+`](http://kangax.github.io/compat-table/es6/) - particularly for `IE11`.
### `deploy` step
How your project deploys will probably differ between the cookie cutter and your own application.
For demonstrational purposes, the cookie cutter deploys to GitHub pages using [ GitHUb CI ].
Your application might deploy to an `S3` bucket or to `npm`.

View File

@@ -11,6 +11,5 @@ module.exports = createConfig('jest', {
coveragePathIgnorePatterns: [
'src/segment.js',
'src/postcss.config.js',
'testUtils', // don't unit test jest mocking tools
],
});

62960
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.6.0",
"version": "1.4.47",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",
@@ -8,6 +8,7 @@
},
"scripts": {
"build": "fedx-scripts webpack",
"coveralls": "cat ./coverage/lcov.info | coveralls",
"is-es5": "es-check es5 ./dist/*.js",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
@@ -16,7 +17,8 @@
"semantic-release": "semantic-release",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"watch-tests": "jest --watch"
"watch-tests": "jest --watch",
"travis-deploy-once": "travis-deploy-once"
},
"author": "edX",
"license": "AGPL-3.0",
@@ -24,17 +26,12 @@
"publishConfig": {
"access": "public"
},
"browserslist": [
"last 2 versions",
"not ie > 0",
"not ie_mob > 0"
],
"dependencies": {
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
"@edx/frontend-component-footer": "^11.1.1",
"@edx/frontend-component-header": "^3.1.1",
"@edx/frontend-platform": "2.5.0",
"@edx/paragon": "19.6.0",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-component-header": "2.2.5",
"@edx/frontend-platform": "1.9.5",
"@edx/paragon": "14.16.4",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
@@ -48,13 +45,13 @@
"enzyme-to-json": "^3.6.2",
"font-awesome": "4.7.0",
"history": "4.10.1",
"node-sass": "^4.14.1",
"prop-types": "15.7.2",
"query-string": "6.13.0",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "^6.1.0",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-intl": "^2.9.0",
"react-redux": "^7.1.1",
"react-redux": "^5.1.1",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-router-redux": "^5.0.0-alpha.9",
@@ -64,14 +61,14 @@
"redux-logger": "3.0.6",
"redux-thunk": "2.3.0",
"regenerator-runtime": "^0.13.7",
"sass": "^1.49.0",
"util": "^0.12.3",
"whatwg-fetch": "^2.0.4"
},
"devDependencies": {
"@edx/frontend-build": "9.1.1",
"@edx/frontend-build": "5.5.2",
"axios": "0.21.1",
"axios-mock-adapter": "^1.17.0",
"codecov": "^3.6.1",
"enzyme-adapter-react-16": "^1.14.0",
"es-check": "^2.3.0",
"fetch-mock": "^6.5.2",
@@ -82,6 +79,7 @@
"react-test-renderer": "^16.10.1",
"reactifex": "1.1.1",
"redux-mock-store": "^1.5.3",
"semantic-release": "^17.2.3"
"semantic-release": "^17.2.3",
"travis-deploy-once": "^5.0.11"
}
}

View File

@@ -10,11 +10,9 @@ import { routePath } from 'data/constants/app';
import store from 'data/store';
import GradebookPage from 'containers/GradebookPage';
import './App.scss';
import Head from './head/Head';
const App = () => (
<AppProvider store={store}>
<Head />
<Router>
<div>
<Header />

View File

@@ -12,7 +12,6 @@ import store from 'data/store';
import GradebookPage from 'containers/GradebookPage';
import App from './App';
import Head from './head/Head';
jest.mock('react-router-dom', () => ({
BrowserRouter: () => 'BrowserRouter',
@@ -42,7 +41,7 @@ describe('App router component', () => {
beforeEach(() => {
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
el = shallow(<App />);
router = el.childAt(1);
router = el.childAt(0);
});
describe('AppProvider', () => {
test('AppProvider is the parent component, passed the redux store props', () => {
@@ -50,13 +49,8 @@ describe('App router component', () => {
expect(el.props().store).toEqual(store);
});
});
describe('Head', () => {
test('first child of AppProvider', () => {
expect(el.childAt(0).type()).toBe(Head);
});
});
describe('Router', () => {
test('second child of AppProvider', () => {
test('first child of AppProvider', () => {
expect(router.type()).toBe(Router);
});
test('Header is above/outside-of the routing', () => {

View File

@@ -4,7 +4,6 @@ exports[`App router component snapshot 1`] = `
<AppProvider
store="testStore"
>
<injectIntl(ShimmedIntlComponent) />
<BrowserRouter>
<div>
<Header />

View File

@@ -3,7 +3,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { DataTable } from '@edx/paragon';
import { Table } from '@edx/paragon';
import { bulkManagementColumns } from 'data/constants/app';
import selectors from 'data/selectors';
@@ -30,13 +30,14 @@ export const mapHistoryRows = ({
export const HistoryTable = ({
bulkManagementHistory,
}) => (
<DataTable
data={bulkManagementHistory.map(mapHistoryRows)}
hasFixedColumnWidths
columns={bulkManagementColumns}
className="table-striped"
itemCount={bulkManagementHistory.length}
/>
<>
<Table
data={bulkManagementHistory.map(mapHistoryRows)}
hasFixedColumnWidths
columns={bulkManagementColumns}
className="table-striped"
/>
</>
);
HistoryTable.defaultProps = {
bulkManagementHistory: [],

View File

@@ -1,7 +1,7 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { shallow } from 'enzyme';
import { DataTable } from '@edx/paragon';
import { Table } from '@edx/paragon';
import selectors from 'data/selectors';
import { bulkManagementColumns } from 'data/constants/app';
@@ -9,12 +9,13 @@ import { bulkManagementColumns } from 'data/constants/app';
import ResultsSummary from './ResultsSummary';
import { HistoryTable, mapStateToProps } from './HistoryTable';
jest.mock('@edx/paragon', () => ({ DataTable: () => 'DataTable' }));
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
}));
jest.mock('@edx/paragon', () => ({
Table: () => 'Table',
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
@@ -61,7 +62,7 @@ describe('HistoryTable', () => {
describe('history table', () => {
let table;
beforeEach(() => {
table = el.find(DataTable);
table = el.find(Table);
});
describe('data (from bulkManagementHistory.map(this.formatHistoryRow)', () => {
const fieldAssertions = [

View File

@@ -42,77 +42,78 @@ Array [
`;
exports[`HistoryTable component snapshot snapshot - loads formatted table 1`] = `
<DataTable
className="table-striped"
columns={
Array [
Object {
"Header": "Gradebook",
"accessor": "filename",
"columnSortable": false,
"width": "col-5",
},
Object {
"Header": "Download Summary",
"accessor": "resultsSummary",
"columnSortable": false,
"width": "col",
},
Object {
"Header": "Who",
"accessor": "user",
"columnSortable": false,
"width": "col-1",
},
Object {
"Header": "When",
"accessor": "timeUploaded",
"columnSortable": false,
"width": "col",
},
]
}
data={
Array [
Object {
"filename": <span
className="wrap-text-in-cell"
>
blue.png
</span>,
"resultsSummary": <ResultsSummary
courseId="Da Bu Dee"
rowId={12}
text="Da ba daa"
/>,
"timeUploaded": "65",
"user": <span
className="wrap-text-in-cell"
>
Eifel
</span>,
},
Object {
"filename": <span
className="wrap-text-in-cell"
>
allStar.jpg
</span>,
"resultsSummary": <ResultsSummary
courseId="rockstar"
rowId={2}
text="all that glitters is gold"
/>,
"timeUploaded": "2000s?",
"user": <span
className="wrap-text-in-cell"
>
Smashmouth
</span>,
},
]
}
hasFixedColumnWidths={true}
itemCount={2}
/>
<Fragment>
<Table
className="table-striped"
columns={
Array [
Object {
"columnSortable": false,
"key": "filename",
"label": "Gradebook",
"width": "col-5",
},
Object {
"columnSortable": false,
"key": "resultsSummary",
"label": "Download Summary",
"width": "col",
},
Object {
"columnSortable": false,
"key": "user",
"label": "Who",
"width": "col-1",
},
Object {
"columnSortable": false,
"key": "timeUploaded",
"label": "When",
"width": "col",
},
]
}
data={
Array [
Object {
"filename": <span
className="wrap-text-in-cell"
>
blue.png
</span>,
"resultsSummary": <ResultsSummary
courseId="Da Bu Dee"
rowId={12}
text="Da ba daa"
/>,
"timeUploaded": "65",
"user": <span
className="wrap-text-in-cell"
>
Eifel
</span>,
},
Object {
"filename": <span
className="wrap-text-in-cell"
>
allStar.jpg
</span>,
"resultsSummary": <ResultsSummary
courseId="rockstar"
rowId={2}
text="all that glitters is gold"
/>,
"timeUploaded": "2000s?",
"user": <span
className="wrap-text-in-cell"
>
Smashmouth
</span>,
},
]
}
hasFixedColumnWidths={true}
/>
</Fragment>
`;

View File

@@ -31,14 +31,14 @@ exports[`GradebookFilters Component snapshots basic snapshot 1`] = `
}
>
<div>
<AssignmentTypeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
<Connect(AssignmentTypeFilter)
updateQueryParams={[MockFunction]}
/>
<AssignmentFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
<Connect(AssignmentFilter)
updateQueryParams={[MockFunction]}
/>
<AssignmentGradeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
<Connect(AssignmentGradeFilter)
updateQueryParams={[MockFunction]}
/>
</div>
</Collapsible>
@@ -53,8 +53,8 @@ exports[`GradebookFilters Component snapshots basic snapshot 1`] = `
/>
}
>
<CourseGradeFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
<Connect(CourseGradeFilter)
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible
@@ -68,8 +68,8 @@ exports[`GradebookFilters Component snapshots basic snapshot 1`] = `
/>
}
>
<StudentGroupsFilter
updateQueryParams={[MockFunction this.props.updateQueryParams]}
<InjectIntl(ShimmedIntlComponent)
updateQueryParams={[MockFunction]}
/>
</Collapsible>
<Collapsible

View File

@@ -22,11 +22,6 @@ jest.mock('@edx/paragon', () => ({
jest.mock('@edx/paragon/icons', () => ({
Close: 'paragon.icons.Close',
}));
jest.mock('./AssignmentTypeFilter', () => 'AssignmentTypeFilter');
jest.mock('./AssignmentFilter', () => 'AssignmentFilter');
jest.mock('./AssignmentGradeFilter', () => 'AssignmentGradeFilter');
jest.mock('./CourseGradeFilter', () => 'CourseGradeFilter');
jest.mock('./StudentGroupsFilter', () => 'StudentGroupsFilter');
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
@@ -55,7 +50,7 @@ describe('GradebookFilters', () => {
closeMenu: jest.fn().mockName('this.props.closeMenu'),
fetchGrades: jest.fn(),
updateIncludeCourseRoleMembers: jest.fn(),
updateQueryParams: jest.fn().mockName('this.props.updateQueryParams'),
updateQueryParams: jest.fn(),
};
});

View File

@@ -2,10 +2,10 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { configuration } from 'config';
import { views } from 'data/constants/app';
import actions from 'data/actions';
import selectors from 'data/selectors';
@@ -25,7 +25,7 @@ export class GradebookHeader extends React.Component {
}
lmsInstructorDashboardUrl = courseId => (
`${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor`
`${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`
);
handleToggleViewClick() {

View File

@@ -4,7 +4,7 @@ const messages = defineMessages({
downloadGradesBtn: {
id: 'gradebook.GradesView.BulkManagementControls.bulkManagementLabel',
defaultMessage: 'Download Grades',
description: 'A labeled button that allows an admin user to download course grades all at once (in bulk).',
description: 'Button text for bulk grades download control in GradesView',
},
});

View File

@@ -7,7 +7,6 @@ import { Form } from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
import { getLocale, isRtl } from '@edx/frontend-platform/i18n';
/**
* <AdjustedGradeInput />
@@ -46,7 +45,7 @@ export class AdjustedGradeInput extends React.Component {
value={this.props.value}
onChange={this.onChange}
/>
{this.props.possibleGrade && ` ${isRtl(getLocale()) ? '\\' : '/'} ${this.props.possibleGrade}`}
{this.props.possibleGrade && ` / ${this.props.possibleGrade}`}
</span>
);
}

View File

@@ -1,40 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`OverrideTable Component snapshots basic snapshot shows a row for each entry and one editable row 1`] = `
<DataTable
<Table
columns={
Array [
Object {
"Header": <FormattedMessage
"key": "date",
"label": <FormattedMessage
defaultMessage="Date"
description="Edit Modal Override Table Date column header"
id="gradebook.GradesView.EditModal.Overrides.dateHeader"
/>,
"accessor": "date",
},
Object {
"Header": <FormattedMessage
"key": "grader",
"label": <FormattedMessage
defaultMessage="Grader"
description="Edit Modal Override Table Grader column header"
id="gradebook.GradesView.EditModal.Overrides.graderHeader"
/>,
"accessor": "grader",
},
Object {
"Header": <FormattedMessage
"key": "reason",
"label": <FormattedMessage
defaultMessage="Reason"
description="Edit Modal Override Table Reason column header"
id="gradebook.GradesView.EditModal.Overrides.reasonHeader"
/>,
"accessor": "reason",
},
Object {
"Header": <FormattedMessage
"key": "adjustedGrade",
"label": <FormattedMessage
defaultMessage="Adjusted grade"
description="Edit Modal Override Table Adjusted grade column header"
id="gradebook.GradesView.EditModal.Overrides.adjustedGradeHeader"
/>,
"accessor": "adjustedGrade",
},
]
}
@@ -59,6 +59,5 @@ exports[`OverrideTable Component snapshots basic snapshot shows a row for each e
},
]
}
itemCount={2}
/>
`;

View File

@@ -3,7 +3,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { DataTable } from '@edx/paragon';
import { Table } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { gradeOverrideHistoryColumns as columns } from 'data/constants/app';
@@ -27,14 +27,14 @@ export const OverrideTable = ({
return null;
}
return (
<DataTable
<Table
columns={[
{ Header: <FormattedMessage {...messages.dateHeader} />, accessor: columns.date },
{ Header: <FormattedMessage {...messages.graderHeader} />, accessor: columns.grader },
{ Header: <FormattedMessage {...messages.reasonHeader} />, accessor: columns.reason },
{ label: <FormattedMessage {...messages.dateHeader} />, key: columns.date },
{ label: <FormattedMessage {...messages.graderHeader} />, key: columns.grader },
{ label: <FormattedMessage {...messages.reasonHeader} />, key: columns.reason },
{
Header: <FormattedMessage {...messages.adjustedGradeHeader} />,
accessor: columns.adjustedGrade,
label: <FormattedMessage {...messages.adjustedGradeHeader} />,
key: columns.adjustedGrade,
},
]}
data={[
@@ -45,7 +45,6 @@ export const OverrideTable = ({
reason: <ReasonInput />,
},
]}
itemCount={gradeOverrides.length}
/>
);
};

View File

@@ -8,7 +8,7 @@ import {
mapStateToProps,
} from '.';
jest.mock('@edx/paragon', () => ({ DataTable: () => 'DataTable' }));
jest.mock('@edx/paragon', () => ({ Table: () => 'Table' }));
jest.mock('./ReasonInput', () => 'ReasonInput');
jest.mock('./AdjustedGradeInput', () => 'AdjustedGradeInput');

View File

@@ -5,13 +5,12 @@ exports[`EditMoal Component snapshots gradeOverrideHistoryError is and empty and
body={
<div>
<ModalHeaders />
<Alert
<StatusAlert
alertType="danger"
dialog="Weve been trying to contact you regarding..."
dismissible={false}
show={true}
variant="danger"
>
Weve been trying to contact you regarding...
</Alert>
open={true}
/>
<OverrideTable />
<div>
<FormattedMessage
@@ -67,13 +66,12 @@ exports[`EditMoal Component snapshots gradeOverrideHistoryError is empty and ope
body={
<div>
<ModalHeaders />
<Alert
<StatusAlert
alertType="danger"
dialog=""
dismissible={false}
show={false}
variant="danger"
>
</Alert>
open={false}
/>
<OverrideTable />
<div>
<FormattedMessage

View File

@@ -6,7 +6,7 @@ import { connect } from 'react-redux';
import {
Button,
Modal,
Alert,
StatusAlert,
} from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
@@ -53,13 +53,12 @@ export class EditModal extends React.Component {
body={(
<div>
<ModalHeaders />
<Alert
variant="danger"
show={!!this.props.gradeOverrideHistoryError}
<StatusAlert
alertType="danger"
dialog={this.props.gradeOverrideHistoryError}
open={!!this.props.gradeOverrideHistoryError}
dismissible={false}
>
{this.props.gradeOverrideHistoryError}
</Alert>
/>
<OverrideTable />
<div><FormattedMessage {...messages.visibility} /></div>
<div><FormattedMessage {...messages.saveVisibility} /></div>

View File

@@ -16,7 +16,7 @@ jest.mock('./ModalHeaders', () => 'ModalHeaders');
jest.mock('@edx/paragon', () => ({
Button: () => 'Button',
Modal: () => 'Modal',
Alert: () => 'Alert',
StatusAlert: () => 'StatusAlert',
}));
jest.mock('data/actions', () => ({
__esModule: true,

View File

@@ -4,7 +4,7 @@ const messages = defineMessages({
editFilters: {
id: 'gradebook.GradesView.editFilterLabel',
defaultMessage: 'Edit Filters',
description: 'A labeled button in the Grades tab that opens/closes the Filters tab, allowing the grades to be filtered',
description: 'Button text on Grades tab to open/close the Filters tab',
},
});

View File

@@ -4,58 +4,48 @@ exports[`GradebookTable component snapshot - fields1 and 2 between email and tot
<div
className="gradebook-container"
>
<DataTable
RowStatusComponent={[MockFunction this.nullMethod]}
columns={
Array [
Object {
"Header": <UsernameLabelReplacement />,
"accessor": "Username",
},
Object {
"Header": <FormattedMessage
defaultMessage="Email"
description="Gradebook table email column header"
id="gradebook.GradesView.table.headings.email"
/>,
"accessor": "Email",
},
Object {
"Header": "field1",
"accessor": "field1",
},
Object {
"Header": "field2",
"accessor": "field2",
},
Object {
"Header": <TotalGradeLabelReplacement />,
"accessor": "Total Grade (%)",
},
]
}
data={
Array [
"mappedRow: 1",
"mappedRow: 2",
"mappedRow: 3",
]
}
hasFixedColumnWidths={true}
itemCount={3}
rowHeaderColumnKey="username"
<div
className="gbook"
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable
content={
<FormattedMessage
defaultMessage="No results found"
description="Gradebook table message when no learner results were found"
id="gradebook.GradesView.table.noResultsFound"
/>
<Table
columns={
Array [
Object {
"key": "Username",
"label": <UsernameLabelReplacement />,
},
Object {
"key": "Email",
"label": <FormattedMessage
defaultMessage="Email*"
description="Gradebook table email column header"
id="gradebook.GradesView.table.headings.email"
/>,
},
Object {
"key": "field1",
"label": "field1",
},
Object {
"key": "field2",
"label": "field2",
},
Object {
"key": "Total Grade (%)",
"label": <TotalGradeLabelReplacement />,
},
]
}
data={
Array [
"mappedRow: 1",
"mappedRow: 2",
"mappedRow: 3",
]
}
hasFixedColumnWidths={true}
rowHeaderColumnKey="username"
/>
</DataTable>
</div>
</div>
`;

View File

@@ -3,8 +3,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { DataTable } from '@edx/paragon';
import { FormattedMessage, getLocale, isRtl } from '@edx/frontend-platform/i18n';
import { Table } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
import { Headings } from 'data/constants/grades';
@@ -27,7 +27,6 @@ export class GradebookTable extends React.Component {
super(props);
this.mapHeaders = this.mapHeaders.bind(this);
this.mapRows = this.mapRows.bind(this);
this.nullMethod = this.nullMethod.bind(this);
}
mapHeaders(heading) {
@@ -41,7 +40,7 @@ export class GradebookTable extends React.Component {
} else {
label = heading;
}
return { Header: label, accessor: heading };
return { label, key: heading };
}
mapRows(entry) {
@@ -50,7 +49,7 @@ export class GradebookTable extends React.Component {
<Fields.Username username={entry.username} userKey={entry.external_user_key} />
),
[Headings.email]: (<Fields.Email email={entry.email} />),
[Headings.totalGrade]: `${roundGrade(entry.percent * 100)}${isRtl(getLocale()) ? '\u200f' : ''}%`,
[Headings.totalGrade]: `${roundGrade(entry.percent * 100)}%`,
};
entry.section_breakdown.forEach(subsection => {
dataRow[subsection.label] = (
@@ -60,25 +59,17 @@ export class GradebookTable extends React.Component {
return dataRow;
}
nullMethod() {
return null;
}
render() {
return (
<div className="gradebook-container">
<DataTable
columns={this.props.headings.map(this.mapHeaders)}
data={this.props.grades.map(this.mapRows)}
rowHeaderColumnKey="username"
hasFixedColumnWidths
itemCount={this.props.grades.length}
RowStatusComponent={this.nullMethod}
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.EmptyTable content={<FormattedMessage {...messages.noResultsFound} />} />
</DataTable>
<div className="gbook">
<Table
columns={this.props.headings.map(this.mapHeaders)}
data={this.props.grades.map(this.mapRows)}
rowHeaderColumnKey="username"
hasFixedColumnWidths
/>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
emailHeading: {
id: 'gradebook.GradesView.table.headings.email',
defaultMessage: 'Email',
defaultMessage: 'Email*',
description: 'Gradebook table email column header',
},
totalGradeHeading: {
@@ -31,11 +31,6 @@ const messages = defineMessages({
defaultMessage: 'Total Grade values are always displayed as a percentage',
description: 'Gradebook table message that total grades are displayed in percent format',
},
noResultsFound: {
id: 'gradebook.GradesView.table.noResultsFound',
defaultMessage: 'No results found',
description: 'Gradebook table message when no learner results were found',
},
});
export default messages;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { DataTable } from '@edx/paragon';
import { Table } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
@@ -11,12 +11,8 @@ import Fields from './Fields';
import messages from './messages';
import { GradebookTable, mapStateToProps } from '.';
jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedComponents({
DataTable: {
Table: 'DataTable.Table',
TableControlBar: 'DataTable.TableControlBar',
EmptyTable: 'DataTable.EmptyTable',
},
jest.mock('@edx/paragon', () => ({
Table: () => 'Table',
}));
jest.mock('./Fields', () => ({
__esModule: true,
@@ -83,45 +79,40 @@ describe('GradebookTable', () => {
};
test('snapshot - fields1 and 2 between email and totalGrade, mocked rows', () => {
el = shallow(<GradebookTable {...props} />);
el.instance().nullMethod = jest.fn().mockName('this.nullMethod');
el.instance().mapRows = (entry) => `mappedRow: ${entry.percent}`;
expect(el.instance().render()).toMatchSnapshot();
});
test('null method returns null for stub component', () => {
el = shallow(<GradebookTable {...props} />);
expect(el.instance().nullMethod()).toEqual(null);
});
describe('table columns (mapHeaders)', () => {
let headings;
beforeEach(() => {
el = shallow(<GradebookTable {...props} />);
headings = el.find(DataTable).props().columns;
headings = el.find(Table).props().columns;
});
test('username sets key and replaces Header with component', () => {
test('username sets key and replaces label with component', () => {
const heading = headings[0];
expect(heading.accessor).toEqual(Headings.username);
expect(heading.Header.type).toEqual(LabelReplacements.UsernameLabelReplacement);
expect(heading.key).toEqual(Headings.username);
expect(heading.label.type).toEqual(LabelReplacements.UsernameLabelReplacement);
});
test('email sets key and Header from header', () => {
test('email sets key and label from header', () => {
const heading = headings[1];
expect(heading.accessor).toEqual(Headings.email);
expect(heading.Header).toEqual(<FormattedMessage {...messages.emailHeading} />);
expect(heading.key).toEqual(Headings.email);
expect(heading.label).toEqual(<FormattedMessage {...messages.emailHeading} />);
});
test('subsections set key and Header from header', () => {
expect(headings[2]).toEqual({ accessor: fields.field1, Header: fields.field1 });
expect(headings[3]).toEqual({ accessor: fields.field2, Header: fields.field2 });
test('subsections set key and label from header', () => {
expect(headings[2]).toEqual({ key: fields.field1, label: fields.field1 });
expect(headings[3]).toEqual({ key: fields.field2, label: fields.field2 });
});
test('totalGrade sets key and replaces Header with component', () => {
test('totalGrade sets key and replaces label with component', () => {
const heading = headings[4];
expect(heading.accessor).toEqual(Headings.totalGrade);
expect(heading.Header.type).toEqual(LabelReplacements.TotalGradeLabelReplacement);
expect(heading.key).toEqual(Headings.totalGrade);
expect(heading.label.type).toEqual(LabelReplacements.TotalGradeLabelReplacement);
});
});
describe('table data (mapRows)', () => {
let rows;
beforeEach(() => {
el = shallow(<GradebookTable {...props} />);
rows = el.find(DataTable).props().data;
rows = el.find(Table).props().data;
});
describe.each([0, 1, 2])('gradeEntry($percent)', (gradeIndex) => {
let row;

View File

@@ -31,7 +31,7 @@
.import-grades-btn {
margin-left: 20px;
}
.intervention-report-description {
.intervention-report-description: {
margin-right: 40px;
}
h4.step-message-1 {
@@ -67,9 +67,104 @@
overflow-x: auto;
height: 600px;
overflow-y: auto;
word-break: break-word;
position: relative;
}
.gbook {
width: 100%;
.grade-button {
text-decoration: underline;
}
.student-key {
font-size: 14px;
}
#courseGradeTooltipIcon {
float: right;
}
.table thead tr {
min-height: 60px;
&:nth-child(1) {
position: sticky;
top: 0;
z-index: 10;
background-color: white;
th {
background-color: white;
border-bottom: 1px solid $gray_200;
}
}
}
thead, tbody, tr, td, th {
display: block;
word-break: break-word;
}
.table tr th:first-child {
border-bottom: none;
}
.table tr th:first-child,
.table tr td:first-child {
position: sticky;
left: 0;
z-index: 1; // to float over the following children in the side-scrolling case
background: white;
}
.table tr {
th:nth-child(1),
td:nth-child(1),
th:nth-child(2),
td:nth-child(2) {
width: 240px;
}
th:nth-last-of-type(1) {
width: 150px;
}
th, td {
width: 120px;
}
}
.table tbody th {
font-weight: normal;
}
.table {
overflow-x: hidden;
height: 100%;
tbody {
overflow-y: auto;
display: block;
}
thead, tbody tr {
display: table;
table-layout: fixed;
}
th {
vertical-align: top;
font-size: 14px;
}
}
.link-style {
color: #0075b4;
&:hover, &:focus {
color: #004368;
text-decoration: underline;
}
}
}
.form-group, .pgn__form-group {
label {
font-weight: bold;

View File

@@ -4,12 +4,12 @@ const messages = defineMessages({
csvUploadLabel: {
id: 'gradebook.BulkManagementHistoryView.csvUploadLabel',
defaultMessage: 'Upload Grade CSV',
description: 'A labeled button to upload a CSV containing course grades.',
description: 'Button in BulkManagementHistoryView Alerts',
},
importGradesBtnText: {
id: 'gradebook.GradesView.importGradesBtnText',
defaultMessage: 'Import Grades',
description: 'A labeled button to import grades in the BulkManagement Tab File Upload Form',
description: 'Button in BulkManagement Tab File Upload Form',
},
});

View File

@@ -4,12 +4,12 @@ const messages = defineMessages({
description: {
id: 'gradebook.GradesView.ImportSuccessToast.description',
defaultMessage: 'Import Successful! Grades will be updated momentarily.',
description: 'A message congratulating a successful Import of grades',
description: 'Import Success Toast description',
},
showHistoryViewBtn: {
id: 'gradebook.GradesView.ImportSuccessToast.showHistoryViewBtn',
defaultMessage: 'View Activity Log',
description: 'The text on a button that loads a view of the Bulk Management Activity Log',
description: 'Button text for action that loads Bulk Management Activity Log view',
},
});

View File

@@ -4,17 +4,17 @@ const messages = defineMessages({
title: {
id: 'gradebook.GradesView.InterventionsReport.title',
defaultMessage: 'Interventions Report',
description: 'The title for the Intervention report subsection',
description: 'Intervention report subsection label',
},
description: {
id: 'gradebook.GradesView.InterventionsReport.description',
defaultMessage: 'Need to find students who may be falling behind? Download the interventions report to obtain engagement metrics such as section attempts and visits.',
description: 'The description for the Intervention report subsection',
description: 'Intervention report subsection description',
},
downloadBtn: {
id: 'gradebook.GradesView.InterventionsReport.downloadBtn',
defaultMessage: 'Download Interventions',
description: 'The labeled button to download the Intervention report from the Grades View',
description: 'Button text for intervention report download control in GradesView',
},
});

View File

@@ -4,17 +4,17 @@ const messages = defineMessages({
scoreView: {
id: 'gradebook.GradesView.scoreViewLabel',
defaultMessage: 'Score View',
description: 'The label for the dropdown list that allows a user to select the Score format',
description: 'Score format select dropdown label',
},
absolute: {
id: 'gradebook.GradesView.absoluteOption',
defaultMessage: 'Absolute',
description: 'A label within the Score Format dropdown list for the Absolute Grade Score option',
description: 'Score format select dropdown option',
},
percent: {
id: 'gradebook.GradesView.percentOption',
defaultMessage: 'Percent',
description: 'A label within the Score Format dropdown list for the Percent Grade Score option',
description: 'Score format select dropdown option',
},
});

View File

@@ -18,14 +18,13 @@ import messages from './SearchControls.messages';
export class SearchControls extends React.Component {
constructor(props) {
super(props);
this.onBlur = this.onBlur.bind(this);
this.onChange = this.onChange.bind(this);
this.onClear = this.onClear.bind(this);
this.onSubmit = this.onSubmit.bind(this);
}
onBlur(e) {
this.props.setSearchValue(e.target.value);
/** Changing the search value stores the key in Gradebook. Currently unused */
onChange(searchValue) {
this.props.setSearchValue(searchValue);
}
onClear() {
@@ -33,18 +32,13 @@ export class SearchControls extends React.Component {
this.props.fetchGrades();
}
onSubmit(searchValue) {
this.props.setSearchValue(searchValue);
this.props.fetchGrades();
}
render() {
return (
<div>
<SearchField
onSubmit={this.onSubmit}
onSubmit={this.props.fetchGrades}
inputLabel={<FormattedMessage {...messages.label} />}
onBlur={this.onBlur}
onChange={this.onChange}
onClear={this.onClear}
value={this.props.searchValue}
/>

View File

@@ -4,12 +4,12 @@ const messages = defineMessages({
label: {
id: 'gradebook.GradesView.search.label',
defaultMessage: 'Search for a learner',
description: 'Text prompting a user to use this functionality to search for a learner',
description: 'Search description label',
},
hint: {
id: 'gradebook.GradesView.search.hint',
defaultMessage: 'Search by username, email, or student key',
description: 'A hint explaining the ways a user can search',
description: 'Search hint label',
},
});

View File

@@ -4,11 +4,7 @@ import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import {
mapDispatchToProps,
mapStateToProps,
SearchControls,
} from './SearchControls';
import { mapDispatchToProps, mapStateToProps, SearchControls } from './SearchControls';
jest.mock('@edx/paragon', () => ({
Icon: 'Icon',
@@ -19,7 +15,7 @@ jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
searchValue: jest.fn((state) => ({ searchValue: state })),
searchValue: jest.fn(state => ({ searchValue: state })),
},
},
}));
@@ -56,45 +52,26 @@ describe('SearchControls', () => {
describe('Snapshots', () => {
test('basic snapshot', () => {
const wrapper = searchControls();
wrapper.instance().onBlur = jest.fn().mockName('onBlur');
wrapper.instance().onChange = jest.fn().mockName('onChange');
wrapper.instance().onClear = jest.fn().mockName('onClear');
wrapper.instance().onSubmit = jest.fn().mockName('onSubmit');
expect(wrapper.instance().render()).toMatchSnapshot();
});
});
describe('Behavior', () => {
describe('onBlur', () => {
it('saves the search value to Gradebook state but do not fetch grade', () => {
const wrapper = searchControls();
const event = {
target: {
value: 'bob',
},
};
wrapper.instance().onBlur(event);
expect(props.setSearchValue).toHaveBeenCalledWith('bob');
expect(props.fetchGrades).not.toHaveBeenCalled();
});
describe('onChange', () => {
it('saves the changed search value to Gradebook state', () => {
const wrapper = searchControls();
wrapper.instance().onChange('bob');
expect(props.setSearchValue).toHaveBeenCalledWith('bob');
});
});
describe('onClear', () => {
it('sets search value to empty string and calls fetchGrades', () => {
const wrapper = searchControls();
wrapper.instance().onClear();
expect(props.setSearchValue).toHaveBeenCalledWith('');
expect(props.fetchGrades).toHaveBeenCalled();
});
});
describe('onSubmit', () => {
it('sets search value to input and calls fetchGrades', () => {
const wrapper = searchControls();
wrapper.instance().onSubmit('John');
expect(props.setSearchValue).toHaveBeenCalledWith('John');
expect(props.fetchGrades).toHaveBeenCalled();
});
describe('onChange', () => {
it('sets search value to empty string and calls fetchGrades', () => {
const wrapper = searchControls();
wrapper.instance().onClear();
expect(props.setSearchValue).toHaveBeenCalledWith('');
expect(props.fetchGrades).toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Alert } from '@edx/paragon';
import { StatusAlert } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
@@ -40,20 +40,18 @@ export class StatusAlerts extends React.Component {
render() {
return (
<>
<Alert
variant="success"
<StatusAlert
alertType="success"
dialog={<FormattedMessage {...messages.editSuccessAlert} />}
onClose={this.props.handleCloseSuccessBanner}
show={this.props.showSuccessBanner}
>
<FormattedMessage {...messages.editSuccessAlert} />
</Alert>
<Alert
variant="danger"
open={this.props.showSuccessBanner}
/>
<StatusAlert
alertType="danger"
dialog={this.courseGradeFilterAlertDialogText}
dismissible={false}
show={this.isCourseGradeFilterAlertOpen}
>
{this.courseGradeFilterAlertDialogText}
</Alert>
open={this.isCourseGradeFilterAlertOpen}
/>
</>
);
}

View File

@@ -4,17 +4,17 @@ const messages = defineMessages({
editSuccessAlert: {
id: 'gradebook.GradesView.editSuccessAlert',
defaultMessage: 'The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook.',
description: 'An alert text for successfully editing a grade',
description: 'Alert text for successful edit action',
},
maxGradeInvalid: {
id: 'gradebook.GradesView.maxCourseGradeInvalid',
defaultMessage: 'Maximum course grade must be between 0 and 100',
description: 'An alert text for selecting a maximum course grade greater than 100',
description: 'Alert text for invalid maximum course grade',
},
minGradeInvalid: {
id: 'gradebook.GradesView.minCourseGradeInvalid',
defaultMessage: 'Minimum course grade must be between 0 and 100',
description: 'An alert text for selecting a minimum course grade less than 0',
description: 'Alert text for invalid minimum course grade',
},
});

View File

@@ -13,7 +13,7 @@ import {
} from './StatusAlerts';
jest.mock('@edx/paragon', () => ({
Alert: 'Alert',
StatusAlert: 'StatusAlert',
}));
jest.mock('data/selectors', () => ({
__esModule: true,

View File

@@ -12,7 +12,7 @@ exports[`FilterMenuToggle component snapshots basic snapshot 1`] = `
<FormattedMessage
defaultMessage="Edit Filters"
description="A labeled button in the Grades tab that opens/closes the Filters tab, allowing the grades to be filtered"
description="Button text on Grades tab to open/close the Filters tab"
id="gradebook.GradesView.editFilterLabel"
/>
</Button>

View File

@@ -19,7 +19,7 @@ exports[`ImportGradesButton component snapshot snapshot - loads export form w/ a
label={
<FormattedMessage
defaultMessage="Upload Grade CSV"
description="A labeled button to upload a CSV containing course grades."
description="Button in BulkManagementHistoryView Alerts"
id="gradebook.BulkManagementHistoryView.csvUploadLabel"
/>
}
@@ -35,7 +35,7 @@ exports[`ImportGradesButton component snapshot snapshot - loads export form w/ a
label={
Object {
"defaultMessage": "Import Grades",
"description": "A labeled button to import grades in the BulkManagement Tab File Upload Form",
"description": "Button in BulkManagement Tab File Upload Form",
"id": "gradebook.GradesView.importGradesBtnText",
}
}

View File

@@ -7,7 +7,7 @@ exports[`InterventionsReport component snapshots snapshot 1`] = `
>
<FormattedMessage
defaultMessage="Interventions Report"
description="The title for the Intervention report subsection"
description="Intervention report subsection label"
id="gradebook.GradesView.InterventionsReport.title"
/>
</h4>
@@ -19,7 +19,7 @@ exports[`InterventionsReport component snapshots snapshot 1`] = `
>
<FormattedMessage
defaultMessage="Need to find students who may be falling behind? Download the interventions report to obtain engagement metrics such as section attempts and visits."
description="The description for the Intervention report subsection"
description="Intervention report subsection description"
id="gradebook.GradesView.InterventionsReport.description"
/>
</div>
@@ -27,7 +27,7 @@ exports[`InterventionsReport component snapshots snapshot 1`] = `
label={
Object {
"defaultMessage": "Download Interventions",
"description": "The labeled button to download the Intervention report from the Grades View",
"description": "Button text for intervention report download control in GradesView",
"id": "gradebook.GradesView.InterventionsReport.downloadBtn",
}
}

View File

@@ -7,7 +7,7 @@ exports[`ScoreViewInput component snapshot - select box with percent and absolut
<FormLabel>
<FormattedMessage
defaultMessage="Score View"
description="The label for the dropdown list that allows a user to select the Score format"
description="Score format select dropdown label"
id="gradebook.GradesView.scoreViewLabel"
/>
:

View File

@@ -6,13 +6,13 @@ exports[`SearchControls Component Snapshots basic snapshot 1`] = `
inputLabel={
<FormattedMessage
defaultMessage="Search for a learner"
description="Text prompting a user to use this functionality to search for a learner"
description="Search description label"
id="gradebook.GradesView.search.label"
/>
}
onBlur={[MockFunction onBlur]}
onChange={[MockFunction onChange]}
onClear={[MockFunction onClear]}
onSubmit={[MockFunction onSubmit]}
onSubmit={[MockFunction fetchGrades]}
value="alice"
/>
<small
@@ -20,7 +20,7 @@ exports[`SearchControls Component Snapshots basic snapshot 1`] = `
>
<FormattedMessage
defaultMessage="Search by username, email, or student key"
description="A hint explaining the ways a user can search"
description="Search hint label"
id="gradebook.GradesView.search.hint"
/>
</small>

View File

@@ -2,23 +2,23 @@
exports[`StatusAlerts snapshots basic snapshot 1`] = `
<React.Fragment>
<Alert
<StatusAlert
alertType="success"
dialog={
<FormattedMessage
defaultMessage="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
description="Alert text for successful edit action"
id="gradebook.GradesView.editSuccessAlert"
/>
}
onClose={[MockFunction handleCloseSuccessBanner]}
show={true}
variant="success"
>
<FormattedMessage
defaultMessage="The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook."
description="An alert text for successfully editing a grade"
id="gradebook.GradesView.editSuccessAlert"
/>
</Alert>
<Alert
open={true}
/>
<StatusAlert
alertType="danger"
dialog="the quiCk brown does somEthing or other"
dismissible={false}
show={false}
variant="danger"
>
the quiCk brown does somEthing or other
</Alert>
open={false}
/>
</React.Fragment>
`;

16
src/config/index.js Normal file
View File

@@ -0,0 +1,16 @@
const configuration = {
BASE_URL: process.env.BASE_URL,
LMS_BASE_URL: process.env.LMS_BASE_URL,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH,
REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT,
DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
SECURE_COOKIES: process.env.NODE_ENV !== 'development',
SEGMENT_KEY: process.env.SEGMENT_KEY,
ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
};
const features = {};
export { configuration, features };

View File

@@ -32,26 +32,26 @@ export const localFilterKeys = StrictDict({
*/
export const bulkManagementColumns = [
{
accessor: 'filename',
Header: 'Gradebook',
key: 'filename',
label: 'Gradebook',
columnSortable: false,
width: 'col-5',
},
{
accessor: 'resultsSummary',
Header: 'Download Summary',
key: 'resultsSummary',
label: 'Download Summary',
columnSortable: false,
width: 'col',
},
{
accessor: 'user',
Header: 'Who',
key: 'user',
label: 'Who',
columnSortable: false,
width: 'col-1',
},
{
accessor: 'timeUploaded',
Header: 'When',
key: 'timeUploaded',
label: 'When',
columnSortable: false,
width: 'col',
},

View File

@@ -4,37 +4,37 @@ const messages = defineMessages({
assignment: {
id: 'gradebook.GradesTab.FilterBadges.assignment',
defaultMessage: 'Assignment',
description: 'A label describing the notification under the "Edit Filters" button that shows by which of the course\'s assignments the view is being filtered.',
description: 'Assignment FilterBadge label',
},
assignmentGrade: {
id: 'gradebook.GradesTab.FilterBadges.assignmentGrade',
defaultMessage: 'Assignment Grade',
description: 'A label describing the notification under the "Edit Filters" button that shows that the view is being filtered to include assignment grades within the alloted range.',
description: 'Assignment Grade FilterBadge label',
},
assignmentType: {
id: 'gradebook.GradesTab.FilterBadges.assignmentType',
defaultMessage: 'Assignment Type',
description: 'A label describing the notification under the "Edit Filters" button that shows by which of the course\'s assignment types the view is being filtered.',
description: 'Assignment Type FilterBadge label',
},
cohort: {
id: 'gradebook.GradesTab.FilterBadges.cohort',
defaultMessage: 'Cohort',
description: 'A label describing the notification under the "Edit Filters" button that shows by which of the course\'s cohorts the view is being filtered.',
description: 'Cohort FilterBadge label',
},
courseGrade: {
id: 'gradebook.GradesTab.FilterBadges.courseGrade',
defaultMessage: 'Course Grade',
description: 'A label describing the notification under the "Edit Filters" button that shows that the view is being filtered to include course grades within the alloted range.',
description: 'Course Grade FilterBadge label',
},
includeCourseRoleMembers: {
id: 'gradebook.GradesTab.FilterBadges.includeCourseRoleMembers',
defaultMessage: 'Include Course Team Members',
description: 'A label describing the notification under the "Edit Filters" button that shows that the view is being filtered to include course team members.',
description: 'Include Course Team Members FilterBadge label',
},
track: {
id: 'gradebook.GradesTab.FilterBadges.track',
defaultMessage: 'Track',
description: 'A label describing the notification under the "Edit Filters" button that shows by which of the course\'s tracks the view is being filtered.',
description: 'Track FilterBadge label',
},
});

View File

@@ -131,7 +131,7 @@ describe('app reducer', () => {
const mockDate = new Date(8675309);
let dateSpy;
beforeEach(() => {
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
dateSpy = jest.spyOn(global, 'Date').mockReturnValue(mockDate);
});
afterEach(() => {
dateSpy.mockRestore();

View File

@@ -3,7 +3,6 @@ import { StrictDict } from 'utils';
import { Headings, GradeFormats } from 'data/constants/grades';
import { formatDateForDisplay } from 'data/actions/utils';
import { getLocale, isRtl } from '@edx/frontend-platform/i18n';
import simpleSelectorFactory from '../utils';
import * as module from './grades';
@@ -157,7 +156,7 @@ export const subsectionGrade = StrictDict({
[GradeFormats.absolute]: (subsection) => {
const earned = module.roundGrade(subsection.score_earned);
const possible = module.roundGrade(subsection.score_possible);
return subsection.attempted ? `${earned}${isRtl(getLocale()) ? '\\' : '/'}${possible}` : `${earned}`;
return subsection.attempted ? `${earned}/${possible}` : `${earned}`;
},
/**
* subsectionGrade.percent(subsection)

View File

@@ -91,10 +91,17 @@ export const formattedGradeLimits = (state) => {
const { assignmentGradeMax, assignmentGradeMin } = app.assignmentGradeLimits(state);
const { courseGradeMax, courseGradeMin } = app.courseGradeLimits(state);
const hasAssignment = filters.selectedAssignmentId(state) !== undefined;
if (!hasAssignment) {
return {
assignmentGradeMax: null,
assignmentGradeMin: null,
courseGradeMax: null,
courseGradeMin: null,
};
}
return {
assignmentGradeMax: (assignmentGradeMax === maxGrade || !hasAssignment) ? null : assignmentGradeMax,
assignmentGradeMin: (assignmentGradeMin === minGrade || !hasAssignment) ? null : assignmentGradeMin,
assignmentGradeMax: assignmentGradeMax === maxGrade ? null : assignmentGradeMax,
assignmentGradeMin: assignmentGradeMin === minGrade ? null : assignmentGradeMin,
courseGradeMax: courseGradeMax === maxGrade ? null : courseGradeMax,
courseGradeMin: courseGradeMin === minGrade ? null : courseGradeMin,
};

View File

@@ -260,15 +260,15 @@ describe('root selectors', () => {
};
const grade1 = '42';
const grade2 = '3.14';
it('returns an object of nullable assignmentGrades if assignment is not set', () => {
it('returns an object of nulls if assignment is not set', () => {
mockId(undefined);
mockAssgn(grade1, grade2);
mockCourse(grade1, grade2);
expect(selector(testState)).toEqual({
assignmentGradeMax: null,
assignmentGradeMin: null,
courseGradeMax: '42',
courseGradeMin: '3.14',
courseGradeMax: null,
courseGradeMin: null,
});
});
it('returns null for each extreme iff they are equal their default', () => {

View File

@@ -14,10 +14,10 @@ const { get, post, stringifyUrl } = utils;
/*********************************************************************************
* GET Actions
*********************************************************************************/
const assignmentTypes = () => get(urls.getAssignmentTypesUrl());
const cohorts = () => get(urls.getCohortsUrl());
const roles = () => get(urls.getRolesUrl());
const tracks = () => get(urls.getTracksUrl());
const assignmentTypes = () => get(urls.assignmentTypes);
const cohorts = () => get(urls.cohorts);
const roles = () => get(urls.roles);
const tracks = () => get(urls.tracks);
/**
* fetch.gradebookData(searchText, cohort, track, options)
@@ -45,7 +45,7 @@ const gradebookData = (searchText, cohort, track, options = {}) => {
[paramKeys.assignmentGradeMax]: options.assignmentGradeMax,
[paramKeys.assignmentGradeMin]: options.assignmentGradeMin,
};
return get(stringifyUrl(urls.getGradebookUrl(), queryParams));
return get(stringifyUrl(urls.gradebook, queryParams));
};
/**
@@ -53,7 +53,7 @@ const gradebookData = (searchText, cohort, track, options = {}) => {
* fetches bulk operation history and raises an error if the operation fails
* @return {Promise} - get response
*/
const gradeBulkOperationHistory = () => get(urls.getBulkHistoryUrl())
const gradeBulkOperationHistory = () => get(urls.bulkHistory)
.then(response => response.data)
.catch(() => Promise.reject(Error(messages.errors.unhandledResponse)));
@@ -87,7 +87,7 @@ const gradeOverrideHistory = (subsectionId, userId) => (
* }
* @return {Promise} - post response
*/
const updateGradebookData = (updateData) => post(urls.getBulkUpdateUrl(), updateData);
const updateGradebookData = (updateData) => post(urls.bulkUpdate, updateData);
/**
* uploadGradeCsv(formData)

View File

@@ -35,28 +35,28 @@ describe('lms service api', () => {
describe('fetch.assignmentTypes', () => {
testSimpleFetch(
api.fetch.assignmentTypes,
urls.getAssignmentTypesUrl(),
urls.assignmentTypes,
'fetches from urls.assignmentTypes',
);
});
describe('fetch.cohorts', () => {
testSimpleFetch(
api.fetch.cohorts,
urls.getCohortsUrl(),
urls.cohorts,
'fetches from urls.cohorts',
);
});
describe('fetch.roles', () => {
testSimpleFetch(
api.fetch.roles,
urls.getRolesUrl(),
urls.roles,
'fetches from urls.roles',
);
});
describe('fetch.tracks', () => {
testSimpleFetch(
api.fetch.tracks,
urls.getTracksUrl(),
urls.tracks,
'fetches from urls.tracks',
);
});
@@ -98,7 +98,7 @@ describe('lms service api', () => {
});
test('loads only passed values if options is empty', () => (
api.fetch.gradebookData(searchText, cohort, track).then(({ data }) => {
expect(data).toEqual(utils.stringifyUrl(urls.getGradebookUrl(), {
expect(data).toEqual(utils.stringifyUrl(urls.gradebook, {
[paramKeys.pageSize]: pageSize,
[paramKeys.userContains]: searchText,
[paramKeys.cohortId]: cohort,
@@ -114,7 +114,7 @@ describe('lms service api', () => {
));
test('loads ["all"] for excludedCorseRoles if not includeCourseRoles', () => (
api.fetch.gradebookData(searchText, cohort, track, options).then(({ data }) => {
expect(data).toEqual(utils.stringifyUrl(urls.getGradebookUrl(), {
expect(data).toEqual(utils.stringifyUrl(urls.gradebook, {
[paramKeys.pageSize]: pageSize,
[paramKeys.userContains]: searchText,
[paramKeys.cohortId]: cohort,
@@ -130,7 +130,7 @@ describe('lms service api', () => {
));
test('loads null for excludedCorseRoles if includeCourseRoles', () => (
api.fetch.gradebookData(searchText, cohort, track, options).then(({ data }) => {
expect(data).toEqual(utils.stringifyUrl(urls.getGradebookUrl(), {
expect(data).toEqual(utils.stringifyUrl(urls.gradebook, {
[paramKeys.pageSize]: pageSize,
[paramKeys.userContains]: searchText,
[paramKeys.cohortId]: cohort,
@@ -153,7 +153,7 @@ describe('lms service api', () => {
});
it('fetches from urls.bulkHistory and returns the data', () => (
api.fetch.gradeBulkOperationHistory().then(url => {
expect(url).toEqual(urls.getBulkHistoryUrl());
expect(url).toEqual(urls.bulkHistory);
})
));
});
@@ -195,7 +195,7 @@ describe('lms service api', () => {
});
test('posts to urls.bulkUpdate with passed data', () => (
api.updateGradebookData(updateData).then(({ data }) => {
expect(data).toEqual({ url: urls.getBulkUpdateUrl(), data: updateData });
expect(data).toEqual({ url: urls.bulkUpdate, data: updateData });
})
));
});

View File

@@ -1,54 +1,59 @@
import { getConfig } from '@edx/frontend-platform';
import { StrictDict } from 'utils';
import { configuration } from 'config';
import { historyRecordLimit } from './constants';
import { filterQuery, stringifyUrl } from './utils';
const baseUrl = `${configuration.LMS_BASE_URL}`;
const courseId = window.location.pathname.split('/').filter(Boolean).pop() || '';
export const getUrlPrefix = () => `${getConfig().LMS_BASE_URL}/api/`;
export const getBulkGradesUrl = () => `${getUrlPrefix()}bulk_grades/course/${courseId}/`;
export const getEnrollmentUrl = () => `${getUrlPrefix()}enrollment/v1/`;
export const getGradesUrl = () => `${getUrlPrefix()}grades/v1/`;
export const getGradebookUrl = () => `${getGradesUrl()}gradebook/${courseId}/`;
export const getBulkUpdateUrl = () => `${getGradebookUrl()}bulk-update`;
export const getInterventionUrl = () => `${getBulkGradesUrl()}intervention/`;
export const getCohortsUrl = () => `${getUrlPrefix()}courses/${courseId}/cohorts/`;
export const getTracksUrl = () => `${getEnrollmentUrl()}course/${courseId}?include_expired=1`;
export const getBulkHistoryUrl = () => `${getBulkUpdateUrl()}history/`;
export const getAssignmentTypesUrl = () => stringifyUrl(`${getGradebookUrl()}grading-info`, { graded_only: true });
export const getRolesUrl = () => stringifyUrl(`${getEnrollmentUrl()}roles/`, { courseId });
const api = `${baseUrl}/api/`;
const bulkGrades = `${api}bulk_grades/course/${courseId}/`;
const enrollment = `${api}enrollment/v1/`;
const grades = `${api}grades/v1/`;
const gradebook = `${grades}gradebook/${courseId}/`;
const bulkUpdate = `${gradebook}bulk-update`;
const intervention = `${bulkGrades}intervention/`;
const cohorts = `${baseUrl}/courses/${courseId}/cohorts/`;
const tracks = `${enrollment}course/${courseId}?include_expired=1`;
const bulkHistory = `${bulkGrades}history/`;
const assignmentTypes = stringifyUrl(`${gradebook}grading-info`, { graded_only: true });
const roles = stringifyUrl(`${enrollment}roles/`, { courseId });
/**
* bulkGradesUrlByCourseAndRow(courseId, rowId)
* returns the bulkGrades url with the given rowId.
* @param {string} rowId - row/error identifier
* @return {string} - bulk grades fetch url
*/
export const bulkGradesUrlByRow = (rowId) => stringifyUrl(getBulkGradesUrl(), { error_id: rowId });
export const bulkGradesUrlByRow = (rowId) => stringifyUrl(bulkGrades, { error_id: rowId });
export const gradeCsvUrl = (options = {}) => stringifyUrl(getBulkGradesUrl(), filterQuery(options));
export const gradeCsvUrl = (options = {}) => stringifyUrl(bulkGrades, filterQuery(options));
export const interventionExportCsvUrl = (options = {}) => (
stringifyUrl(getInterventionUrl(), filterQuery(options))
stringifyUrl(intervention, filterQuery(options))
);
export const sectionOverrideHistoryUrl = (subsectionId, userId) => stringifyUrl(
`${getGradesUrl()}subsection/${subsectionId}/`,
`${grades}subsection/${subsectionId}/`,
{ user_id: userId, history_record_limit: historyRecordLimit },
);
export default StrictDict({
getUrlPrefix,
getBulkGradesUrl,
getEnrollmentUrl,
getGradesUrl,
getGradebookUrl,
getBulkUpdateUrl,
getInterventionUrl,
getCohortsUrl,
getTracksUrl,
getBulkHistoryUrl,
getAssignmentTypesUrl,
getRolesUrl,
assignmentTypes,
bulkGrades,
bulkHistory,
bulkUpdate,
cohorts,
enrollment,
grades,
gradebook,
intervention,
roles,
tracks,
bulkGradesUrlByRow,
gradeCsvUrl,
interventionExportCsvUrl,

View File

@@ -17,7 +17,7 @@ describe('lms api url methods', () => {
it('returns bulkGrades url with error_id', () => {
const id = 'heyo';
expect(bulkGradesUrlByRow(id)).toEqual(
utils.stringifyUrl(urls.getBulkGradesUrl(), { error_id: id }),
utils.stringifyUrl(urls.bulkGrades, { error_id: id }),
);
});
});
@@ -25,12 +25,12 @@ describe('lms api url methods', () => {
it('returns bulkGrades with filterQuery-loaded options as query', () => {
const options = { some: 'fun', query: 'options' };
expect(gradeCsvUrl(options)).toEqual(
utils.stringifyUrl(urls.getBulkGradesUrl(), utils.filterQuery(options)),
utils.stringifyUrl(urls.bulkGrades, utils.filterQuery(options)),
);
});
it('defaults options to empty object', () => {
expect(gradeCsvUrl()).toEqual(
utils.stringifyUrl(urls.getBulkGradesUrl(), utils.filterQuery({})),
utils.stringifyUrl(urls.bulkGrades, utils.filterQuery({})),
);
});
});
@@ -38,12 +38,12 @@ describe('lms api url methods', () => {
it('returns intervention url with filterQuery-loaded options as query', () => {
const options = { some: 'fun', query: 'options' };
expect(interventionExportCsvUrl(options)).toEqual(
utils.stringifyUrl(urls.getInterventionUrl(), utils.filterQuery(options)),
utils.stringifyUrl(urls.intervention, utils.filterQuery(options)),
);
});
it('defaults options to empty object', () => {
expect(interventionExportCsvUrl()).toEqual(
utils.stringifyUrl(urls.getInterventionUrl(), utils.filterQuery({})),
utils.stringifyUrl(urls.intervention, utils.filterQuery({})),
);
});
});
@@ -53,7 +53,7 @@ describe('lms api url methods', () => {
const userId = 'Tom';
expect(sectionOverrideHistoryUrl(subsectionId, userId)).toEqual(
utils.stringifyUrl(
`${urls.getGradesUrl()}subsection/${subsectionId}/`,
`${urls.grades}subsection/${subsectionId}/`,
{ user_id: userId, history_record_limit: historyRecordLimit },
),
);

View File

@@ -4,19 +4,19 @@ import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProductio
import { createLogger } from 'redux-logger';
import { createMiddleware } from 'redux-beacon';
import Segment from '@redux-beacon/segment';
import { getConfig } from '@edx/frontend-platform';
import actions from './actions';
import selectors from './selectors';
import reducers from './reducers';
import eventsMap from './services/segment/mapping';
import { configuration } from '../config';
export const createStore = () => {
const loggerMiddleware = createLogger();
const middleware = [thunkMiddleware, loggerMiddleware];
// Conditionally add the segmentMiddleware only if the SEGMENT_KEY environment variable exists.
if (getConfig().SEGMENT_KEY) {
if (configuration.SEGMENT_KEY) {
middleware.push(createMiddleware(eventsMap, Segment()));
}
const store = redux.createStore(

View File

@@ -4,12 +4,12 @@ import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProductio
import { createLogger } from 'redux-logger';
import { createMiddleware } from 'redux-beacon';
import Segment from '@redux-beacon/segment';
import { getConfig } from '@edx/frontend-platform';
import actions from './actions';
import selectors from './selectors';
import reducers from './reducers';
import eventsMap from './services/segment/mapping';
import { configuration } from '../config';
import exportedStore, { createStore } from './store';
@@ -22,10 +22,10 @@ jest.mock('redux-logger', () => ({
createLogger: () => 'logger',
}));
jest.mock('redux-thunk', () => 'thunkMiddleware');
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(() => ({
jest.mock('../config', () => ({
configuration: {
SEGMENT_KEY: 'a-fake-segment-key',
})),
},
}));
jest.mock('redux-beacon', () => ({
createMiddleware: jest.fn((map, model) => ({ map, model })),
@@ -60,9 +60,9 @@ describe('store aggregator module', () => {
});
});
describe('if no SEGMENT_KEY', () => {
const key = getConfig().SEGMENT_KEY;
const key = configuration.SEGMENT_KEY;
beforeEach(() => {
getConfig.mockImplementation(() => ({ SEGMENT_KEY: false }));
configuration.SEGMENT_KEY = false;
});
it('exports thunk and logger middleware, composed and applied with dev tools', () => {
expect(createStore().middleware).toEqual(
@@ -70,7 +70,7 @@ describe('store aggregator module', () => {
);
});
afterEach(() => {
getConfig.mockImplementation(() => ({ SEGMENT_KEY: key }));
configuration.SEGMENT_KEY = key;
});
});
});

View File

@@ -1,21 +0,0 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
const Head = ({ intl }) => (
<Helmet>
<title>
{intl.formatMessage(messages['gradebook.page.title'], { siteName: getConfig().SITE_NAME })}
</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
);
Head.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(Head);

View File

@@ -1,17 +0,0 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { mount } from 'enzyme';
import { getConfig } from '@edx/frontend-platform';
import Head from './Head';
describe('Head', () => {
const props = {};
it('should match render title tag and favicon with the site configuration values', () => {
mount(<IntlProvider locale="en"><Head {...props} /></IntlProvider>);
const helmet = Helmet.peek();
expect(helmet.title).toEqual(`Gradebook | ${getConfig().SITE_NAME}`);
expect(helmet.linkTags[0].rel).toEqual('shortcut icon');
expect(helmet.linkTags[0].href).toEqual(getConfig().FAVICON_URL);
});
});

View File

@@ -1,11 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'gradebook.page.title': {
id: 'gradebook.page.title',
defaultMessage: 'Gradebook | {siteName}',
description: 'Title tag',
},
});
export default messages;

View File

@@ -37,7 +37,7 @@
"gradebook.GradesView.EditModal.Overrides.reasonHeader": "Reason",
"gradebook.GradesTab.usersVisibilityLabel'": "Showing {filteredUsers} of {totalUsers} total learners",
"gradebook.GradesView.editFilterLabel": "Edit Filters",
"gradebook.GradesView.table.headings.email": "Email",
"gradebook.GradesView.table.headings.email": "Email*",
"gradebook.GradesView.table.headings.totalGrade": "Total Grade (%)",
"gradebook.GradesView.table.headings.username": "Username",
"gradebook.GradesView.table.labels.studentKey": "Student Key*",
@@ -70,4 +70,4 @@
"gradebook.GradesTab.FilterBadges.courseGrade": "Course Grade",
"gradebook.GradesTab.FilterBadges.includeCourseRoleMembers": "Include Course Team Members",
"gradebook.GradesTab.FilterBadges.track": "Track"
}
}

View File

@@ -37,7 +37,7 @@
"gradebook.GradesView.EditModal.Overrides.reasonHeader": "Reason",
"gradebook.GradesTab.usersVisibilityLabel'": "Showing {filteredUsers} of {totalUsers} total learners",
"gradebook.GradesView.editFilterLabel": "Edit Filters",
"gradebook.GradesView.table.headings.email": "Email",
"gradebook.GradesView.table.headings.email": "Email*",
"gradebook.GradesView.table.headings.totalGrade": "Total Grade (%)",
"gradebook.GradesView.table.headings.username": "Username",
"gradebook.GradesView.table.labels.studentKey": "Student Key*",
@@ -70,4 +70,4 @@
"gradebook.GradesTab.FilterBadges.courseGrade": "Course Grade",
"gradebook.GradesTab.FilterBadges.includeCourseRoleMembers": "Include Course Team Members",
"gradebook.GradesTab.FilterBadges.track": "Track"
}
}

View File

@@ -37,7 +37,7 @@
"gradebook.GradesView.EditModal.Overrides.reasonHeader": "Motif",
"gradebook.GradesTab.usersVisibilityLabel'": "Showing {filteredUsers} of {totalUsers} total learners",
"gradebook.GradesView.editFilterLabel": "Editer les filtres",
"gradebook.GradesView.table.headings.email": "Email",
"gradebook.GradesView.table.headings.email": "Email*",
"gradebook.GradesView.table.headings.totalGrade": "Note totale (%)",
"gradebook.GradesView.table.headings.username": "Nom dutilisateur",
"gradebook.GradesView.table.labels.studentKey": "Clé d'étudiant",
@@ -70,4 +70,4 @@
"gradebook.GradesTab.FilterBadges.courseGrade": "Note du cours",
"gradebook.GradesTab.FilterBadges.includeCourseRoleMembers": "Include Course Team Members",
"gradebook.GradesTab.FilterBadges.track": "Track"
}
}

View File

@@ -37,7 +37,7 @@
"gradebook.GradesView.EditModal.Overrides.reasonHeader": "Reason",
"gradebook.GradesTab.usersVisibilityLabel'": "Showing {filteredUsers} of {totalUsers} total learners",
"gradebook.GradesView.editFilterLabel": "Edit Filters",
"gradebook.GradesView.table.headings.email": "Email",
"gradebook.GradesView.table.headings.email": "Email*",
"gradebook.GradesView.table.headings.totalGrade": "Total Grade (%)",
"gradebook.GradesView.table.headings.username": "Username",
"gradebook.GradesView.table.labels.studentKey": "Student Key*",
@@ -70,4 +70,4 @@
"gradebook.GradesTab.FilterBadges.courseGrade": "Course Grade",
"gradebook.GradesTab.FilterBadges.includeCourseRoleMembers": "Include Course Team Members",
"gradebook.GradesTab.FilterBadges.track": "Track"
}
}

View File

@@ -37,7 +37,7 @@
"gradebook.GradesView.EditModal.Overrides.reasonHeader": "Reason",
"gradebook.GradesTab.usersVisibilityLabel'": "Showing {filteredUsers} of {totalUsers} total learners",
"gradebook.GradesView.editFilterLabel": "Edit Filters",
"gradebook.GradesView.table.headings.email": "Email",
"gradebook.GradesView.table.headings.email": "Email*",
"gradebook.GradesView.table.headings.totalGrade": "Total Grade (%)",
"gradebook.GradesView.table.headings.username": "Username",
"gradebook.GradesView.table.labels.studentKey": "Student Key*",
@@ -70,4 +70,4 @@
"gradebook.GradesTab.FilterBadges.courseGrade": "Course Grade",
"gradebook.GradesTab.FilterBadges.includeCourseRoleMembers": "Include Course Team Members",
"gradebook.GradesTab.FilterBadges.track": "Track"
}
}

View File

@@ -7,7 +7,6 @@ import ReactDOM from 'react-dom';
import {
APP_READY,
initialize,
mergeConfig,
subscribe,
} from '@edx/frontend-platform';
import { messages as headerMessages } from '@edx/frontend-component-header';
@@ -21,22 +20,6 @@ subscribe(APP_READY, () => {
});
initialize({
handlers: {
config: () => {
mergeConfig({
BASE_URL: process.env.BASE_URL,
LMS_BASE_URL: process.env.LMS_BASE_URL,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH,
REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT,
DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
SECURE_COOKIES: process.env.NODE_ENV !== 'development',
SEGMENT_KEY: process.env.SEGMENT_KEY,
ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
});
},
},
messages: [
appMessages,
headerMessages,

View File

@@ -4,7 +4,6 @@ import ReactDOM from 'react-dom';
import {
APP_READY,
initialize,
mergeConfig,
subscribe,
} from '@edx/frontend-platform';
import { messages as headerMessages } from '@edx/frontend-component-header';
@@ -20,7 +19,6 @@ jest.mock('react-dom', () => ({
jest.mock('@edx/frontend-platform', () => ({
APP_READY: 'app-is-ready-key',
initialize: jest.fn(),
mergeConfig: jest.fn(),
subscribe: jest.fn(),
}));
jest.mock('@edx/frontend-component-header', () => ({
@@ -48,23 +46,10 @@ describe('app registry', () => {
ReactDOM.render(<App />, document.getElementById('root')),
);
});
test('initialize is called with requireAuthenticatedUser, messages, and a config handler', () => {
test('initialize is called with footerMessages and requireAuthenticatedUser', () => {
expect(initialize).toHaveBeenCalledWith({
messages: [appMessages, headerMessages, footerMessages],
requireAuthenticatedUser: true,
handlers: {
config: expect.any(Function),
},
});
});
test('initialize config loads LMS_BASE_URL from env', () => {
const oldEnv = process.env;
const initializeArg = initialize.mock.calls[0][0];
process.env = { ...oldEnv, LMS_BASE_URL: 'http://example.com/fake' };
initializeArg.handlers.config();
expect(mergeConfig).toHaveBeenCalledWith(
expect.objectContaining({ LMS_BASE_URL: 'http://example.com/fake' }),
);
process.env = oldEnv;
});
});

View File

@@ -1,6 +1,6 @@
// The code in this file is from Segment's website:
// https://segment.com/docs/sources/website/analytics.js/quickstart/
import { getConfig } from '@edx/frontend-platform';
import { configuration } from './config';
(function () {
// Create a queue, but don't obliterate an existing one!
@@ -81,5 +81,5 @@ import { getConfig } from '@edx/frontend-platform';
// Load Analytics.js with your key, which will automatically
// load the tools you've enabled for your account. Boosh!
analytics.load(getConfig().SEGMENT_KEY);
analytics.load(configuration.SEGMENT_KEY);
}());

View File

@@ -8,8 +8,6 @@ Enzyme.configure({ adapter: new Adapter() });
// These configuration values are usually set in webpack's EnvironmentPlugin however
// Jest does not use webpack so we need to set these so for testing
process.env.LMS_BASE_URL = 'http://localhost:18000';
process.env.SITE_NAME = 'localhost';
process.env.FAVICON_URL = 'http://localhost:18000/favicon.ico';
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');

View File

@@ -1,187 +0,0 @@
import react from 'react';
import { StrictDict } from 'utils';
/**
* Mocked formatMessage provided by react-intl
*/
export const formatMessage = (msg, values) => {
let message = msg.defaultMessage;
if (values === undefined) {
return message;
}
Object.keys(values).forEach((key) => {
// eslint-disable-next-line
message = message.replace(`{${key}}`, values[key]);
});
return message;
};
/**
* Mock a single component, or a nested component so that its children render nicely
* in snapshots.
* @param {string} name - parent component name
* @param {obj} contents - object of child components with intended component
* render name.
* @return {func} - mock component with nested children.
*
* usage:
* mockNestedComponent('Card', { Body: 'Card.Body', Form: { Control: { Feedback: 'Form.Control.Feedback' }}... });
* mockNestedComponent('IconButton', 'IconButton');
*/
export const mockNestedComponent = (name, contents) => {
if (typeof contents !== 'object') {
return contents;
}
const fn = () => name;
Object.defineProperty(fn, 'name', { value: name });
Object.keys(contents).forEach((nestedName) => {
const value = contents[nestedName];
fn[nestedName] = typeof value !== 'object'
? value
: mockNestedComponent(`${name}.${nestedName}`, value);
});
return fn;
};
/**
* Mock a module of components. nested components will be rendered nicely in snapshots.
* @param {obj} mapping - component module mock config.
* @return {obj} - module of flat and nested components that will render nicely in snapshots.
* usage:
* mockNestedComponents({
* Card: { Body: 'Card.Body' },
* IconButton: 'IconButton',
* })
*/
export const mockNestedComponents = (mapping) => Object.entries(mapping).reduce(
(obj, [name, value]) => ({
...obj,
[name]: mockNestedComponent(name, value),
}),
{},
);
/**
* Mock utility for working with useState in a hooks module.
* Expects/requires an object containing the state object in order to ensure
* the mock behavior works appropriately.
*
* Expected format:
* hooks = { state: { <key>: (val) => React.createRef(val), ... } }
*
* Returns a utility for mocking useState and providing access to specific state values
* and setState methods, as well as allowing per-test configuration of useState value returns.
*
* Example usage:
* // hooks.js
* import * as module from './hooks';
* const state = {
* isOpen: (val) => React.useState(val),
* hasDoors: (val) => React.useState(val),
* selected: (val) => React.useState(val),
* };
* ...
* export const exampleHook = () => {
* const [isOpen, setIsOpen] = module.state.isOpen(false);
* if (!isOpen) { return null; }
* return { isOpen, setIsOpen };
* }
* ...
*
* // hooks.test.js
* import * as hooks from './hooks';
* const state = new MockUseState(hooks)
* ...
* describe('state hooks', () => {
* state.testGetter(state.keys.isOpen);
* state.testGetter(state.keys.hasDoors);
* state.testGetter(state.keys.selected);
* });
* describe('exampleHook', () => {
* beforeEach(() => { state.mock(); });
* it('returns null if isOpen is default value', () => {
* expect(hooks.exampleHook()).toEqual(null);
* });
* it('returns isOpen and setIsOpen if isOpen is not null', () => {
* state.mockVal(state.keys.isOpen, true);
* expect(hooks.exampleHook()).toEqual({
* isOpen: true,
* setIsOpen: state.setState[state.keys.isOpen],
* });
* });
* afterEach(() => { state.restore(); });
* });
*
* @param {obj} hooks - hooks module containing a 'state' object
*/
export class MockUseState {
constructor(hooks) {
this.hooks = hooks;
this.oldState = null;
this.setState = {};
this.stateVals = {};
this.mock = this.mock.bind(this);
this.restore = this.restore.bind(this);
this.mockVal = this.mockVal.bind(this);
this.testGetter = this.testGetter.bind(this);
}
/**
* @return {object} - StrictDict of state object keys
*/
get keys() {
return StrictDict(Object.keys(this.hooks.state).reduce(
(obj, key) => ({ ...obj, [key]: key }),
{},
));
}
/**
* Replace the hook module's state object with a mocked version, initialized to default values.
*/
mock() {
this.oldState = this.hooks.state;
Object.keys(this.keys).forEach(key => {
this.hooks.state[key] = jest.fn(val => {
this.stateVals[key] = val;
return [val, this.setState[key]];
});
});
this.setState = Object.keys(this.keys).reduce(
(obj, key) => ({
...obj,
[key]: jest.fn(val => {
this.hooks.state[key] = val;
}),
}),
{},
);
}
/**
* Restore the hook module's state object to the actual code.
*/
restore() {
this.hooks.state = this.oldState;
}
/**
* Mock the state getter associated with a single key to return a specific value one time.
* @param {string} key - state key (from this.keys)
* @param {any} val - new value to be returned by the useState call.
*/
mockVal(key, val) {
this.hooks.state[key].mockReturnValueOnce([val, this.setState[key]]);
}
testGetter(key) {
test(`${key} state getter should return useState passthrough`, () => {
const testValue = 'some value';
const useState = (val) => ({ useState: val });
jest.spyOn(react, 'useState').mockImplementationOnce(useState);
expect(this.hooks.state[key](testValue)).toEqual(useState(testValue));
});
}
}

View File

@@ -4,10 +4,6 @@ const strictGet = (target, name) => {
return target;
}
if (name === '$$typeof') {
return typeof target;
}
if (name in target || name === '_reactFragment') {
return target[name];
}

View File

@@ -45,9 +45,6 @@ describe('StrictDict', () => {
it('allows entry listing', () => {
expect(Object.entries(dict)).toEqual(Object.entries(rawDict));
});
it('allows $$typeof access', () => {
expect(dict.$$typeof).toEqual(typeof rawDict);
});
describe('missing key', () => {
it('logs error with target, name, and error stack', () => {
// eslint-ignore-next-line no-unused-vars