Compare commits

...

20 Commits

Author SHA1 Message Date
ihor-romaniuk
85709d9c71 feat: add a simple validation to the adjusted grade field 2023-01-30 09:40:17 -05:00
Eugene Dyudyunov
dc36d138c1 fix: correct grades minmax values (#303)
When the assignment type is selected, but assignment id isn't - the
courseGradeMax, courseGradeMin assignmentGradeMax and assignmentGradeMin
values become nullable. This leads to incorrect filtering results.

Fix:
- Preserve the courseGradeMax and courseGradeMin values in such case;
2023-01-24 13:51:32 -05:00
Ghassan Maslamani
ff4d0c75dd feat: ensure lms api synced with latest value in config
This change make it possible if LMS url to be changed, that
  the last value will be picked.

  This is simlair openedx/frontend-app-course-authoring/pull/389
  which issue overhangio/tutor-mfe/issues/86, the fixes is needed
  so that dynamic config would work with tutor:
  overhangio/tutor-mfe/pull/69
2022-12-08 18:22:27 +00:00
Kyle McCormick
c4846f9ebd fix: use getConfig in order to support runtime configuration (#286) (#287)
Before, gradebook was reading config from `process.env`
directly, which locked the app into using only
static (build-time) configuration. In order to
enable dynamic (runtime) configuration, we update
gradebook to use frontend-platform's standard
configuration interface: `mergeConfig()` and `getConfig()`.

Bumps version from 1.5.0 to 1.6.0.
(I would normally just do a patch release for a fix, but the version
 was hasn't been bumped for a while, so adding in full runtime
 configuration support seemed like it warranted a proper
 minor version bump.)

Co-authored-by: Ghassan Maslamani <ghassan.maslamani@gmail.com>
2022-11-28 12:06:02 -05:00
Diana Olarte
bccd87fd49 feat: allow runtime configuration (#253)
Allows frontend-app-gradebook to be configured at
runtime using the LMS's new MFE Configuration API.

Part of https://github.com/openedx/frontend-wg/issues/103
2022-11-14 20:35:54 +00:00
Simon Chen
03fa143fc1 Merge pull request #266 from fennec-tech/rtl-use-backslash-to-write-fractions
RTL: use backslash to write fractions (grades)
2022-09-29 15:02:24 -04:00
Simon Chen
075846f869 Merge pull request #265 from fennec-tech/rtl-fix-percentage-direction
RTL: fix (%) symbol to follow text direction
2022-09-29 14:49:14 -04:00
Abderraouf Mehdi Bouhali
1208d27d92 fix(rtl): use backslash to write fractions (grades) 2022-09-29 11:34:30 +01:00
Abderraouf Mehdi Bouhali
e345716bd4 fix(rtl): force % symbol to follow text direction 2022-09-29 10:11:26 +01:00
Maman Khan
2121a63c83 Removing codecov and coveralls packages (#245)
* fix: removed coveralls and codecov packages with update in ci uploader

* fix: pinning npm to version 8.5.x

Co-authored-by: Abdullah Waheed <abdullah.waheed@arbisoft.com>
2022-09-28 10:47:57 -04:00
Matt Hughes
47cab71b3c fix: do not squish the table with word wrapping
we should handle word wrapping down at the level of specific cells
which we can opt-in to non-default word-wrapping
2022-07-12 10:54:00 -04:00
Leangseu Kim
2d8af2ec00 fix: update and resolve dependencies 2022-07-07 13:23:23 -04:00
Leangseu Kim
d55abbe91e fix: search blur state for every single charater input 2022-07-07 10:05:35 -04:00
Abdullah Waheed
a75f365bdd refactor: migrated StatusAlert to Alert in GradesBook component
refactor: migrated StatusAlert to Alert in StatusAlerts component

refactor: updated unit tests mocks to set snapshot issues
2022-06-30 21:35:01 -04:00
Muhammad Abdullah Waheed
bbb7e895a5 Paragon table deprecation updation (#246)
* refactor: updated table deprecation of HistoryTable

* refactor: updated table deprecation of OverrideTable

* refactor: updated table deprecation of GradebookTable

* refactor: updated unit tests of OverrideTable according to new changes

* fix: error for css

* fix: strict dictionary error for react component

* chore: update css and handle syntax errors

* chore: update unit test

* fix: remove datatable row status and update tests

* fix: test coverage

Co-authored-by: Leangseu Kim <lkim@edx.org>
Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2022-06-30 14:22:29 -04:00
Muhammad Abdullah Waheed
bf70fd1450 fix: added specific npm version to fix peerDependency issue (#247) 2022-06-16 15:06:30 +05:00
Jawayria
af2ece8290 Merge pull request #242 from openedx/jenkins/version-check-c6a4685
feat: Add package-lock file version check
2022-05-06 15:56:23 +05:00
edX requirements bot
620827d772 feat: Add package-lock file version check 2022-04-29 08:48:48 -04:00
edX requirements bot
c6a4685bf5 chore!: Dropped support for Node 12 (#239)
* chore!: Dropped support for Node 12

* fix: dropped Node 12

Co-authored-by: Jawayria <39649635+Jawayria@users.noreply.github.com>
2022-04-14 12:53:54 -04:00
Leangseu Kim
8dd2237f9c chore: update package lock after update to node 16 2022-04-12 10:36:36 -04:00
57 changed files with 22225 additions and 19731 deletions

2
.env
View File

@@ -30,3 +30,5 @@ 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,3 +37,5 @@ 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=''

View File

@@ -3,17 +3,18 @@ name: node_js CI
on:
push:
branches:
- master
- master
pull_request:
branches:
- "**"
- '**'
jobs:
test:
runs-on: ubuntu-20.04
strategy:
matrix:
node: [12, 14, 16]
node: [16]
npm: [8.5.x]
steps:
- name: Checkout
@@ -24,6 +25,9 @@ jobs:
with:
node-version: ${{ matrix.node }}
- name: Install npm 8.5.x
run: npm install -g npm@${{ matrix.npm }}
- name: Install dependencies
run: npm ci
@@ -56,4 +60,5 @@ jobs:
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 }}"
body: CI workflow in ${{github.repository}} failed! For details see "github.com/${{
github.repository }}/actions/runs/${{ github.run_id }}"

View File

@@ -0,0 +1,13 @@
#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,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) [![Coveralls](https://img.shields.io/coveralls/edx/frontend-app-gradebook.svg?branch=master)](https://coveralls.io/github/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)
[![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)

View File

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

40733
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.5.0",
"version": "1.6.0",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",
@@ -8,7 +8,6 @@
},
"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/",
@@ -32,9 +31,9 @@
],
"dependencies": {
"@edx/brand": "npm:@edx/brand-edx.org@^1.3.2",
"@edx/frontend-component-footer": "10.2.1",
"@edx/frontend-component-header": "2.4.5",
"@edx/frontend-platform": "1.15.1",
"@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",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2",
@@ -53,6 +52,7 @@
"query-string": "6.13.0",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "^6.1.0",
"react-intl": "^2.9.0",
"react-redux": "^7.1.1",
"react-router": "5.2.0",
@@ -72,7 +72,6 @@
"@edx/frontend-build": "9.1.1",
"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",

View File

@@ -10,9 +10,11 @@ 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,6 +12,7 @@ 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',
@@ -41,7 +42,7 @@ describe('App router component', () => {
beforeEach(() => {
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
el = shallow(<App />);
router = el.childAt(0);
router = el.childAt(1);
});
describe('AppProvider', () => {
test('AppProvider is the parent component, passed the redux store props', () => {
@@ -49,8 +50,13 @@ describe('App router component', () => {
expect(el.props().store).toEqual(store);
});
});
describe('Router', () => {
describe('Head', () => {
test('first child of AppProvider', () => {
expect(el.childAt(0).type()).toBe(Head);
});
});
describe('Router', () => {
test('second child of AppProvider', () => {
expect(router.type()).toBe(Router);
});
test('Header is above/outside-of the routing', () => {

View File

@@ -4,6 +4,7 @@ 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 { Table } from '@edx/paragon';
import { DataTable } from '@edx/paragon';
import { bulkManagementColumns } from 'data/constants/app';
import selectors from 'data/selectors';
@@ -30,14 +30,13 @@ export const mapHistoryRows = ({
export const HistoryTable = ({
bulkManagementHistory,
}) => (
<>
<Table
data={bulkManagementHistory.map(mapHistoryRows)}
hasFixedColumnWidths
columns={bulkManagementColumns}
className="table-striped"
/>
</>
<DataTable
data={bulkManagementHistory.map(mapHistoryRows)}
hasFixedColumnWidths
columns={bulkManagementColumns}
className="table-striped"
itemCount={bulkManagementHistory.length}
/>
);
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 { Table } from '@edx/paragon';
import { DataTable } from '@edx/paragon';
import selectors from 'data/selectors';
import { bulkManagementColumns } from 'data/constants/app';
@@ -9,13 +9,12 @@ 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: {
@@ -62,7 +61,7 @@ describe('HistoryTable', () => {
describe('history table', () => {
let table;
beforeEach(() => {
table = el.find(Table);
table = el.find(DataTable);
});
describe('data (from bulkManagementHistory.map(this.formatHistoryRow)', () => {
const fieldAssertions = [

View File

@@ -42,78 +42,77 @@ Array [
`;
exports[`HistoryTable component snapshot snapshot - loads formatted table 1`] = `
<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>
<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}
/>
`;

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 => (
`${configuration.LMS_BASE_URL}/courses/${courseId}/instructor`
`${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor`
);
handleToggleViewClick() {

View File

@@ -7,6 +7,7 @@ import { Form } from '@edx/paragon';
import selectors from 'data/selectors';
import actions from 'data/actions';
import { getLocale, isRtl } from '@edx/frontend-platform/i18n';
/**
* <AdjustedGradeInput />
@@ -20,19 +21,32 @@ export class AdjustedGradeInput extends React.Component {
}
onChange = ({ target }) => {
this.props.setModalState({ adjustedGradeValue: target.value });
let adjustedGradeValue;
switch (true) {
case target.value < 0:
adjustedGradeValue = 0;
break;
case this.props.possibleGrade && target.value > this.props.possibleGrade:
adjustedGradeValue = this.props.possibleGrade;
break;
default:
adjustedGradeValue = target.value;
}
this.props.setModalState({ adjustedGradeValue });
};
render() {
return (
<span>
<Form.Control
type="text"
type="number"
name="adjustedGradeValue"
min="0"
max={this.props.possibleGrade ? this.props.possibleGrade : ''}
value={this.props.value}
onChange={this.onChange}
/>
{this.props.possibleGrade && ` / ${this.props.possibleGrade}`}
{this.props.possibleGrade && ` ${isRtl(getLocale()) ? '\\' : '/'} ${this.props.possibleGrade}`}
</span>
);
}

View File

@@ -54,9 +54,34 @@ describe('AdjustedGradeInput', () => {
});
describe('behavior', () => {
describe('onChange', () => {
it('calls props.setModalState event target value', () => {
it('calls props.setModalState event target value with correct value', () => {
const value = 3;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: value,
});
});
it('calls props.setModalState event target value with a value more then the possibleGrade value', () => {
const value = 42;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: props.possibleGrade,
});
});
it('calls props.setModalState event target value with less then 0', () => {
const value = -5;
el.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: 0,
});
});
it('calls props.setModalState event target value without possibleGrade value', () => {
const value = 100;
const newEl = shallow(<AdjustedGradeInput {...props} possibleGrade={null} />);
newEl.instance().onChange({ target: { value } });
expect(props.setModalState).toHaveBeenCalledWith({
adjustedGradeValue: value,
});

View File

@@ -3,9 +3,11 @@
exports[`AdjustedGradeInput Component snapshots displays input control and "out of possible grade" label 1`] = `
<span>
<Control
max={5}
min="0"
name="adjustedGradeValue"
onChange={[MockFunction this.onChange]}
type="text"
type="number"
value={1}
/>
/ 5

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`] = `
<Table
<DataTable
columns={
Array [
Object {
"key": "date",
"label": <FormattedMessage
"Header": <FormattedMessage
defaultMessage="Date"
description="Edit Modal Override Table Date column header"
id="gradebook.GradesView.EditModal.Overrides.dateHeader"
/>,
"accessor": "date",
},
Object {
"key": "grader",
"label": <FormattedMessage
"Header": <FormattedMessage
defaultMessage="Grader"
description="Edit Modal Override Table Grader column header"
id="gradebook.GradesView.EditModal.Overrides.graderHeader"
/>,
"accessor": "grader",
},
Object {
"key": "reason",
"label": <FormattedMessage
"Header": <FormattedMessage
defaultMessage="Reason"
description="Edit Modal Override Table Reason column header"
id="gradebook.GradesView.EditModal.Overrides.reasonHeader"
/>,
"accessor": "reason",
},
Object {
"key": "adjustedGrade",
"label": <FormattedMessage
"Header": <FormattedMessage
defaultMessage="Adjusted grade"
description="Edit Modal Override Table Adjusted grade column header"
id="gradebook.GradesView.EditModal.Overrides.adjustedGradeHeader"
/>,
"accessor": "adjustedGrade",
},
]
}
@@ -59,5 +59,6 @@ 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 { Table } from '@edx/paragon';
import { DataTable } 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 (
<Table
<DataTable
columns={[
{ label: <FormattedMessage {...messages.dateHeader} />, key: columns.date },
{ label: <FormattedMessage {...messages.graderHeader} />, key: columns.grader },
{ label: <FormattedMessage {...messages.reasonHeader} />, key: columns.reason },
{ Header: <FormattedMessage {...messages.dateHeader} />, accessor: columns.date },
{ Header: <FormattedMessage {...messages.graderHeader} />, accessor: columns.grader },
{ Header: <FormattedMessage {...messages.reasonHeader} />, accessor: columns.reason },
{
label: <FormattedMessage {...messages.adjustedGradeHeader} />,
key: columns.adjustedGrade,
Header: <FormattedMessage {...messages.adjustedGradeHeader} />,
accessor: columns.adjustedGrade,
},
]}
data={[
@@ -45,6 +45,7 @@ export const OverrideTable = ({
reason: <ReasonInput />,
},
]}
itemCount={gradeOverrides.length}
/>
);
};

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { connect } from 'react-redux';
import {
Button,
Modal,
StatusAlert,
Alert,
} from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
@@ -53,12 +53,13 @@ export class EditModal extends React.Component {
body={(
<div>
<ModalHeaders />
<StatusAlert
alertType="danger"
dialog={this.props.gradeOverrideHistoryError}
open={!!this.props.gradeOverrideHistoryError}
<Alert
variant="danger"
show={!!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',
StatusAlert: () => 'StatusAlert',
Alert: () => 'Alert',
}));
jest.mock('data/actions', () => ({
__esModule: true,

View File

@@ -4,48 +4,58 @@ exports[`GradebookTable component snapshot - fields1 and 2 between email and tot
<div
className="gradebook-container"
>
<div
className="gbook"
<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"
>
<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 />,
},
]
<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"
/>
}
data={
Array [
"mappedRow: 1",
"mappedRow: 2",
"mappedRow: 3",
]
}
hasFixedColumnWidths={true}
rowHeaderColumnKey="username"
/>
</div>
</DataTable>
</div>
`;

View File

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

View File

@@ -31,6 +31,11 @@ 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 { Table } from '@edx/paragon';
import { DataTable } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import selectors from 'data/selectors';
@@ -11,8 +11,12 @@ import Fields from './Fields';
import messages from './messages';
import { GradebookTable, mapStateToProps } from '.';
jest.mock('@edx/paragon', () => ({
Table: () => 'Table',
jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedComponents({
DataTable: {
Table: 'DataTable.Table',
TableControlBar: 'DataTable.TableControlBar',
EmptyTable: 'DataTable.EmptyTable',
},
}));
jest.mock('./Fields', () => ({
__esModule: true,
@@ -79,40 +83,45 @@ 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(Table).props().columns;
headings = el.find(DataTable).props().columns;
});
test('username sets key and replaces label with component', () => {
test('username sets key and replaces Header with component', () => {
const heading = headings[0];
expect(heading.key).toEqual(Headings.username);
expect(heading.label.type).toEqual(LabelReplacements.UsernameLabelReplacement);
expect(heading.accessor).toEqual(Headings.username);
expect(heading.Header.type).toEqual(LabelReplacements.UsernameLabelReplacement);
});
test('email sets key and label from header', () => {
test('email sets key and Header from header', () => {
const heading = headings[1];
expect(heading.key).toEqual(Headings.email);
expect(heading.label).toEqual(<FormattedMessage {...messages.emailHeading} />);
expect(heading.accessor).toEqual(Headings.email);
expect(heading.Header).toEqual(<FormattedMessage {...messages.emailHeading} />);
});
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('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('totalGrade sets key and replaces label with component', () => {
test('totalGrade sets key and replaces Header with component', () => {
const heading = headings[4];
expect(heading.key).toEqual(Headings.totalGrade);
expect(heading.label.type).toEqual(LabelReplacements.TotalGradeLabelReplacement);
expect(heading.accessor).toEqual(Headings.totalGrade);
expect(heading.Header.type).toEqual(LabelReplacements.TotalGradeLabelReplacement);
});
});
describe('table data (mapRows)', () => {
let rows;
beforeEach(() => {
el = shallow(<GradebookTable {...props} />);
rows = el.find(Table).props().data;
rows = el.find(DataTable).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,104 +67,9 @@
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

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

View File

@@ -4,7 +4,11 @@ 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',
@@ -15,7 +19,7 @@ jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
searchValue: jest.fn(state => ({ searchValue: state })),
searchValue: jest.fn((state) => ({ searchValue: state })),
},
},
}));
@@ -52,26 +56,45 @@ describe('SearchControls', () => {
describe('Snapshots', () => {
test('basic snapshot', () => {
const wrapper = searchControls();
wrapper.instance().onChange = jest.fn().mockName('onChange');
wrapper.instance().onBlur = jest.fn().mockName('onBlur');
wrapper.instance().onClear = jest.fn().mockName('onClear');
wrapper.instance().onSubmit = jest.fn().mockName('onSubmit');
expect(wrapper.instance().render()).toMatchSnapshot();
});
});
describe('onChange', () => {
it('saves the changed search value to Gradebook state', () => {
const wrapper = searchControls();
wrapper.instance().onChange('bob');
expect(props.setSearchValue).toHaveBeenCalledWith('bob');
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('sets search value to empty string and calls fetchGrades', () => {
const wrapper = searchControls();
wrapper.instance().onClear();
expect(props.setSearchValue).toHaveBeenCalledWith('');
expect(props.fetchGrades).toHaveBeenCalled();
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();
});
});
});

View File

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

View File

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

View File

@@ -10,9 +10,9 @@ exports[`SearchControls Component Snapshots basic snapshot 1`] = `
id="gradebook.GradesView.search.label"
/>
}
onChange={[MockFunction onChange]}
onBlur={[MockFunction onBlur]}
onClear={[MockFunction onClear]}
onSubmit={[MockFunction fetchGrades]}
onSubmit={[MockFunction onSubmit]}
value="alice"
/>
<small

View File

@@ -2,23 +2,23 @@
exports[`StatusAlerts snapshots basic snapshot 1`] = `
<React.Fragment>
<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="An alert text for successfully editing a grade"
id="gradebook.GradesView.editSuccessAlert"
/>
}
<Alert
onClose={[MockFunction handleCloseSuccessBanner]}
open={true}
/>
<StatusAlert
alertType="danger"
dialog="the quiCk brown does somEthing or other"
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
dismissible={false}
open={false}
/>
show={false}
variant="danger"
>
the quiCk brown does somEthing or other
</Alert>
</React.Fragment>
`;

View File

@@ -1,16 +0,0 @@
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 = [
{
key: 'filename',
label: 'Gradebook',
accessor: 'filename',
Header: 'Gradebook',
columnSortable: false,
width: 'col-5',
},
{
key: 'resultsSummary',
label: 'Download Summary',
accessor: 'resultsSummary',
Header: 'Download Summary',
columnSortable: false,
width: 'col',
},
{
key: 'user',
label: 'Who',
accessor: 'user',
Header: 'Who',
columnSortable: false,
width: 'col-1',
},
{
key: 'timeUploaded',
label: 'When',
accessor: 'timeUploaded',
Header: 'When',
columnSortable: false,
width: 'col',
},

View File

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

View File

@@ -3,6 +3,7 @@ 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';
@@ -156,7 +157,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}/${possible}` : `${earned}`;
return subsection.attempted ? `${earned}${isRtl(getLocale()) ? '\\' : '/'}${possible}` : `${earned}`;
},
/**
* subsectionGrade.percent(subsection)

View File

@@ -91,17 +91,10 @@ 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 ? null : assignmentGradeMax,
assignmentGradeMin: assignmentGradeMin === minGrade ? null : assignmentGradeMin,
assignmentGradeMax: (assignmentGradeMax === maxGrade || !hasAssignment) ? null : assignmentGradeMax,
assignmentGradeMin: (assignmentGradeMin === minGrade || !hasAssignment) ? 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 nulls if assignment is not set', () => {
it('returns an object of nullable assignmentGrades if assignment is not set', () => {
mockId(undefined);
mockAssgn(grade1, grade2);
mockCourse(grade1, grade2);
expect(selector(testState)).toEqual({
assignmentGradeMax: null,
assignmentGradeMin: null,
courseGradeMax: null,
courseGradeMin: null,
courseGradeMax: '42',
courseGradeMin: '3.14',
});
});
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.assignmentTypes);
const cohorts = () => get(urls.cohorts);
const roles = () => get(urls.roles);
const tracks = () => get(urls.tracks);
const assignmentTypes = () => get(urls.getAssignmentTypesUrl());
const cohorts = () => get(urls.getCohortsUrl());
const roles = () => get(urls.getRolesUrl());
const tracks = () => get(urls.getTracksUrl());
/**
* 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.gradebook, queryParams));
return get(stringifyUrl(urls.getGradebookUrl(), 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.bulkHistory)
const gradeBulkOperationHistory = () => get(urls.getBulkHistoryUrl())
.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.bulkUpdate, updateData);
const updateGradebookData = (updateData) => post(urls.getBulkUpdateUrl(), updateData);
/**
* uploadGradeCsv(formData)

View File

@@ -35,28 +35,28 @@ describe('lms service api', () => {
describe('fetch.assignmentTypes', () => {
testSimpleFetch(
api.fetch.assignmentTypes,
urls.assignmentTypes,
urls.getAssignmentTypesUrl(),
'fetches from urls.assignmentTypes',
);
});
describe('fetch.cohorts', () => {
testSimpleFetch(
api.fetch.cohorts,
urls.cohorts,
urls.getCohortsUrl(),
'fetches from urls.cohorts',
);
});
describe('fetch.roles', () => {
testSimpleFetch(
api.fetch.roles,
urls.roles,
urls.getRolesUrl(),
'fetches from urls.roles',
);
});
describe('fetch.tracks', () => {
testSimpleFetch(
api.fetch.tracks,
urls.tracks,
urls.getTracksUrl(),
'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.gradebook, {
expect(data).toEqual(utils.stringifyUrl(urls.getGradebookUrl(), {
[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.gradebook, {
expect(data).toEqual(utils.stringifyUrl(urls.getGradebookUrl(), {
[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.gradebook, {
expect(data).toEqual(utils.stringifyUrl(urls.getGradebookUrl(), {
[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.bulkHistory);
expect(url).toEqual(urls.getBulkHistoryUrl());
})
));
});
@@ -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.bulkUpdate, data: updateData });
expect(data).toEqual({ url: urls.getBulkUpdateUrl(), data: updateData });
})
));
});

View File

@@ -1,59 +1,54 @@
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() || '';
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 });
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 });
/**
* 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(bulkGrades, { error_id: rowId });
export const bulkGradesUrlByRow = (rowId) => stringifyUrl(getBulkGradesUrl(), { error_id: rowId });
export const gradeCsvUrl = (options = {}) => stringifyUrl(bulkGrades, filterQuery(options));
export const gradeCsvUrl = (options = {}) => stringifyUrl(getBulkGradesUrl(), filterQuery(options));
export const interventionExportCsvUrl = (options = {}) => (
stringifyUrl(intervention, filterQuery(options))
stringifyUrl(getInterventionUrl(), filterQuery(options))
);
export const sectionOverrideHistoryUrl = (subsectionId, userId) => stringifyUrl(
`${grades}subsection/${subsectionId}/`,
`${getGradesUrl()}subsection/${subsectionId}/`,
{ user_id: userId, history_record_limit: historyRecordLimit },
);
export default StrictDict({
assignmentTypes,
bulkGrades,
bulkHistory,
bulkUpdate,
cohorts,
enrollment,
grades,
gradebook,
intervention,
roles,
tracks,
getUrlPrefix,
getBulkGradesUrl,
getEnrollmentUrl,
getGradesUrl,
getGradebookUrl,
getBulkUpdateUrl,
getInterventionUrl,
getCohortsUrl,
getTracksUrl,
getBulkHistoryUrl,
getAssignmentTypesUrl,
getRolesUrl,
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.bulkGrades, { error_id: id }),
utils.stringifyUrl(urls.getBulkGradesUrl(), { 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.bulkGrades, utils.filterQuery(options)),
utils.stringifyUrl(urls.getBulkGradesUrl(), utils.filterQuery(options)),
);
});
it('defaults options to empty object', () => {
expect(gradeCsvUrl()).toEqual(
utils.stringifyUrl(urls.bulkGrades, utils.filterQuery({})),
utils.stringifyUrl(urls.getBulkGradesUrl(), 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.intervention, utils.filterQuery(options)),
utils.stringifyUrl(urls.getInterventionUrl(), utils.filterQuery(options)),
);
});
it('defaults options to empty object', () => {
expect(interventionExportCsvUrl()).toEqual(
utils.stringifyUrl(urls.intervention, utils.filterQuery({})),
utils.stringifyUrl(urls.getInterventionUrl(), utils.filterQuery({})),
);
});
});
@@ -53,7 +53,7 @@ describe('lms api url methods', () => {
const userId = 'Tom';
expect(sectionOverrideHistoryUrl(subsectionId, userId)).toEqual(
utils.stringifyUrl(
`${urls.grades}subsection/${subsectionId}/`,
`${urls.getGradesUrl()}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 (configuration.SEGMENT_KEY) {
if (getConfig().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('../config', () => ({
configuration: {
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(() => ({
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 = configuration.SEGMENT_KEY;
const key = getConfig().SEGMENT_KEY;
beforeEach(() => {
configuration.SEGMENT_KEY = false;
getConfig.mockImplementation(() => ({ 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(() => {
configuration.SEGMENT_KEY = key;
getConfig.mockImplementation(() => ({ SEGMENT_KEY: key }));
});
});
});

21
src/head/Head.jsx Normal file
View File

@@ -0,0 +1,21 @@
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);

17
src/head/Head.test.jsx Normal file
View File

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

11
src/head/messages.js Normal file
View File

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

@@ -7,6 +7,7 @@ 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,6 +21,22 @@ 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,6 +4,7 @@ import ReactDOM from 'react-dom';
import {
APP_READY,
initialize,
mergeConfig,
subscribe,
} from '@edx/frontend-platform';
import { messages as headerMessages } from '@edx/frontend-component-header';
@@ -19,6 +20,7 @@ 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', () => ({
@@ -46,10 +48,23 @@ describe('app registry', () => {
ReactDOM.render(<App />, document.getElementById('root')),
);
});
test('initialize is called with footerMessages and requireAuthenticatedUser', () => {
test('initialize is called with requireAuthenticatedUser, messages, and a config handler', () => {
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 { configuration } from './config';
import { getConfig } from '@edx/frontend-platform';
(function () {
// Create a queue, but don't obliterate an existing one!
@@ -81,5 +81,5 @@ import { configuration } from './config';
// Load Analytics.js with your key, which will automatically
// load the tools you've enabled for your account. Boosh!
analytics.load(configuration.SEGMENT_KEY);
analytics.load(getConfig().SEGMENT_KEY);
}());

View File

@@ -8,6 +8,8 @@ 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');

187
src/testUtils.js Normal file
View File

@@ -0,0 +1,187 @@
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,6 +4,10 @@ const strictGet = (target, name) => {
return target;
}
if (name === '$$typeof') {
return typeof target;
}
if (name in target || name === '_reactFragment') {
return target[name];
}

View File

@@ -45,6 +45,9 @@ 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