Compare commits

...

5 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
27 changed files with 266 additions and 117 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=''

60
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.5.0",
"version": "1.6.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@edx/frontend-app-gradebook",
"version": "1.5.0",
"version": "1.6.0",
"license": "AGPL-3.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.3.0",
"@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",
@@ -31,6 +31,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",
@@ -3986,9 +3987,9 @@
}
},
"node_modules/@edx/frontend-platform": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-2.3.0.tgz",
"integrity": "sha512-vZAw3eKJgUvD3wu8QOlCbNvuhe9YOGhdVuiTiFGMJKsYagJNMuQZxTJ2DwPCr7/gprJ65mboisJ3BF5IoFzVJA==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-2.5.0.tgz",
"integrity": "sha512-Ws40TMkxrF9Fz71K8bqp+qui7kXYOBvl8+PYLa1K0lmzwD70FFU73mQBTvgTJKKWcw8VsjK9oJCxmjGvz6Qe1Q==",
"dependencies": {
"@cospired/i18n-iso-languages": "2.2.0",
"@formatjs/intl-pluralrules": "^4.3.3",
@@ -4014,7 +4015,7 @@
"transifex-utils.js": "i18n/scripts/transifex-utils.js"
},
"peerDependencies": {
"@edx/paragon": ">= 10.0.0 < 20.0.0",
"@edx/paragon": ">= 10.0.0 < 21.0.0",
"prop-types": "^15.7.2",
"react": "^16.9.0",
"react-dom": "^16.9.0",
@@ -28541,6 +28542,20 @@
}
}
},
"node_modules/react-helmet": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
"integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
"dependencies": {
"object-assign": "^4.1.1",
"prop-types": "^15.7.2",
"react-fast-compare": "^3.1.1",
"react-side-effect": "^2.1.0"
},
"peerDependencies": {
"react": ">=16.3.0"
}
},
"node_modules/react-intl": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-2.9.0.tgz",
@@ -28803,6 +28818,14 @@
"isarray": "0.0.1"
}
},
"node_modules/react-side-effect": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
"integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==",
"peerDependencies": {
"react": "^16.3.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.1.1.tgz",
@@ -37680,9 +37703,9 @@
}
},
"@edx/frontend-platform": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-2.3.0.tgz",
"integrity": "sha512-vZAw3eKJgUvD3wu8QOlCbNvuhe9YOGhdVuiTiFGMJKsYagJNMuQZxTJ2DwPCr7/gprJ65mboisJ3BF5IoFzVJA==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-2.5.0.tgz",
"integrity": "sha512-Ws40TMkxrF9Fz71K8bqp+qui7kXYOBvl8+PYLa1K0lmzwD70FFU73mQBTvgTJKKWcw8VsjK9oJCxmjGvz6Qe1Q==",
"requires": {
"@cospired/i18n-iso-languages": "2.2.0",
"@formatjs/intl-pluralrules": "^4.3.3",
@@ -56733,6 +56756,17 @@
"use-sidecar": "^1.0.5"
}
},
"react-helmet": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
"integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
"requires": {
"object-assign": "^4.1.1",
"prop-types": "^15.7.2",
"react-fast-compare": "^3.1.1",
"react-side-effect": "^2.1.0"
}
},
"react-intl": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-2.9.0.tgz",
@@ -56936,6 +56970,12 @@
}
}
},
"react-side-effect": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
"integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==",
"requires": {}
},
"react-style-singleton": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.1.1.tgz",

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",
@@ -33,7 +33,7 @@
"@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.3.0",
"@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",
@@ -52,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",

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

@@ -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

@@ -21,15 +21,28 @@ 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}
/>

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,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

@@ -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');