refactor: lms service testing (#199)

* v1.4.40

* ignore accepted import eslint errors

* clean up LmsApiService into smaller, tested modules in lms service

* set default format before initial fetches

* fix bulk grades export and grade filtering

* fix clearing assignment grade filter badge

* re-connect grade format control
This commit is contained in:
Ben Warzeski
2021-06-30 11:50:07 -04:00
committed by GitHub
parent 1ab2fee004
commit 15b76edb5d
33 changed files with 804 additions and 314 deletions

View File

@@ -1,6 +1,13 @@
const { createConfig } = require('@edx/frontend-build');
const config = createConfig('eslint');
const config = createConfig('eslint', {
rules: {
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off',
'import/no-self-import': 'off',
'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
},
});
config.settings = {
"import/resolver": {

View File

@@ -1,6 +1,6 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.4.39",
"version": "1.4.40",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",

View File

@@ -55,7 +55,6 @@ HistoryTable.propTypes = {
timeUploaded: PropTypes.string.isRequired,
resultsSummary: PropTypes.shape({
rowId: PropTypes.number.isRequired,
courseId: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
}),
})),

View File

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
import { Hyperlink, Icon } from '@edx/paragon';
import { Download } from '@edx/paragon/icons';
import { bulkGradesUrlByCourseAndRow } from 'data/constants/api';
import lms from 'data/services/lms';
/**
* <ResultsSummary {...{ courseId, rowId, text }} />
@@ -15,12 +15,11 @@ import { bulkGradesUrlByCourseAndRow } from 'data/constants/api';
* @param {string} text - summary string
*/
const ResultsSummary = ({
courseId,
rowId,
text,
}) => (
<Hyperlink
href={bulkGradesUrlByCourseAndRow(courseId, rowId)}
href={lms.urls.bulkGradesUrlByRow(rowId)}
destination="www.edx.org"
target="_blank"
rel="noopener noreferrer"
@@ -32,7 +31,6 @@ const ResultsSummary = ({
);
ResultsSummary.propTypes = {
courseId: PropTypes.string.isRequired,
rowId: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
};

View File

@@ -4,7 +4,7 @@ import { shallow } from 'enzyme';
import { Icon } from '@edx/paragon';
import { Download } from '@edx/paragon/icons';
import * as api from 'data/constants/api';
import lms from 'data/services/lms';
import ResultsSummary from './ResultsSummary';
jest.mock('@edx/paragon', () => ({
@@ -14,13 +14,14 @@ jest.mock('@edx/paragon', () => ({
jest.mock('@edx/paragon/icons', () => ({
Download: 'DownloadIcon',
}));
jest.mock('data/constants/api', () => ({
bulkGradesUrlByCourseAndRow: jest.fn((courseId, rowId) => ({ url: { courseId, rowId } })),
jest.mock('data/services/lms', () => ({
urls: {
bulkGradesUrlByRow: jest.fn((rowId) => ({ url: { rowId } })),
},
}));
describe('ResultsSummary component', () => {
const props = {
courseId: 'classy',
rowId: 42,
text: 'texty',
};
@@ -41,7 +42,7 @@ describe('ResultsSummary component', () => {
expect(el.props().rel).toEqual('noopener noreferrer');
});
test('Hyperlink has href to bulkGradesUrl', () => {
expect(el.props().href).toEqual(api.bulkGradesUrlByCourseAndRow(props.courseId, props.rowId));
expect(el.props().href).toEqual(lms.urls.bulkGradesUrlByRow(props.rowId));
});
test('displays Download Icon and text', () => {
const icon = el.childAt(0);

View File

@@ -6,7 +6,6 @@ exports[`ResultsSummary component snapshot - safe hyperlink with bulkGradesUrl w
href={
Object {
"url": Object {
"courseId": "classy",
"rowId": 42,
},
}

View File

@@ -24,8 +24,11 @@ export const ScoreViewInput = ({ format, toggleFormat }) => (
</FormControl>
</FormGroup>
);
ScoreViewInput.defaultProps = {
format: 'percent',
};
ScoreViewInput.propTypes = {
format: PropTypes.string.isRequired,
format: PropTypes.string,
toggleFormat: PropTypes.func.isRequired,
};
@@ -37,4 +40,4 @@ export const mapDispatchToProps = {
toggleFormat: actions.grades.toggleGradeFormat,
};
export default connect(() => ({}), mapDispatchToProps)(ScoreViewInput);
export default connect(mapStateToProps, mapDispatchToProps)(ScoreViewInput);

View File

@@ -1,14 +0,0 @@
import { configuration } from 'config';
export const baseUrl = `${configuration.LMS_BASE_URL}/api`;
/**
* bulkGradesUrlByCourseAndRow(courseId, rowId)
* returns the bulkGrades url with the given courseId and rowId.
* @param {string} courseId - course identifier
* @param {string} rowId - row/error identifier
* @return {string} - bulk grades fetch url
*/
export const bulkGradesUrlByCourseAndRow = (courseId, rowId) => (
`${baseUrl}/bulkGrades/course/${courseId}/?error_id=${rowId}`
);

View File

@@ -37,8 +37,8 @@ export const filterConfig = StrictDict({
},
[filters.assignmentGrade]: {
displayName: 'Assignment Grade',
filterOrder: ['courseGradeMin', 'courseGradeMax'],
connectedFilters: ['courseGradeMax', 'courseGradeMin'],
filterOrder: ['assignmentGradeMin', 'assignmentGradeMax'],
connectedFilters: ['assignmentGradeMax', 'assignmentGradeMin'],
},
[filters.cohort]: {
displayName: 'Cohort',

View File

@@ -133,7 +133,6 @@ export const transformHistoryEntry = ({
originalFilename,
resultsSummary: {
rowId: id,
courseId,
text: module.getRowsProcessed(data),
},
...rest,

View File

@@ -200,7 +200,6 @@ describe('grades selectors', () => {
it('summarizes processed rows', () => {
expect(output.resultsSummary).toEqual({
text: selectors.getRowsProcessed(rawEntry.data),
courseId: rawEntry.unique_id,
rowId: rawEntry.id,
});
});

View File

@@ -1,7 +1,6 @@
/* eslint-disable import/no-named-as-default-member, import/no-self-import */
import { StrictDict } from 'utils';
import LmsApiService from 'data/services/LmsApiService';
import lms from 'data/services/lms';
import * as filterConstants from 'data/constants/filters';
import * as module from '.';
@@ -122,13 +121,13 @@ export const getHeadings = (state) => grades.headingMapper(
/**
* gradeExportUrl(state, options)
* Returns the output of getGradeExportCsvUrl, applying the current includeCourseRoleMembers
* Returns the output of getGradeCsvUrl, applying the current includeCourseRoleMembers
* filter.
* @param {object} state - redux state
* @return {string} - generated grade export url
*/
export const gradeExportUrl = (state) => (
LmsApiService.getGradeExportCsvUrl(app.courseId(state), {
lms.urls.gradeCsvUrl({
...module.lmsApiServiceArgs(state),
excludeCourseRoles: filters.includeCourseRoleMembers(state) ? '' : 'all',
})
@@ -141,8 +140,7 @@ export const gradeExportUrl = (state) => (
* @return {string} - generated intervention export url
*/
export const interventionExportUrl = (state) => (
LmsApiService.getInterventionExportCsvUrl(
app.courseId(state),
lms.urls.interventionExportCsvUrl(
module.lmsApiServiceArgs(state),
)
);

View File

@@ -1,17 +1,16 @@
/* eslint-disable import/no-named-as-default-member */
/* eslint-disable import/no-named-as-default-member, import/no-named-as-default */
import * as filterConstants from '../constants/filters';
import selectors from '.';
import * as moduleSelectors from '.';
import { minGrade, maxGrade } from './grades';
jest.mock('../services/LmsApiService', () => ({
__esModule: true,
default: {
getGradeExportCsvUrl: jest.fn(
(...args) => ({ getGradeExportCsvUrl: { args } }),
jest.mock('data/services/lms', () => ({
urls: {
gradeCsvUrl: jest.fn(
(...args) => ({ gradeCsvUrl: { args } }),
),
getInterventionExportCsvUrl: jest.fn(
(...args) => ({ getInterventionExportCsvUrl: { args } }),
interventionExportCsvUrl: jest.fn(
(...args) => ({ interventionExportCsvUrl: { args } }),
),
},
}));
@@ -344,8 +343,8 @@ describe('root selectors', () => {
it('calls the API service with the right args, excluding all course roles', () => {
selectors.filters.includeCourseRoleMembers.mockReturnValue(undefined);
expect(selector(testState)).toEqual({
getGradeExportCsvUrl: {
args: [testCourseId, { lmsArgs: testState, excludeCourseRoles: 'all' }],
gradeCsvUrl: {
args: [{ lmsArgs: testState, excludeCourseRoles: 'all' }],
},
});
});
@@ -354,8 +353,8 @@ describe('root selectors', () => {
it('calls the API service with the right args, including course roles', () => {
selectors.filters.includeCourseRoleMembers.mockReturnValue(true);
expect(selector(testState)).toEqual({
getGradeExportCsvUrl: {
args: [testCourseId, { lmsArgs: testState, excludeCourseRoles: '' }],
gradeCsvUrl: {
args: [{ lmsArgs: testState, excludeCourseRoles: '' }],
},
});
});
@@ -369,8 +368,8 @@ describe('root selectors', () => {
expect(
moduleSelectors.interventionExportUrl(testState),
).toEqual({
getInterventionExportCsvUrl: {
args: [testCourseId, { lmsArgs: testState }],
interventionExportCsvUrl: {
args: [{ lmsArgs: testState }],
},
});
moduleSelectors.lmsApiServiceArgs = lmsApiServiceArgs;

View File

@@ -1,143 +0,0 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { configuration } from '../../config';
class LmsApiService {
static baseUrl = configuration.LMS_BASE_URL;
static pageSize = 25
static fetchGradebookData(courseId, searchText, cohort, track, options = {}) {
const queryParams = {};
queryParams.page_size = LmsApiService.pageSize;
if (searchText) {
queryParams.user_contains = searchText;
}
if (cohort) {
queryParams.cohort_id = cohort;
}
if (track) {
queryParams.enrollment_mode = track;
}
if (options.assignmentGradeMax || options.assignmentGradeMin) {
if (!options.assignment) {
throw new Error('Gradebook LMS API requires assignment to be set to filter by min/max assig. grade');
}
queryParams.assignment = options.assignment;
if (options.assignmentGradeMin) {
queryParams.assignment_grade_min = options.assignmentGradeMin;
}
if (options.assignmentGradeMax) {
queryParams.assignment_grade_max = options.assignmentGradeMax;
}
}
if (options.courseGradeMin) {
queryParams.course_grade_min = options.courseGradeMin;
}
if (options.courseGradeMax) {
queryParams.course_grade_max = options.courseGradeMax;
}
if (!options.includeCourseRoleMembers) {
queryParams.excluded_course_roles = ['all'];
}
const queryParamString = Object.keys(queryParams)
.map(attr => `${attr}=${encodeURIComponent(queryParams[attr])}`)
.join('&');
const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/?${queryParamString}`;
return getAuthenticatedHttpClient().get(gradebookUrl);
}
static updateGradebookData(courseId, updateData) {
/*
updateData is expected to be a list of objects with the keys 'user_id' (an integer),
'usage_id' (a string) and 'grade', which is an object with the keys:
'earned_all_override',
'possible_all_override',
'earned_graded_override',
and 'possible_graded_override',
each of which should be an integer.
Example:
[
{
"user_id": 9,
"usage_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions",
"grade": {
"earned_all_override": 11,
"possible_all_override": 11,
"earned_graded_override": 11,
"possible_graded_override": 11,
"comment": "reason for override"
}
}
]
*/
const gradebookUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/bulk-update`;
return getAuthenticatedHttpClient().post(gradebookUrl, updateData);
}
static fetchTracks(courseId) {
const trackUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/course/${courseId}?include_expired=1`;
return getAuthenticatedHttpClient().get(trackUrl);
}
static fetchCohorts(courseId) {
const cohortsUrl = `${LmsApiService.baseUrl}/courses/${courseId}/cohorts/`;
return getAuthenticatedHttpClient().get(cohortsUrl);
}
static fetchAssignmentTypes(courseId) {
const assignmentTypesUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`;
return getAuthenticatedHttpClient().get(assignmentTypesUrl);
}
static fetchUserRoles(courseId) {
const rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/?course_id=${encodeURIComponent(courseId)}`;
return getAuthenticatedHttpClient().get(rolesUrl);
}
static getGradeExportCsvUrl(courseId, options = {}) {
const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax',
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax', 'excludedCourseRoles']
.filter(opt => options[opt]
&& options[opt] !== 'All')
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`)
.join('&');
return `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/?${queryParams}`;
}
static getInterventionExportCsvUrl(courseId, options = {}) {
const queryParams = ['track', 'cohort', 'assignment', 'assignmentType', 'assignmentGradeMax',
'assignmentGradeMin', 'courseGradeMin', 'courseGradeMax']
.filter(opt => options[opt]
&& options[opt] !== 'All')
.map(opt => `${opt}=${encodeURIComponent(options[opt])}`)
.join('&');
return `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/intervention?${queryParams}`;
}
static getGradeImportCsvUrl = LmsApiService.getGradeExportCsvUrl;
static uploadGradeCsv(courseId, formData) {
const fileUploadUrl = LmsApiService.getGradeImportCsvUrl(courseId);
return getAuthenticatedHttpClient().post(fileUploadUrl, formData).then((result) => {
if (result.status === 200 && !result.data.error_messages.length) {
return result.data;
}
return Promise.reject(result);
});
}
static fetchGradeBulkOperationHistory(courseId) {
const url = `${LmsApiService.baseUrl}/api/bulk_grades/course/${courseId}/history/`;
return getAuthenticatedHttpClient().get(url).then(response => response.data).catch(() => Promise.reject(Error('unhandled response error')));
}
static fetchGradeOverrideHistory(subsectionId, userId) {
const historyUrl = `${LmsApiService.baseUrl}/api/grades/v1/subsection/${subsectionId}/?user_id=${userId}&history_record_limit=5`;
return getAuthenticatedHttpClient().get(historyUrl);
}
}
export default LmsApiService;

View File

@@ -0,0 +1,120 @@
import { StrictDict } from 'utils';
import urls, {
gradeCsvUrl,
sectionOverrideHistoryUrl,
} from './urls';
import { pageSize, paramKeys } from './constants';
import messages from './messages';
import * as utils from './utils';
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);
/**
* fetch.gradebookData(searchText, cohort, track, options)
* fetches updated gradebook data based on current filter selections.
* Raises an error if assignment grade limits are set, but not assignment.
* @param {string} searchText - search text filter
* @param {nunber} cohort - selected cohort filter
* @param {string} track - selected track filter
* @param {object} options - additional optional filter values
* @return {Promise} - get response
*/
const gradebookData = (searchText, cohort, track, options = {}) => {
if ((options.assignmentGradeMax || options.assignmentGradeMin) && !options.assignment) {
throw new Error(messages.errors.missingAssignment);
}
const queryParams = {
[paramKeys.pageSize]: pageSize,
[paramKeys.userContains]: searchText,
[paramKeys.cohortId]: cohort,
[paramKeys.enrollmentMode]: track,
[paramKeys.courseGradeMax]: options.courseGradeMax,
[paramKeys.courseGradeMin]: options.courseGradeMin,
[paramKeys.excludedCourseRoles]: options.includeCourseRoleMembers ? null : ['all'],
[paramKeys.assignment]: options.assignment,
[paramKeys.assignmentGradeMax]: options.assignmentGradeMax,
[paramKeys.assignmentGradeMin]: options.assignmentGradeMin,
};
return get(stringifyUrl(urls.gradebook, queryParams));
};
/**
* fetch.gradeBulkOperationHistory()
* fetches bulk operation history and raises an error if the operation fails
* @return {Promise} - get response
*/
const gradeBulkOperationHistory = () => get(urls.bulkHistory)
.then(response => response.data)
.catch(() => Promise.reject(Error(messages.errors.unhandledResponse)));
/**
* fetch.gradeOverrideHistory(subsectionId, userId)
* fetches grade override history for a given user on a given subsection
* @param {string} subsectionId - subsection identifier
* @param {string} userId - user identifier
* @return {Promise} - get response
*/
const gradeOverrideHistory = (subsectionId, userId) => (
get(sectionOverrideHistoryUrl(subsectionId, userId))
);
/*********************************************************************************
* POST Actions
*********************************************************************************/
/**
* updateGradebookData(updateData)
* sends an update message with new grades overrides
* @param {object[]} updateData
* {
* user_id: <int>,
* usage_id: <string>
* grade: {
* earned_all_override: <int>
* possible_all_override: <int>
* earned_graded_override: <int>
* possible_graded_override: <int>
* }
* }
* @return {Promise} - post response
*/
const updateGradebookData = (updateData) => post(urls.bulkUpdate, updateData);
/**
* uploadGradeCsv(formData)
* Posts form data to grade csv url. On success, forwards response data.
* Reject promise with result on failure.
* @param {object} formData - new grade data
* @return {Promise} - post response
*/
const uploadGradeCsv = (formData) => (
post(gradeCsvUrl(), formData).then((result) => {
if (result.status === 200 && !result.data.error_messages.length) {
return result.data;
}
return Promise.reject(result);
})
);
export default StrictDict({
fetch: StrictDict({
assignmentTypes,
cohorts,
gradebookData,
gradeBulkOperationHistory,
gradeOverrideHistory,
roles,
tracks,
}),
updateGradebookData,
uploadGradeCsv,
});

View File

@@ -0,0 +1,234 @@
import api from './api';
import { pageSize, paramKeys } from './constants';
import messages from './messages';
import urls, { gradeCsvUrl, sectionOverrideHistoryUrl } from './urls';
import * as utils from './utils';
jest.mock('./urls', () => ({
__esModule: true,
default: jest.requireActual('./urls').default,
gradeCsvUrl: (...args) => ({ gradeCsvUrl: args }),
sectionOverrideHistoryUrl: (...args) => `sectionOverrideHistoryUrl(${args})`,
}));
jest.mock('./utils', () => ({
get: jest.fn(),
post: jest.fn(),
stringifyUrl: jest.fn(),
}));
describe('lms service api', () => {
describe('get actions', () => {
const mockGet = promiseFn => {
jest.spyOn(utils, 'get').mockImplementation(
url => new Promise(promiseFn(url)),
);
};
const resolveFn = (url) => (resolve) => resolve({ data: url });
const rejectFn = (url) => (resolve, reject) => reject(url);
const testSimpleFetch = (method, expectedUrl, description) => {
mockGet(resolveFn);
test(description, () => (
method().then(({ data }) => { expect(data).toEqual(expectedUrl); })
));
};
describe('fetch.assignmentTypes', () => {
testSimpleFetch(
api.fetch.assignmentTypes,
urls.assignmentTypes,
'fetches from urls.assignmentTypes',
);
});
describe('fetch.cohorts', () => {
testSimpleFetch(
api.fetch.cohorts,
urls.cohorts,
'fetches from urls.cohorts',
);
});
describe('fetch.roles', () => {
testSimpleFetch(
api.fetch.roles,
urls.roles,
'fetches from urls.roles',
);
});
describe('fetch.tracks', () => {
testSimpleFetch(
api.fetch.tracks,
urls.tracks,
'fetches from urls.tracks',
);
});
describe('fetch.gradebookData', () => {
const searchText = 'some user';
const cohort = 2;
const track = 'masters';
const options = {
courseGradeMax: 90,
courseGradeMin: 10,
includeCourseRoleMembers: true,
assignment: 'some work',
assignmentGradeMax: 95,
assignmentGradeMin: 5,
};
it('throws an error if either assignmentGrade limit is set, but no assignment', () => {
mockGet(resolveFn);
expect(() => {
api.fetch.gradebookData(
searchText,
cohort,
track,
{ ...options, assignmentGradeMax: null, assignment: null },
);
}).toThrow(Error(messages.errors.missingAssignment));
expect(() => {
api.fetch.gradebookData(
searchText,
cohort,
track,
{ ...options, assignmentGradeMin: null, assignment: null },
);
}).toThrow(Error(messages.errors.missingAssignment));
});
describe('fetches from urls.gradebook with queryParams loaded from options', () => {
beforeEach(() => {
mockGet(resolveFn);
});
test('loads only passed values if options is empty', () => (
api.fetch.gradebookData(searchText, cohort, track).then(({ data }) => {
expect(data).toEqual(utils.stringifyUrl(urls.gradebook, {
[paramKeys.pageSize]: pageSize,
[paramKeys.userContains]: searchText,
[paramKeys.cohortId]: cohort,
[paramKeys.enrollmentMode]: track,
[paramKeys.courseGradeMax]: undefined,
[paramKeys.courseGradeMin]: undefined,
[paramKeys.excludedCourseRoles]: undefined,
[paramKeys.assignment]: undefined,
[paramKeys.assignmentGradeMax]: undefined,
[paramKeys.assignmentGradeMin]: undefined,
}));
})
));
test('loads ["all"] for excludedCorseRoles if not includeCourseRoles', () => (
api.fetch.gradebookData(searchText, cohort, track, options).then(({ data }) => {
expect(data).toEqual(utils.stringifyUrl(urls.gradebook, {
[paramKeys.pageSize]: pageSize,
[paramKeys.userContains]: searchText,
[paramKeys.cohortId]: cohort,
[paramKeys.enrollmentMode]: track,
[paramKeys.courseGradeMax]: options.courseGradeMax,
[paramKeys.courseGradeMin]: options.courseGradeMin,
[paramKeys.excludedCourseRoles]: ['all'],
[paramKeys.assignment]: options.assignment,
[paramKeys.assignmentGradeMax]: options.assignmentGradeMax,
[paramKeys.assignmentGradeMin]: options.assignmentGradeMin,
}));
})
));
test('loads null for excludedCorseRoles if includeCourseRoles', () => (
api.fetch.gradebookData(searchText, cohort, track, options).then(({ data }) => {
expect(data).toEqual(utils.stringifyUrl(urls.gradebook, {
[paramKeys.pageSize]: pageSize,
[paramKeys.userContains]: searchText,
[paramKeys.cohortId]: cohort,
[paramKeys.enrollmentMode]: track,
[paramKeys.courseGradeMax]: options.courseGradeMax,
[paramKeys.courseGradeMin]: options.courseGradeMin,
[paramKeys.excludedCourseRoles]: null,
[paramKeys.assignment]: options.assignment,
[paramKeys.assignmentGradeMax]: options.assignmentGradeMax,
[paramKeys.assignmentGradeMin]: options.assignmentGradeMin,
}));
})
));
});
});
describe('gradeBulkOperationHistory', () => {
describe('success', () => {
beforeEach(() => {
mockGet(resolveFn);
});
it('fetches from urls.bulkHistory and returns the data', () => (
api.fetch.gradeBulkOperationHistory().then(url => {
expect(url).toEqual(urls.bulkHistory);
})
));
});
describe('failure', () => {
beforeEach(() => {
mockGet(rejectFn);
});
it('rejects with unhandledResponse Error', () => (
api.fetch.gradeBulkOperationHistory().catch(error => {
expect(error).toEqual(Error(messages.errors.unhandledResponse));
})
));
});
});
describe('gradeOverrideHistory', () => {
const subsectionId = 'a subsection';
const userId = 'Thomas';
beforeEach(() => {
mockGet(resolveFn);
});
test('gets from urls.sectionOverrideHistoryUrl with passed subseciton and user ids', () => (
api.fetch.gradeOverrideHistory(subsectionId, userId).then(({ data }) => {
expect(data).toEqual(sectionOverrideHistoryUrl(subsectionId, userId));
})
));
});
});
describe('post actions', () => {
const mockPost = promiseFn => {
jest.spyOn(utils, 'post').mockImplementation(
(url, callback) => new Promise(promiseFn(url, callback)),
);
};
const resolveFn = (url, data) => (resolve) => resolve({ data: { url, data } });
describe('updateGradebookData', () => {
const updateData = { some: 'update data' };
beforeEach(() => {
mockPost(resolveFn);
});
test('posts to urls.bulkUpdate with passed data', () => (
api.updateGradebookData(updateData).then(({ data }) => {
expect(data).toEqual({ url: urls.bulkUpdate, data: updateData });
})
));
});
describe('uploadGradeCsv', () => {
describe('200 status with no error_messages', () => {
const response = {
status: 200,
data: {
error_messages: [],
other: 'data',
},
};
const formData = { some: 'form Data' };
beforeEach(() => {
mockPost(() => (resolve) => { resolve(response); });
});
it('posts formData to gradeCsvUrl and returns the data from response', () => (
api.uploadGradeCsv(formData).then(result => {
expect(result).toEqual(response.data);
})
));
});
describe('non-200 status', () => {
const formData = { some: 'form Data' };
beforeEach(() => {
mockPost((url, data) => (resolve) => { resolve({ url, data }); });
});
it('posts formData to gradeCsvUrl and returns the data from response', () => (
api.uploadGradeCsv(formData).catch(result => {
expect(result).toEqual({ url: gradeCsvUrl(), data: formData });
})
));
});
});
});
});

View File

@@ -0,0 +1,17 @@
import { StrictDict } from 'utils';
export const pageSize = 25;
export const historyRecordLimit = 5;
export const paramKeys = StrictDict({
cohortId: 'cohort_id',
pageSize: 'page_size',
userContains: 'user_contains',
enrollmentMode: 'enrollment_mode',
assignment: 'assignment',
assignmentGradeMin: 'assignment_grade_min',
assignmentGradeMax: 'assignment_grade_max',
courseGradeMin: 'course_grade_min',
courseGradeMax: 'course_grade_max',
excludedCourseRoles: 'excluded_course_roles',
});

View File

@@ -0,0 +1,8 @@
import { StrictDict } from 'utils';
import api from './api';
import urls from './urls';
export default StrictDict({
api,
urls,
});

View File

@@ -0,0 +1,10 @@
import { StrictDict } from 'utils';
export default StrictDict({
errors: {
missingAssignment: (
'Gradebook LMS API requires assignment to be set to filter by min/max assig. grade'
),
unhandledResponse: 'unhandled response error',
},
});

View File

@@ -0,0 +1,61 @@
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.slice(1);
const api = `${baseUrl}/api/`;
const bulkGrades = `${api}bulk_grades/course/${courseId}/`;
const enrollment = `${api}enrollment/v1/`;
const grades = `${api}grades/v1/`;
const gradebook = `${grades}gradebook/${courseId}/`;
const bulkUpdate = `${gradebook}bulk-update`;
const intervention = `${bulkGrades}intervention/`;
const cohorts = `${baseUrl}courses/${courseId}/cohorts/`;
const tracks = `${enrollment}course/${courseId}?include_expired=1`;
const bulkHistory = `${bulkGrades}history/`;
const assignmentTypes = stringifyUrl(`${gradebook}grading-info`, { graded_only: true });
const roles = stringifyUrl(`${enrollment}roles/`, { courseId });
/**
* bulkGradesUrlByCourseAndRow(courseId, rowId)
* returns the bulkGrades url with the given rowId.
* @param {string} rowId - row/error identifier
* @return {string} - bulk grades fetch url
*/
export const bulkGradesUrlByRow = (rowId) => stringifyUrl(bulkGrades, { error_id: rowId });
export const gradeCsvUrl = (options = {}) => stringifyUrl(bulkGrades, filterQuery(options));
export const interventionExportCsvUrl = (options = {}) => (
stringifyUrl(intervention, filterQuery(options))
);
export const sectionOverrideHistoryUrl = (subsectionId, userId) => stringifyUrl(
`${grades}subsection/${subsectionId}/`,
{ user_id: userId, history_record_limit: historyRecordLimit },
);
export default StrictDict({
assignmentTypes,
bulkGrades,
bulkHistory,
bulkUpdate,
cohorts,
enrollment,
grades,
gradebook,
intervention,
roles,
tracks,
bulkGradesUrlByRow,
gradeCsvUrl,
interventionExportCsvUrl,
sectionOverrideHistoryUrl,
});

View File

@@ -0,0 +1,62 @@
import { historyRecordLimit } from './constants';
import * as utils from './utils';
import urls, {
bulkGradesUrlByRow,
gradeCsvUrl,
interventionExportCsvUrl,
sectionOverrideHistoryUrl,
} from './urls';
jest.mock('./utils', () => ({
filterQuery: jest.fn(options => ({ filterQuery: options })),
stringifyUrl: jest.fn((url, query) => ({ url, query })),
}));
describe('lms api url methods', () => {
describe('bulkGradesUrlByRow', () => {
it('returns bulkGrades url with error_id', () => {
const id = 'heyo';
expect(bulkGradesUrlByRow(id)).toEqual(
utils.stringifyUrl(urls.bulkGrades, { error_id: id }),
);
});
});
describe('gradeCsvUrl', () => {
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)),
);
});
it('defaults options to empty object', () => {
expect(gradeCsvUrl()).toEqual(
utils.stringifyUrl(urls.bulkGrades, utils.filterQuery({})),
);
});
});
describe('interventionExportCsvUrl', () => {
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)),
);
});
it('defaults options to empty object', () => {
expect(interventionExportCsvUrl()).toEqual(
utils.stringifyUrl(urls.intervention, utils.filterQuery({})),
);
});
});
describe('sectionOverrideHistoryUrl', () => {
it('returns grades url with subsection id, and user_id/history_record_limit query', () => {
const subsectionId = 'a sub section';
const userId = 'Tom';
expect(sectionOverrideHistoryUrl(subsectionId, userId)).toEqual(
utils.stringifyUrl(
`${urls.grades}subsection/${subsectionId}/`,
{ user_id: userId, history_record_limit: historyRecordLimit },
),
);
});
});
});

View File

@@ -0,0 +1,42 @@
import queryString from 'query-string';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { filters } from 'data/constants/filters';
/**
* get(url)
* simple wrapper providing an authenticated Http client get action
* @param {string} url - target url
*/
export const get = (...args) => getAuthenticatedHttpClient().get(...args);
/**
* post(url, data)
* simple wrapper providing an authenticated Http client post action
* @param {string} url - target url
* @param {object|string} data - post payload
*/
export const post = (...args) => getAuthenticatedHttpClient().post(...args);
/**
* stringifyUrl(url, query)
* simple wrapper around queryString.stringifyUrl that sets skip behavior
* @param {string} url - base url string
* @param {object} query - query parameters
*/
export const stringifyUrl = (url, query) => queryString.stringifyUrl(
{ url, query },
{ skipNull: true, skipEmptyString: true },
);
/**
* filterQuery(options)
* Takes current filter object and returns it with only valid filters that are
* set and have non-'All' values
* @param {object} options - filter values
* @return {object} - valid filters that are set and do not equal 'All'
*/
export const filterQuery = (options) => Object.values(filters)
.filter(filter => options[filter] && options[filter] !== 'All')
.reduce(
(obj, filter) => ({ ...obj, [filter]: options[filter] }),
{},
);

View File

@@ -0,0 +1,97 @@
import queryString from 'query-string';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { filters } from 'data/constants/filters';
import * as utils from './utils';
jest.mock('query-string', () => ({
stringifyUrl: jest.fn((url, options) => ({ url, options })),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
describe('lms service utils', () => {
describe('get', () => {
it('forwards arguments to authenticatedHttpClient().get', () => {
const get = jest.fn((...args) => ({ get: args }));
getAuthenticatedHttpClient.mockReturnValue({ get });
const args = ['some', 'args', 'for', 'the', 'test'];
expect(utils.get(...args)).toEqual(get(...args));
});
});
describe('post', () => {
it('forwards arguments to authenticatedHttpClient().post', () => {
const post = jest.fn((...args) => ({ post: args }));
getAuthenticatedHttpClient.mockReturnValue({ post });
const args = ['some', 'args', 'for', 'the', 'test'];
expect(utils.post(...args)).toEqual(post(...args));
});
});
describe('stringifyUrl', () => {
it('forwards url and query to stringifyUrl with options to skip null and ""', () => {
const url = 'here.com';
const query = { some: 'set', of: 'queryParams' };
const options = { skipNull: true, skipEmptyString: true };
expect(utils.stringifyUrl(url, query)).toEqual(
queryString.stringifyUrl({ url, query }, options),
);
});
});
describe('filterQuery', () => {
it('returns all filters included in validated list that are not "All"', () => {
const goodOptions = {
[filters.assignmentType]: 'quiz',
[filters.courseGradeMax]: 100,
[filters.courseGradeMin]: 1,
};
const extraOptions = {
fake: 'option',
another: 'fake one',
};
expect(utils.filterQuery({
...goodOptions,
...extraOptions,
[filters.includeCourseRoleMembers]: 'All',
})).toEqual(goodOptions);
});
});
});
/**
* get(url)
* simple wrapper providing an authenticated Http client get action
* @param {string} url - target url
*/
export const get = (...args) => getAuthenticatedHttpClient().get(...args);
/**
* post(url, data)
* simple wrapper providing an authenticated Http client post action
* @param {string} url - target url
* @param {object|string} data - post payload
*/
export const post = (...args) => getAuthenticatedHttpClient().post(...args);
/**
* stringifyUrl(url, query)
* simple wrapper around queryString.stringifyUrl that sets skip behavior
* @param {string} url - base url string
* @param {object} query - query parameters
*/
export const stringifyUrl = (url, query) => queryString.stringifyUrl(
{ url, query },
{ skipNull: true, skipEmptyString: true },
);
/**
* filterQuery(options)
* Takes current filter object and returns it with only valid filters that are
* set and have non-'All' values
* @param {object} options - filter values
* @return {object} - valid filters that are set and do not equal 'All'
*/
export const filterQuery = (options) => Object.values(filters)
.filter(filter => options[filter] && options[filter] !== 'All')
.reduce(
(obj, filter) => ({ ...obj, [filter]: options[filter] }),
{},
);

View File

@@ -1,16 +1,15 @@
/* eslint-disable import/prefer-default-export */
import { StrictDict } from 'utils';
import actions from 'data/actions';
import selectors from 'data/selectors';
import LmsApiService from 'data/services/LmsApiService';
import lms from 'data/services/lms';
const { fetching, gotGradesFrozen } = actions.assignmentTypes;
const { gotBulkManagementConfig } = actions.config;
export const fetchAssignmentTypes = () => (
(dispatch, getState) => {
(dispatch) => {
dispatch(fetching.started());
return LmsApiService.fetchAssignmentTypes(selectors.app.courseId(getState()))
return lms.api.fetch.assignmentTypes()
.then(response => response.data)
.then((data) => {
dispatch(fetching.received(Object.keys(data.assignment_types)));

View File

@@ -1,12 +1,15 @@
import LmsApiService from '../services/LmsApiService';
import lms from '../services/lms';
import actions from '../actions';
import selectors from '../selectors';
import * as thunkActions from './assignmentTypes';
import { createTestFetcher } from './testUtils';
jest.mock('../services/LmsApiService', () => ({
fetchAssignmentTypes: jest.fn(),
jest.mock('data/services/lms', () => ({
api: {
fetch: {
assignmentTypes: jest.fn(),
},
},
}));
const responseData = {
@@ -19,16 +22,12 @@ const responseData = {
};
describe('assignmentType thunkActions', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
beforeEach(() => {
selectors.app.courseId = jest.fn(() => courseId);
});
describe('fetchAssignmentTypes', () => {
const testFetch = createTestFetcher(
LmsApiService.fetchAssignmentTypes,
lms.api.fetch.assignmentTypes,
thunkActions.fetchAssignmentTypes,
[],
() => expect(LmsApiService.fetchAssignmentTypes).toHaveBeenCalledWith(courseId),
() => expect(lms.api.fetch.assignmentTypes).toHaveBeenCalledWith(),
);
describe('actions dispatched on valid response', () => {
const actionNames = [

View File

@@ -1,13 +1,12 @@
/* eslint-disable import/prefer-default-export */
import { StrictDict } from 'utils';
import actions from 'data/actions';
import selectors from 'data/selectors';
import LmsApiService from 'data/services/LmsApiService';
import lms from 'data/services/lms';
export const fetchCohorts = () => (
(dispatch, getState) => {
(dispatch) => {
dispatch(actions.cohorts.fetching.started());
return LmsApiService.fetchCohorts(selectors.app.courseId(getState()))
return lms.api.fetch.cohorts()
.then(response => response.data)
.then((data) => {
dispatch(actions.cohorts.fetching.received(data.cohorts));

View File

@@ -1,12 +1,13 @@
import LmsApiService from '../services/LmsApiService';
import lms from 'data/services/lms';
import actions from '../actions';
import selectors from '../selectors';
import actions from 'data/actions';
import * as thunkActions from './cohorts';
import { createTestFetcher } from './testUtils';
jest.mock('../services/LmsApiService', () => ({
fetchCohorts: jest.fn(),
jest.mock('data/services/lms', () => ({
api: {
fetch: { cohorts: jest.fn() },
},
}));
const responseData = {
@@ -17,16 +18,12 @@ const responseData = {
};
describe('cohorts thunkActions', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
beforeEach(() => {
selectors.app.courseId = jest.fn(() => courseId);
});
describe('fetchCohorts', () => {
const testFetch = createTestFetcher(
LmsApiService.fetchCohorts,
lms.api.fetch.cohorts,
thunkActions.fetchCohorts,
[],
() => expect(LmsApiService.fetchCohorts).toHaveBeenCalledWith(courseId),
() => expect(lms.api.fetch.cohorts).toHaveBeenCalledWith(),
);
describe('actions dispatched on valid response', () => {
test('fetching.started, fetching.received', () => testFetch(

View File

@@ -1,4 +1,4 @@
/* eslint-disable import/no-self-import */
/* eslint-disable import/no-self-import, import/no-named-as-default-member */
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { StrictDict } from 'utils';
@@ -8,7 +8,7 @@ import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from 'data/constants/errors';
import grades from 'data/actions/grades';
import { sortAlphaAsc } from 'data/actions/utils';
import selectors from 'data/selectors';
import LmsApiService from 'data/services/LmsApiService';
import lms from 'data/services/lms';
import * as module from './grades';
@@ -16,13 +16,12 @@ const { formatGradeOverrideForDisplay } = selectors.grades;
export const defaultAssignmentFilter = 'All';
export const fetchBulkUpgradeHistory = () => (dispatch, getState) => {
export const fetchBulkUpgradeHistory = () => (dispatch) => (
// todo add loading effect
const courseId = selectors.app.courseId(getState());
return LmsApiService.fetchGradeBulkOperationHistory(courseId).then(
lms.api.fetch.gradeBulkOperationHistory().then(
(response) => { dispatch(grades.bulkHistory.received(response)); },
).catch(() => dispatch(grades.bulkHistory.error()));
};
).catch(() => dispatch(grades.bulkHistory.error()))
);
export const fetchGrades = (overrides = {}) => (
(dispatch, getState) => {
@@ -35,8 +34,7 @@ export const fetchGrades = (overrides = {}) => (
...selectors.root.localFilters(getState()),
...options,
};
return LmsApiService.fetchGradebookData(
courseId,
return lms.api.fetch.gradebookData(
fetchOptions.searchText || null,
cohort,
track,
@@ -74,7 +72,7 @@ export const fetchGradesIfAssignmentGradeFiltersSet = () => (
);
export const fetchGradeOverrideHistory = (subsectionId, userId) => (
dispatch => LmsApiService.fetchGradeOverrideHistory(subsectionId, userId)
dispatch => lms.api.fetch.gradeOverrideHistory(subsectionId, userId)
.then(response => response.data)
.then((data) => {
if (data.success) {
@@ -130,7 +128,7 @@ export const submitFileUploadFormData = (formData) => (
(dispatch, getState) => {
const courseId = selectors.app.courseId(getState());
dispatch(grades.csvUpload.started());
return LmsApiService.uploadGradeCsv(courseId, formData).then(() => {
return lms.api.uploadGradeCsv(formData).then(() => {
dispatch(grades.csvUpload.finished());
dispatch(grades.uploadOverride.success(courseId));
}).catch((error) => {
@@ -146,10 +144,9 @@ export const submitFileUploadFormData = (formData) => (
export const updateGrades = () => (
(dispatch, getState) => {
const courseId = selectors.app.courseId(getState());
const updateData = selectors.app.editUpdateData(getState());
dispatch(grades.update.request());
return LmsApiService.updateGradebookData(courseId, updateData)
return lms.api.updateGradebookData(updateData)
.then(response => response.data)
.then((data) => {
dispatch(grades.update.success({ data }));

View File

@@ -2,12 +2,13 @@ import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import * as auth from '@edx/frontend-platform/auth';
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from 'data/constants/errors';
import actions from 'data/actions';
import { sortAlphaAsc } from 'data/actions/utils';
import lms from 'data/services/lms';
import selectors from 'data/selectors';
import * as thunkActions from './grades';
import actions from '../actions';
import GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG from '../constants/errors';
import { sortAlphaAsc } from '../actions/utils';
import LmsApiService from '../services/LmsApiService';
import selectors from '../selectors';
import { createTestFetcher } from './testUtils';
@@ -68,19 +69,25 @@ const allFilters = {
const testVal = 'A Test VALue';
jest.mock('../services/LmsApiService', () => ({
fetchGradebookData: jest.fn(),
fetchGradeBulkOperationHistory: jest.fn(),
fetchGradeOverrideHistory: jest.fn(),
fetchPrevNextGrades: jest.fn(),
updateGradebookData: jest.fn(),
updateGrades: jest.fn(),
uploadGradeCsv: jest.fn(),
jest.mock('data/services/lms', () => ({
__esModule: true,
default: {
api: {
fetch: {
gradebookData: jest.fn(),
gradeBulkOperationHistory: jest.fn(),
gradeOverrideHistory: jest.fn(),
prevNextGrades: jest.fn(),
},
updateGradebookData: jest.fn(),
uploadGradeCsv: jest.fn(),
},
},
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
jest.mock('../selectors', () => ({
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grades: {
@@ -94,7 +101,9 @@ jest.mock('../selectors', () => ({
assignmentGradeMin: jest.fn(),
assignmentGradeMax: jest.fn(),
},
app: {},
app: {
courseId: jest.fn(),
},
root: {},
},
}));
@@ -103,11 +112,10 @@ selectors.filters.allFilters.mockReturnValue(allFilters);
describe('grades thunkActions', () => {
beforeEach(() => {
LmsApiService.fetchGradebookData.mockClear();
LmsApiService.fetchGradeBulkOperationHistory.mockClear();
LmsApiService.fetchGradeOverrideHistory.mockClear();
LmsApiService.fetchPrevNextGrades.mockClear();
LmsApiService.updateGrades.mockClear();
lms.api.fetch.gradebookData.mockClear();
lms.api.fetch.gradeBulkOperationHistory.mockClear();
lms.api.fetch.gradeOverrideHistory.mockClear();
lms.api.fetch.prevNextGrades.mockClear();
selectors.app.courseId = jest.fn(() => courseId);
selectors.filters.cohort = jest.fn(() => cohort);
selectors.filters.track = jest.fn(() => track);
@@ -115,10 +123,10 @@ describe('grades thunkActions', () => {
});
describe('fetchBulkUpgradeHistory', () => {
const testFetch = createTestFetcher(
LmsApiService.fetchGradeBulkOperationHistory,
lms.api.fetch.gradeBulkOperationHistory,
thunkActions.fetchBulkUpgradeHistory,
[],
() => expect(LmsApiService.fetchGradeBulkOperationHistory).toHaveBeenCalledWith(courseId),
() => expect(lms.api.fetch.gradeBulkOperationHistory).toHaveBeenCalledWith(),
);
describe('valid response', () => {
it('dispatches bulkHistory.received with response', () => {
@@ -154,13 +162,13 @@ describe('grades thunkActions', () => {
};
const testFetch = ({ apiArgs, overrides }) => createTestFetcher(
LmsApiService.fetchGradebookData,
lms.api.fetch.gradebookData,
thunkActions.fetchGrades,
[overrides],
() => {
if (apiArgs !== undefined) {
expect(
LmsApiService.fetchGradebookData,
lms.api.fetch.gradebookData,
).toHaveBeenCalledWith(...apiArgs);
}
},
@@ -179,10 +187,10 @@ describe('grades thunkActions', () => {
describe('fetchGradebookData args', () => {
describe('searchText is not empty', () => {
const options = { to: 'be', or: 'not', searchText: '' };
test('courseId, searchText, cohort, track, and merged localFilters and options', () => (
test('searchText, cohort, track, and merged localFilters and options', () => (
testFetch({
overrides: { options },
apiArgs: [courseId, null, cohort, track, { ...localFilters, ...options }],
apiArgs: [null, cohort, track, { ...localFilters, ...options }],
})(resolve => resolve({ data: responseData }))
));
});
@@ -190,7 +198,7 @@ describe('grades thunkActions', () => {
const options = { to: 'be', or: 'not', searchText: '' };
test('null searchText', () => testFetch({
overrides: { options },
apiArgs: [courseId, null, cohort, track, { ...localFilters, ...options }],
apiArgs: [null, cohort, track, { ...localFilters, ...options }],
})(resolve => resolve({ data: responseData })));
});
});
@@ -270,12 +278,12 @@ describe('grades thunkActions', () => {
const history = { some: 'history' };
const testFetch = createTestFetcher(
LmsApiService.fetchGradeOverrideHistory,
lms.api.fetch.gradeOverrideHistory,
thunkActions.fetchGradeOverrideHistory,
[subsectionId, userId],
() => {
expect(
LmsApiService.fetchGradeOverrideHistory,
lms.api.fetch.gradeOverrideHistory,
).toHaveBeenCalledWith(subsectionId, userId);
},
);
@@ -402,12 +410,12 @@ describe('grades thunkActions', () => {
const updateData = { some: 'data' };
const gradebookData = { data: { OTher: 'DATA' } };
const testFetch = createTestFetcher(
LmsApiService.updateGradebookData,
lms.api.updateGradebookData,
thunkActions.updateGrades,
[],
() => expect(
LmsApiService.updateGradebookData,
).toHaveBeenCalledWith(courseId, updateData),
lms.api.updateGradebookData,
).toHaveBeenCalledWith(updateData),
);
beforeEach(() => {
selectors.app.editUpdateData = jest.fn(() => updateData);
@@ -511,10 +519,10 @@ describe('grades thunkActions', () => {
describe('submitFileUploadFormData', () => {
const formData = { form: 'data' };
const testFetch = createTestFetcher(
LmsApiService.uploadGradeCsv,
lms.api.uploadGradeCsv,
thunkActions.submitFileUploadFormData,
[formData],
() => expect(LmsApiService.uploadGradeCsv).toHaveBeenCalledWith(courseId, formData),
() => expect(lms.api.uploadGradeCsv).toHaveBeenCalledWith(formData),
);
const { csvUpload, uploadOverride } = actions.grades;
describe('valid data', () => {

View File

@@ -3,19 +3,19 @@ import { StrictDict } from 'utils';
import roles from 'data/actions/roles';
import selectors from 'data/selectors';
import lms from 'data/services/lms';
import { fetchCohorts } from './cohorts';
import { fetchGrades } from './grades';
import { fetchTracks } from './tracks';
import { fetchAssignmentTypes } from './assignmentTypes';
import LmsApiService from '../services/LmsApiService';
export const allowedRoles = ['staff', 'instructor', 'support'];
export const fetchRoles = () => (
(dispatch, getState) => {
const courseId = selectors.app.courseId(getState());
return LmsApiService.fetchUserRoles(courseId)
return lms.api.fetch.roles()
.then(response => response.data)
.then((response) => {
const isAllowedRole = (role) => (

View File

@@ -1,17 +1,15 @@
import { createTestFetcher } from './testUtils';
import LmsApiService from '../services/LmsApiService';
import actions from '../actions';
import selectors from '../selectors';
import lms from 'data/services/lms';
import actions from 'data/actions';
import selectors from 'data/selectors';
import { fetchAssignmentTypes } from './assignmentTypes';
import { fetchCohorts } from './cohorts';
import { fetchGrades } from './grades';
import { fetchTracks } from './tracks';
import { allowedRoles, fetchRoles } from './roles';
import { createTestFetcher } from './testUtils';
jest.mock('../selectors', () => ({
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
filters: {
@@ -20,8 +18,10 @@ jest.mock('../selectors', () => ({
app: {},
},
}));
jest.mock('../services/LmsApiService', () => ({
fetchUserRoles: jest.fn(),
jest.mock('data/services/lms', () => ({
api: {
fetch: { roles: jest.fn() },
},
}));
jest.mock('./assignmentTypes', () => ({
fetchAssignmentTypes: jest.fn((...args) => ({ type: 'fetchAssignmentTypes', args })),
@@ -58,10 +58,10 @@ describe('roles thunkActions', () => {
});
describe('fetchRoles', () => {
const testFetch = createTestFetcher(
LmsApiService.fetchUserRoles,
lms.api.fetch.roles,
fetchRoles,
[],
() => expect(LmsApiService.fetchUserRoles).toHaveBeenCalledWith(courseId),
() => expect(lms.api.fetch.roles).toHaveBeenCalledWith(),
);
describe('valid response', () => {
describe('cannot view gradebook (not is_staff, and no allowed roles)', () => {

View File

@@ -1,27 +1,25 @@
/* eslint-disable import/prefer-default-export */
import { StrictDict } from 'utils';
import tracks from '../actions/tracks';
import selectors from '../selectors';
import lms from 'data/services/lms';
import actions from 'data/actions';
import selectors from 'data/selectors';
import { fetchBulkUpgradeHistory } from './grades';
import LmsApiService from '../services/LmsApiService';
export const fetchTracks = () => (
(dispatch, getState) => {
const courseId = selectors.app.courseId(getState());
dispatch(tracks.fetching.started());
return LmsApiService.fetchTracks(courseId)
(dispatch) => {
dispatch(actions.tracks.fetching.started());
return lms.api.fetch.tracks()
.then(response => response.data)
.then((data) => {
dispatch(tracks.fetching.received(data.course_modes));
dispatch(actions.tracks.fetching.received(data.course_modes));
if (selectors.tracks.hasMastersTrack(data.course_modes)) {
dispatch(fetchBulkUpgradeHistory(courseId));
dispatch(fetchBulkUpgradeHistory());
}
})
.catch(() => {
dispatch(tracks.fetching.error());
dispatch(actions.tracks.fetching.error());
});
}
);

View File

@@ -1,41 +1,38 @@
import { createTestFetcher } from './testUtils';
import lms from 'data/services/lms';
import actions from 'data/actions';
import selectors from 'data/selectors';
import LmsApiService from '../services/LmsApiService';
import actions from '../actions';
import selectors from '../selectors';
import { createTestFetcher } from './testUtils';
import { fetchBulkUpgradeHistory } from './grades';
import { fetchTracks } from './tracks';
jest.mock('../services/LmsApiService', () => ({
fetchTracks: jest.fn(),
jest.mock('data/services/lms', () => ({
api: {
fetch: { tracks: jest.fn() },
},
}));
jest.mock('../selectors', () => ({
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
tracks: { hasMastersTrack: jest.fn(() => false) },
app: { },
},
}));
jest.mock('./grades', () => ({
fetchBulkUpgradeHistory: jest.fn((...args) => ({ type: 'fetchBulkUpgradeHistory', args })),
}));
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const responseData = {
couse_modes: ['some', 'course', 'modes'],
};
describe('tracjs thunkActions', () => {
beforeEach(() => {
selectors.app.courseId = jest.fn(() => courseId);
});
describe('tracks thunkActions', () => {
describe('fetchTracks', () => {
const testFetch = createTestFetcher(
LmsApiService.fetchTracks,
lms.api.fetch.tracks,
fetchTracks,
[],
() => expect(LmsApiService.fetchTracks).toHaveBeenCalledWith(courseId),
() => expect(lms.api.fetch.tracks).toHaveBeenCalledWith(),
);
describe('valid response', () => {
describe('if not hasMastersTrack(data.course_modes)', () => {
@@ -64,14 +61,14 @@ describe('tracjs thunkActions', () => {
const expectedActions = [
'fetching.started',
'fetching.received with course_modes',
'fetchBulkUpgradeHistory thunkAction with courseId',
'fetchBulkUpgradeHistory thunkAction',
];
test(`[${expectedActions.join(', ')}]`, () => testFetch(
(resolve) => resolve({ data: responseData }),
[
actions.tracks.fetching.started(),
actions.tracks.fetching.received(responseData.course_modes),
fetchBulkUpgradeHistory(courseId),
fetchBulkUpgradeHistory(),
],
));
});