diff --git a/src/containers/CriterionContainer/CriterionFeedback.jsx b/src/containers/CriterionContainer/CriterionFeedback.jsx index 8161449..375eb77 100644 --- a/src/containers/CriterionContainer/CriterionFeedback.jsx +++ b/src/containers/CriterionContainer/CriterionFeedback.jsx @@ -56,7 +56,7 @@ CriterionFeedback.propTypes = { export const mapStateToProps = (state, { orderNum }) => ({ isGrading: selectors.app.isGrading(state), - config: selectors.app.rubricCriterionFeedbackConfig(state, { orderNum }), + config: selectors.app.rubric.criterionFeedbackConfig(state, { orderNum }), value: selectors.grading.selected.criterionFeedback(state, { orderNum }), }); diff --git a/src/containers/CriterionContainer/GradingCriterion.jsx b/src/containers/CriterionContainer/GradingCriterion.jsx index 9baba47..70590a2 100644 --- a/src/containers/CriterionContainer/GradingCriterion.jsx +++ b/src/containers/CriterionContainer/GradingCriterion.jsx @@ -78,7 +78,7 @@ GradingCriterion.propTypes = { }; export const mapStateToProps = (state, { orderNum }) => ({ - config: selectors.app.rubricCriterionConfig(state, { orderNum }), + config: selectors.app.rubric.criterionConfig(state, { orderNum }), data: selectors.grading.selected.criterionGradeData(state, { orderNum }), }); diff --git a/src/containers/CriterionContainer/ReviewCriterion.jsx b/src/containers/CriterionContainer/ReviewCriterion.jsx index 6e160bb..4fd3839 100644 --- a/src/containers/CriterionContainer/ReviewCriterion.jsx +++ b/src/containers/CriterionContainer/ReviewCriterion.jsx @@ -54,7 +54,7 @@ ReviewCriterion.propTypes = { }; export const mapStateToProps = (state, { orderNum }) => ({ - config: selectors.app.rubricCriterionConfig(state, { orderNum }), + config: selectors.app.rubric.criterionConfig(state, { orderNum }), data: selectors.grading.selected.criterionGradeData(state, { orderNum }), }); diff --git a/src/containers/CriterionContainer/index.jsx b/src/containers/CriterionContainer/index.jsx index 4e7758b..4593c2b 100644 --- a/src/containers/CriterionContainer/index.jsx +++ b/src/containers/CriterionContainer/index.jsx @@ -73,7 +73,7 @@ CriterionContainer.propTypes = { }; export const mapStateToProps = (state, { orderNum }) => ({ - config: selectors.app.rubricCriterionConfig(state, { orderNum }), + config: selectors.app.rubric.criterionConfig(state, { orderNum }), }); export const mapDispatchToProps = { diff --git a/src/containers/ListView/ListViewBreadcrumb.jsx b/src/containers/ListView/ListViewBreadcrumb.jsx index b6ace80..d646f17 100644 --- a/src/containers/ListView/ListViewBreadcrumb.jsx +++ b/src/containers/ListView/ListViewBreadcrumb.jsx @@ -39,7 +39,7 @@ ListViewBreadcrumb.propTypes = { export const mapStateToProps = (state) => ({ courseId: selectors.app.courseId(state), - oraName: selectors.app.oraName(state), + oraName: selectors.app.ora.name(state), }); export const mapDispatchToProps = { diff --git a/src/containers/ReviewModal/index.jsx b/src/containers/ReviewModal/index.jsx index 0702f9d..131231e 100644 --- a/src/containers/ReviewModal/index.jsx +++ b/src/containers/ReviewModal/index.jsx @@ -69,7 +69,7 @@ ReviewModal.propTypes = { export const mapStateToProps = (state) => ({ isOpen: selectors.app.showReview(state), - oraName: selectors.app.oraName(state), + oraName: selectors.app.ora.name(state), response: selectors.grading.selected.response(state), showRubric: selectors.app.showRubric(state), }); diff --git a/src/containers/Rubric/RubricFeedback.jsx b/src/containers/Rubric/RubricFeedback.jsx index 22ec738..e87d372 100644 --- a/src/containers/Rubric/RubricFeedback.jsx +++ b/src/containers/Rubric/RubricFeedback.jsx @@ -56,7 +56,7 @@ RubricFeedback.propTypes = { export const mapStateToProps = (state) => ({ isGrading: selectors.app.isGrading(state), value: selectors.grading.selected.overallFeedback(state), - config: selectors.app.rubricFeedbackConfig(state), + config: selectors.app.rubric.feedbackConfig(state), }); export const mapDispatchToProps = { diff --git a/src/containers/Rubric/index.jsx b/src/containers/Rubric/index.jsx index a17acd5..f1b3665 100644 --- a/src/containers/Rubric/index.jsx +++ b/src/containers/Rubric/index.jsx @@ -48,7 +48,7 @@ Rubric.propTypes = { export const mapStateToProps = (state) => ({ isGrading: selectors.app.isGrading(state), - criteriaIndices: selectors.app.rubricCriteriaIndices(state), + criteriaIndices: selectors.app.rubric.criteriaIndices(state), }); export const mapDispatchToProps = { diff --git a/src/data/selectors/app.js b/src/data/selectors/app.js index da436f9..2d18113 100644 --- a/src/data/selectors/app.js +++ b/src/data/selectors/app.js @@ -6,42 +6,83 @@ import { StrictDict } from 'utils'; import * as module from './app'; +export const appSelector = (state) => state.app; + +const mkSimpleSelector = (cb) => createSelector([module.appSelector], cb); + +// top-level app data selectors export const simpleSelectors = { - showReview: state => state.app.showReview, - showRubric: state => state.app.showRubric, - isGrading: state => state.app.isGrading, - courseMetadata: state => state.app.courseMetadata, - courseId: state => state.app.courseMetadata.courseId, - oraName: state => state.app.oraMetadata.name, - oraPrompt: state => state.app.oraMetadata.prompt, - oraTypes: state => state.app.oraMetadata.type, - rubricConfig: state => state.app.oraMetadata.rubricConfig, + showReview: mkSimpleSelector(app => app.showReview), + showRubric: mkSimpleSelector(app => app.showRubric), + isGrading: mkSimpleSelector(app => app.isGrading), + courseMetadata: mkSimpleSelector(app => app.courseMetadata), + oraMetadata: mkSimpleSelector(app => app.oraMetadata), }; +export const courseId = ( + createSelector([module.simpleSelectors.courseMetadata], (data) => data.courseId) +); + +const oraMetadataSelector = (cb) => createSelector([module.simpleSelectors.oraMetadata], cb); +// ORA metadata selectors +export const ora = { + /** + * Returns the ORA name + * @return {string} - ORA name + */ + name: oraMetadataSelector(data => data.name), + /** + * Returns the ORA Prompt + * @return {string} - ORA prompt + */ + prompt: oraMetadataSelector(data => data.prompt), + /** + * Returns the ORA type + * @return {string} - ORA type (team vs individual) + */ + type: oraMetadataSelector(data => data.type), +}; + +/** + * Container for rubric config selectors + */ +export const rubric = {}; +/** + * Returns the full top-level rubric config from the ora metadata + * @return {object} - rubric config object + */ +rubric.config = oraMetadataSelector(data => data.rubricConfig); + +/** + * Returns a momoized selector depending on the rubric config with the given callback + * @param {func} cb - callback taking the rubric config as an arg, and returning a value + * @return {func} - a memoized selector that calls cb with the rubric config + */ +const rubricConfigSelector = (cb) => createSelector([module.rubric.config], cb); + +/** + * Returns true iff the rubric object has loaded. + * @return {bool} - has a rubric config been loaded? + */ +rubric.hasConfig = rubricConfigSelector(config => config !== undefined); /** * Returns the rubric-level feedback config string * @return {string} - rubric-level feedback config string */ -export const rubricFeedbackConfig = createSelector( - [module.simpleSelectors.rubricConfig], - (config) => config.feedback, -); +rubric.feedbackConfig = rubricConfigSelector(config => config.feedback); /** * Returns a list of rubric criterion config objects for the ORA * @return {obj[]} - array of criterion config objects */ -export const criteria = createSelector( - [module.simpleSelectors.rubricConfig], - (config) => config.criteria, -); +rubric.criteria = rubricConfigSelector(config => config.criteria); /** * Returns the config object for the rubric criterion at the given index (orderNum) * @param {number} orderNum - rubric criterion index * @return {obj} - criterion config object */ -export const rubricCriterionConfig = (state, { orderNum }) => module.criteria(state)[orderNum]; +rubric.criterionConfig = (state, { orderNum }) => module.rubric.criteria(state)[orderNum]; /** * Returns the feeback configuration string for tor the criterion at the given index @@ -49,16 +90,16 @@ export const rubricCriterionConfig = (state, { orderNum }) => module.criteria(st * @param {number} orderNum - rubric criterion index * @return {string} - criterion feedback config string */ -export const rubricCriterionFeedbackConfig = (state, { orderNum }) => ( - module.rubricCriterionConfig(state, { orderNum }).feedback +rubric.criterionFeedbackConfig = (state, { orderNum }) => ( + module.rubric.criterionConfig(state, { orderNum }).feedback ); /** * Returns a list of rubric criteria indices for iterating over * @return {number[]} - list of rubric criteria indices */ -export const rubricCriteriaIndices = createSelector( - [module.criteria], +rubric.criteriaIndices = createSelector( + [module.rubric.criteria], (rubricCriteria) => rubricCriteria.map(({ orderNum }) => orderNum), ); @@ -76,16 +117,16 @@ const shouldIncludeFeedback = (feedback) => ([ * @return {obj} - empty grade data object */ export const emptyGrade = createSelector( - [module.simpleSelectors.rubricConfig], - (rubricConfig) => { - if (rubricConfig === undefined) { + [module.rubric.hasConfig, module.rubric.criteria, module.rubric.feedbackConfig], + (hasConfig, criteria, feedbackConfig) => { + if (!hasConfig) { return null; } const gradeData = {}; - if (shouldIncludeFeedback(rubricConfig.feedback)) { + if (shouldIncludeFeedback(feedbackConfig)) { gradeData.overallFeedback = ''; } - gradeData.criteria = rubricConfig.criteria.map(criterion => { + gradeData.criteria = criteria.map(criterion => { const entry = { orderNum: criterion.orderNum, name: criterion.name, @@ -102,9 +143,8 @@ export const emptyGrade = createSelector( export default StrictDict({ ...simpleSelectors, + courseId, + ora, + rubric: StrictDict(rubric), emptyGrade, - rubricCriteriaIndices, - rubricCriterionConfig, - rubricCriterionFeedbackConfig, - rubricFeedbackConfig, }); diff --git a/src/data/selectors/app.test.js b/src/data/selectors/app.test.js new file mode 100644 index 0000000..9807274 --- /dev/null +++ b/src/data/selectors/app.test.js @@ -0,0 +1,233 @@ +import { createSelector } from 'reselect'; + +import { feedbackRequirement } from 'data/services/lms/constants'; + +// import * in order to mock in-file references +import * as selectors from './app'; +// import default export in order to test simpleSelectors not exported individually +import exportedSelectors from './app'; + +jest.mock('reselect', () => ({ + createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })), +})); + +const testState = { + app: { + showReview: false, + showRubric: false, + isGrading: false, + courseMetadata: { + courseId: 'test-course-id', + }, + oraMetadata: { + name: 'test-ora-name', + prompt: 'test-ora-prompt', + type: 'test-ora-type', + rubricConfig: { + feedback: 'optional', + criteria: [ + { + orderNum: 0, + name: 'critERia0', + feedback: 'optional', + }, + { + orderNum: 1, + name: 'critEriA1', + feedback: 'disabled', + }, + { + orderNum: 2, + name: 'cRIteria2', + feedback: 'required', + }, + ], + }, + }, + }, +}; + +describe('app selectors unit tests', () => { + const { appSelector, simpleSelectors, rubric } = selectors; + describe('appSelector', () => { + it('returns the app data', () => { + expect(appSelector(testState)).toEqual(testState.app); + }); + }); + describe('simpleSelectors', () => { + const testSimpleSelector = (key) => { + const { preSelectors, cb } = simpleSelectors[key]; + expect(preSelectors).toEqual([appSelector]); + expect(cb(testState.app)).toEqual(testState.app[key]); + }; + test('simple selectors link their values from app store', () => { + [ + 'showReview', + 'showRubric', + 'isGrading', + 'courseMetadata', + 'oraMetadata', + ].map(testSimpleSelector); + }); + }); + const testReselect = ({ + selector, + preSelectors, + args, + expected, + }) => { + expect(selector.preSelectors).toEqual(preSelectors); + expect(selector.cb(args)).toEqual(expected); + }; + describe('courseId selector', () => { + it('returns course id from courseMetadata', () => { + testReselect({ + selector: selectors.courseId, + preSelectors: [simpleSelectors.courseMetadata], + args: testState.app.courseMetadata, + expected: testState.app.courseMetadata.courseId, + }); + }); + }); + describe('ora metadata selectors', () => { + const { oraMetadata } = testState.app; + const testOraSelector = (selector, expected) => ( + testReselect({ + selector, + preSelectors: [simpleSelectors.oraMetadata], + args: oraMetadata, + expected, + }) + ); + test('ora.name selector returns name from oraMetadata', () => { + testOraSelector(selectors.ora.name, oraMetadata.name); + }); + test('ora.prompt selector returns prompt from oraMetadata', () => { + testOraSelector(selectors.ora.prompt, oraMetadata.prompt); + }); + test('ora.type selector returns type from oraMetadata', () => { + testOraSelector(selectors.ora.type, oraMetadata.type); + }); + test('rubricConfig selector returns rubricConfig from oraMetadata', () => { + testOraSelector(selectors.rubricConfig, oraMetadata.rubricConfig); + }); + }); + describe('rubricConfig selectors', () => { + const { rubricConfig } = testState.app.oraMetadata; + const testRubricSelector = (selector, expected, args = null) => ( + testReselect({ + selector, + preSelectors: [selectors.rubricConfig], + args: args === null ? rubricConfig : args, + expected, + }) + ); + test('hasConfig', () => { + testReselect({ + selector: rubric.hasConfig, + preSelectors: [selectors.rubricConfig], + args: rubricConfig, + expected: true, + }); + testReselect({ + selector: rubric.hasConfig, + preSelectors: [selectors.rubricConfig], + args: undefined, + expected: false, + }); + }); + test('feedbackConfig', () => { + testRubricSelector(rubric.feedbackConfig, rubricConfig.feedback); + }); + test('criteria', () => { + testRubricSelector(rubric.criteria, rubricConfig.criteria); + }); + describe('criteria selectors', () => { + let criteria; + beforeEach(() => { + criteria = rubric.criteria; + rubric.criteria = jest.fn(({ app }) => app.oraMetadata.rubricConfig.criteria); + }); + afterEach(() => { + rubric.criteria = criteria; + }); + test('criterionConfig returns config by orderNum/index', () => { + const testCriterion = (orderNum) => { + expect( + rubric.criterionConfig(testState, { orderNum }), + ).toEqual(rubricConfig.criteria[orderNum]); + }; + [0, 1, 2].map(testCriterion); + }); + test('criterionFeedbackConfig', () => { + const testCriterion = (orderNum) => { + expect( + rubric.criterionFeedbackConfig(testState, { orderNum }), + ).toEqual(rubricConfig.criteria[orderNum].feedback); + }; + [0, 1, 2].map(testCriterion); + }); + test('criteriaIndices returns ordered list of orderNum values', () => { + testReselect({ + selector: rubric.criteriaIndices, + preSelectors: [criteria], + args: rubricConfig.criteria, + expected: [0, 1, 2], + }); + }); + }); + }); + describe('emptyGrade selector', () => { + const { rubricConfig } = testState.app.oraMetadata; + let preSelectors; + let cb; + beforeEach(() => { + ({ preSelectors, cb } = selectors.emptyGrade); + }); + it('is a memoized selector based on rubric.[hasConfig, criteria, feedbackConfig]', () => { + expect(preSelectors).toEqual([ + rubric.hasConfig, + rubric.criteria, + rubric.feedbackConfig, + ]); + }); + describe('If the config is not loaded (hasConfig = undefined)', () => { + it('returns null', () => { + expect(cb(false, {}, '')).toEqual(null); + }); + }); + describe('The generated object', () => { + it('loads an overallFeedback field iff feedbackConfig is optional or required', () => { + let gradeData = cb(true, rubricConfig.criteria, feedbackRequirement.optional); + expect(gradeData.overallFeedback).toEqual(''); + gradeData = cb(true, rubricConfig.criteria, feedbackRequirement.required); + expect(gradeData.overallFeedback).toEqual(''); + gradeData = cb(true, rubricConfig.criteria, feedbackRequirement.disabled); + expect(gradeData.overallFeedback).toEqual(undefined); + }); + it('loads criteria with feedback field based on requirement config', () => { + const gradeData = cb(true, rubricConfig.criteria, rubricConfig.feedback); + const { criteria } = rubricConfig; + expect(gradeData.criteria).toEqual([ + { + orderNum: criteria[0].orderNum, + name: criteria[0].name, + selectedOption: '', + feedback: '', + }, + { + orderNum: criteria[1].orderNum, + name: criteria[1].name, + selectedOption: '', + }, + { + orderNum: criteria[2].orderNum, + name: criteria[2].name, + selectedOption: '', + feedback: '', + }, + ]); + }); + }); + }); +});