diff --git a/package.json b/package.json index 9cb61c894f..752e304362 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "packages/obonode/obojobo-chunks-iframe", "packages/obonode/obojobo-chunks-list", "packages/obonode/obojobo-chunks-materia", + "packages/obonode/obojobo-chunks-materia-assessment", "packages/obonode/obojobo-chunks-math-equation", "packages/obonode/obojobo-chunks-multiple-choice-assessment", "packages/obonode/obojobo-chunks-numeric-assessment", diff --git a/packages/app/obojobo-document-engine/src/scss/_includes.scss b/packages/app/obojobo-document-engine/src/scss/_includes.scss index 4ff4afa9cf..3d2fca859e 100644 --- a/packages/app/obojobo-document-engine/src/scss/_includes.scss +++ b/packages/app/obojobo-document-engine/src/scss/_includes.scss @@ -15,6 +15,7 @@ $color-bg2: #f4f4f4; $color-bg3: #131313; $color-action-bg: #f9f4ff; $color-correct: #38ae24; +$color-partially-correct: #ffc802; $color-incorrect: #c21f00; $color-survey: #48b8b9; $color-alt-correct: #ffc802; diff --git a/packages/app/obojobo-document-json-parser/src/node-parser.js b/packages/app/obojobo-document-json-parser/src/node-parser.js index d524702c9d..492e7018d6 100644 --- a/packages/app/obojobo-document-json-parser/src/node-parser.js +++ b/packages/app/obojobo-document-json-parser/src/node-parser.js @@ -11,6 +11,7 @@ const parserMap = new Map() .set('ObojoboDraft.Chunks.IFrame', require('./parsers/iframe-node-parser')) .set('ObojoboDraft.Chunks.List', require('./parsers/list-node-parser')) .set('ObojoboDraft.Chunks.Materia', require('./parsers/materia-node-parser')) + .set('ObojoboDraft.Chunks.MateriaAssessment', require('./parsers/materia-assessment-node-parser')) .set('ObojoboDraft.Chunks.MathEquation', require('./parsers/math-equation-node-parser')) .set('ObojoboDraft.Chunks.MCAssessment.MCAnswer', require('./parsers/mc-answer-node-parser')) .set('ObojoboDraft.Chunks.MCAssessment.MCChoice', require('./parsers/mc-choice-node-parser')) diff --git a/packages/app/obojobo-document-json-parser/src/parsers/materia-assessment-node-parser.js b/packages/app/obojobo-document-json-parser/src/parsers/materia-assessment-node-parser.js new file mode 100644 index 0000000000..39339a86f9 --- /dev/null +++ b/packages/app/obojobo-document-json-parser/src/parsers/materia-assessment-node-parser.js @@ -0,0 +1,17 @@ +const processAttrs = require('../process-attrs') +const processTriggers = require('../process-triggers') + +const materiaAssessmentNodeParser = (node, childrenParser) => { + const id = node.id ? ` id="${node.id}"` : '' + const attrs = processAttrs(node.content, ['triggers', 'actions']) + const triggersXML = processTriggers(node.content.triggers) + + return ( + `` + + childrenParser(node.children) + + triggersXML + + `` + ) +} + +module.exports = materiaAssessmentNodeParser diff --git a/packages/app/obojobo-document-xml-parser/src/name-transformer.js b/packages/app/obojobo-document-xml-parser/src/name-transformer.js index c884cd20b0..a0edcd72c6 100644 --- a/packages/app/obojobo-document-xml-parser/src/name-transformer.js +++ b/packages/app/obojobo-document-xml-parser/src/name-transformer.js @@ -13,6 +13,7 @@ const nameMap = new Map() .set('IFrame', 'ObojoboDraft.Chunks.IFrame') .set('List', 'ObojoboDraft.Chunks.List') .set('Materia', 'ObojoboDraft.Chunks.Materia') + .set('MateriaAssessment', 'ObojoboDraft.Chunks.MateriaAssessment') .set('MathEquation', 'ObojoboDraft.Chunks.MathEquation') .set('MCAnswer', 'ObojoboDraft.Chunks.MCAssessment.MCAnswer') .set('MCAssessment', 'ObojoboDraft.Chunks.MCAssessment') diff --git a/packages/app/obojobo-express/server/migrations/20230926141756-create-external-tool-data.js b/packages/app/obojobo-express/server/migrations/20230926141756-create-external-tool-data.js new file mode 100644 index 0000000000..5141450538 --- /dev/null +++ b/packages/app/obojobo-express/server/migrations/20230926141756-create-external-tool-data.js @@ -0,0 +1,54 @@ +'use strict' + +let dbm +let type +let seed + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate + type = dbm.dataType + seed = seedLink +} + +exports.up = function(db) { + return db + .createTable('external_tool_data', { + id: { type: 'bigserial', primaryKey: true }, + created_at: { + type: 'timestamp WITH TIME ZONE', + notNull: true, + defaultValue: new String('now()') + }, + payload: { type: 'jsonb' }, + user_id: { type: 'bigint', notNull: true }, + visit_id: { type: 'UUID', notNull: true }, + draft_content_id: { type: 'UUID', notNull: true }, + node_id: { type: 'UUID', notNull: true } + }) + .then(result => { + return db.addIndex('external_tool_data', 'external_tool_data_user_id_index', ['user_id']) + }) + .then(result => { + return db.addIndex('external_tool_data', 'external_tool_data_visit_id_index', ['visit_id']) + }) + .then(result => { + return db.addIndex('external_tool_data', 'external_tool_data_draft_content_id_index', [ + 'draft_content_id' + ]) + }) + .then(result => { + return db.addIndex('external_tool_data', 'external_tool_data_node_id_index', ['node_id']) + }) +} + +exports.down = function(db) { + return db.dropTable('external_tool_data') +} + +exports._meta = { + version: 1 +} diff --git a/packages/obonode/obojobo-chunks-abstract-assessment/constants.js b/packages/obonode/obojobo-chunks-abstract-assessment/constants.js index 845ce5a5a2..65d20fe2aa 100644 --- a/packages/obonode/obojobo-chunks-abstract-assessment/constants.js +++ b/packages/obonode/obojobo-chunks-abstract-assessment/constants.js @@ -1,5 +1,6 @@ const CHOICE_NODE = 'ObojoboDraft.Chunks.AbstractAssessment.Choice' const FEEDBACK_NODE = 'ObojoboDraft.Chunks.AbstractAssessment.Feedback' +const MATERIA_ASSESSMENT_NODE = 'ObojoboDraft.Chunks.MateriaAssessment' // Whenever an inheriting component is created // Add its Assessment type to the valid assessments, its answer type to valid answers @@ -16,22 +17,27 @@ import { } from 'obojobo-chunks-multiple-choice-assessment/constants' import mcAssessment from 'obojobo-chunks-multiple-choice-assessment/empty-node.json' +import materiaAssessment from 'obojobo-chunks-materia-assessment/empty-node.json' + const assessmentToAnswer = { [NUMERIC_ASSESSMENT_NODE]: numericAssessment.children[0].children[0], - [MC_ASSESSMENT_NODE]: mcAssessment.children[0].children[0] + [MC_ASSESSMENT_NODE]: mcAssessment.children[0].children[0], + [MATERIA_ASSESSMENT_NODE]: materiaAssessment.children[0] } const answerTypeToJson = { [NUMERIC_ANSWER_NODE]: numericAssessment.children[0].children[0], - [MC_ANSWER_NODE]: mcAssessment.children[0].children[0] + [MC_ANSWER_NODE]: mcAssessment.children[0].children[0], + [MATERIA_ASSESSMENT_NODE]: materiaAssessment.children[0] } const answerToAssessment = { [NUMERIC_ANSWER_NODE]: numericAssessment, - [MC_ANSWER_NODE]: mcAssessment + [MC_ANSWER_NODE]: mcAssessment, + [MATERIA_ASSESSMENT_NODE]: materiaAssessment } -const validAssessments = [NUMERIC_ASSESSMENT_NODE, MC_ASSESSMENT_NODE] +const validAssessments = [NUMERIC_ASSESSMENT_NODE, MC_ASSESSMENT_NODE, MATERIA_ASSESSMENT_NODE] const validAnswers = [NUMERIC_ANSWER_NODE, MC_ANSWER_NODE] export { diff --git a/packages/obonode/obojobo-chunks-materia-assessment/.eslintignore b/packages/obonode/obojobo-chunks-materia-assessment/.eslintignore new file mode 100644 index 0000000000..ba2a97b57a --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/.eslintignore @@ -0,0 +1,2 @@ +node_modules +coverage diff --git a/packages/obonode/obojobo-chunks-materia-assessment/.eslintrc b/packages/obonode/obojobo-chunks-materia-assessment/.eslintrc new file mode 100644 index 0000000000..48a4b976f7 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "eslint-config-obojobo" +} diff --git a/packages/obonode/obojobo-chunks-materia-assessment/.gitignore b/packages/obonode/obojobo-chunks-materia-assessment/.gitignore new file mode 100644 index 0000000000..62562b74a3 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/.gitignore @@ -0,0 +1,2 @@ +coverage +node_modules diff --git a/packages/obonode/obojobo-chunks-materia-assessment/.npmignore b/packages/obonode/obojobo-chunks-materia-assessment/.npmignore new file mode 100644 index 0000000000..7fba505d30 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/.npmignore @@ -0,0 +1,18 @@ +coverage +node_modules +npm-debug.log +yarn-error.log +*.DS_Store +__mocks__ +__tests__ +.vscode +.eslintignore +.eslintrc +.prettierignore +.stylelintignore +.stylelintrc +.scss-lint.yml +prettier.config.js +Dockerfile +__snapshots__ +*.test.js diff --git a/packages/obonode/obojobo-chunks-materia-assessment/.prettierignore b/packages/obonode/obojobo-chunks-materia-assessment/.prettierignore new file mode 100644 index 0000000000..13ab9f8412 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/.prettierignore @@ -0,0 +1,2 @@ +package.json +coverage diff --git a/packages/obonode/obojobo-chunks-materia-assessment/.stylelintignore b/packages/obonode/obojobo-chunks-materia-assessment/.stylelintignore new file mode 100644 index 0000000000..2a63845aec --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/.stylelintignore @@ -0,0 +1,5 @@ +node_modules +coverage/ +__snapshots__ +*.js +*.jsx diff --git a/packages/obonode/obojobo-chunks-materia-assessment/.stylelintrc b/packages/obonode/obojobo-chunks-materia-assessment/.stylelintrc new file mode 100644 index 0000000000..f873b6900c --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/.stylelintrc @@ -0,0 +1,3 @@ +{ + "extends": "stylelint-config-obojobo" +} diff --git a/packages/obonode/obojobo-chunks-materia-assessment/__snapshots__/viewer-component.test.js.snap b/packages/obonode/obojobo-chunks-materia-assessment/__snapshots__/viewer-component.test.js.snap new file mode 100644 index 0000000000..7f923685ee --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/__snapshots__/viewer-component.test.js.snap @@ -0,0 +1,68 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MateriaAssessment viewer component renders 1`] = ` +
+
+ MockClassComponent Props: + { + "model": { + "id": "mock-uuid", + "content": { + "src": "http://www.example.com" + }, + "metadata": {}, + "index": 0, + "type": "ObojoboDraft.Chunks.MateriaAssessment", + "children": null + }, + "moduleData": { + "model": { + "title": "mocked-module-title" + }, + "navState": { + "visitId": "mock-visit-id" + } + }, + "title": "Materia Widget" +} +
+
+
+ MockClassComponent Props: + { + "parentModel": { + "id": "mock-uuid", + "content": { + "src": "http://www.example.com" + }, + "metadata": {}, + "index": 0, + "type": "ObojoboDraft.Chunks.MateriaAssessment", + "children": null + }, + "textItem": { + "text": { + "styleList": { + "styles": [] + }, + "value": "" + }, + "data": {}, + "parent": { + "maxItems": 1, + "items": [ + null + ], + "dataTemplate": {} + } + }, + "groupIndex": "0" +} +
+
+
+`; diff --git a/packages/obonode/obojobo-chunks-materia-assessment/babel.config.js b/packages/obonode/obojobo-chunks-materia-assessment/babel.config.js new file mode 100644 index 0000000000..4f4b24d9bb --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/babel.config.js @@ -0,0 +1,11 @@ +module.exports = function(api) { + api.cache(true) + return { + presets: ['@babel/preset-env', '@babel/preset-react'], + env: { + test: { + presets: ['@babel/preset-env', '@babel/preset-react'] + } + } + } +} diff --git a/packages/obonode/obojobo-chunks-materia-assessment/converter.js b/packages/obonode/obojobo-chunks-materia-assessment/converter.js new file mode 100644 index 0000000000..4ecef498d6 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/converter.js @@ -0,0 +1,3 @@ +const materiaChunkConverter = require('obojobo-chunks-materia/converter') + +export default materiaChunkConverter diff --git a/packages/obonode/obojobo-chunks-materia-assessment/editor-registration.js b/packages/obonode/obojobo-chunks-materia-assessment/editor-registration.js new file mode 100644 index 0000000000..9d12f2d105 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/editor-registration.js @@ -0,0 +1,16 @@ +import MateriaRegistration from 'obojobo-chunks-materia/editor-registration' + +import emptyNode from './empty-node.json' + +const MATERIA_ASSESSMENT_NODE = 'ObojoboDraft.Chunks.MateriaAssessment' + +const MateriaAssessment = { + ...MateriaRegistration, + name: MATERIA_ASSESSMENT_NODE, + menuLabel: 'Materia Widget Assessment', + json: { + emptyNode + } +} + +export default MateriaAssessment diff --git a/packages/obonode/obojobo-chunks-materia-assessment/editor-registration.test.js b/packages/obonode/obojobo-chunks-materia-assessment/editor-registration.test.js new file mode 100644 index 0000000000..99326e2366 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/editor-registration.test.js @@ -0,0 +1,43 @@ +jest.mock('obojobo-chunks-materia/editor-component', () => + global.mockReactComponent(this, 'MockMateria') +) +jest.mock('slate') +jest.mock('obojobo-chunks-materia/converter') + +import MateriaAssessment from './editor-registration' + +describe('Materia editorRegistration', () => { + test('has expected properties', () => { + // The editor registration for the MateriaAssessment chunk basically extends the + // editor registration for the Materia chunk, so it should look the same + expect(MateriaAssessment).toMatchInlineSnapshot(` + Object { + "helpers": Object { + "oboToSlate": [MockFunction], + "slateToObo": [MockFunction], + }, + "icon": Object {}, + "isContent": true, + "isInsertable": true, + "json": Object { + "emptyNode": Object { + "children": Array [ + Object { + "text": "", + }, + ], + "content": Object {}, + "type": "ObojoboDraft.Chunks.MateriaAssessment", + }, + }, + "menuLabel": "Materia Widget Assessment", + "name": "ObojoboDraft.Chunks.MateriaAssessment", + "plugins": Object { + "decorate": [Function], + "onKeyDown": [Function], + "renderNode": [Function], + }, + } + `) + }) +}) diff --git a/packages/obonode/obojobo-chunks-materia-assessment/editor.js b/packages/obonode/obojobo-chunks-materia-assessment/editor.js new file mode 100644 index 0000000000..386c16afe4 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/editor.js @@ -0,0 +1,6 @@ +// Main entrypoint for editor +import Common from 'obojobo-document-engine/src/scripts/common' +import EditorNode from './editor-registration' + +// register +Common.Registry.registerEditorModel(EditorNode) diff --git a/packages/obonode/obojobo-chunks-materia-assessment/editor.test.js b/packages/obonode/obojobo-chunks-materia-assessment/editor.test.js new file mode 100644 index 0000000000..513a98a68a --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/editor.test.js @@ -0,0 +1,24 @@ +jest.mock('obojobo-document-engine/src/scripts/common/index', () => ({ + Registry: { + registerEditorModel: jest.fn() + } +})) + +jest.mock('./editor-registration', () => ({ EditorNode: 1 })) + +import Common from 'obojobo-document-engine/src/scripts/common/index' + +describe('Question editor script', () => { + test('registers node', () => { + // shouldn't have been called yet + expect(Common.Registry.registerEditorModel).toHaveBeenCalledTimes(0) + + require('./editor') + const EditorRegistration = require('./editor-registration') + + // the editor script should have registered the model + expect(Common.Registry.registerEditorModel).toHaveBeenCalledTimes(1) + + expect(Common.Registry.registerEditorModel).toHaveBeenCalledWith(EditorRegistration) + }) +}) diff --git a/packages/obonode/obojobo-chunks-materia-assessment/empty-node.json b/packages/obonode/obojobo-chunks-materia-assessment/empty-node.json new file mode 100644 index 0000000000..21cfd0cc49 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/empty-node.json @@ -0,0 +1,5 @@ +{ + "type":"ObojoboDraft.Chunks.MateriaAssessment", + "content": { }, + "children": [{ "text": "" }] +} diff --git a/packages/obonode/obojobo-chunks-materia-assessment/index.js b/packages/obonode/obojobo-chunks-materia-assessment/index.js new file mode 100644 index 0000000000..e3a8fbf704 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/index.js @@ -0,0 +1,11 @@ +module.exports = { + obojobo: { + isOptional: true, + expressMiddleware: 'server/index.js', + clientScripts: { + viewer: 'viewer.js', + editor: 'editor.js' + }, + serverScripts: ['server/materiaassessment'] + } +} diff --git a/packages/obonode/obojobo-chunks-materia-assessment/package.json b/packages/obonode/obojobo-chunks-materia-assessment/package.json new file mode 100644 index 0000000000..8d02e521bb --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/package.json @@ -0,0 +1,67 @@ +{ + "name": "obojobo-chunks-materia-assessment", + "version": "13.2.1", + "license": "AGPL-3.0-only", + "description": "MateriaAssessment chunk for Obojobo", + "scripts": { + "test": "TZ='America/New_York' jest --verbose", + "test:ci": "TZ='America/New_York' CI=true jest --ci --useStderr --coverage --coverageReporters text-summary cobertura", + "lint": "yarn lint:js && yarn lint:css", + "lint:js": "eslint .", + "lint:css": "stylelint **/*.scss", + "prettier:run": "prettier --write '**/*.{js,scss}'", + "precommit": "lint-staged" + }, + "lint-staged": { + "**/*.scss": [ + "stylelint" + ], + "**/*.{js,scss}": [ + "prettier --write", + "git add" + ] + }, + "prettier": { + "printWidth": 100, + "semi": false, + "useTabs": true, + "singleQuote": true + }, + "peerDependencies": { + "obojobo-chunks-question": "^13.2.1", + "obojobo-chunks-materia": "^13.2.1", + "obojobo-lib-utils": "^13.2.1" + }, + "devDependencies": { + "lint-staged": "^10.2.2" + }, + "jest": { + "testMatch": [ + "**/*.test.js" + ], + "setupFilesAfterEnv": [ + "obojobo-lib-utils/test-setup-chunks.js" + ], + "verbose": false, + "coverageReporters": [ + "text", + "lcov" + ], + "coveragePathIgnorePatterns": [ + "node_modules" + ], + "moduleNameMapper": { + "^Common(.*)$": "obojobo-document-engine/src/scripts/common$1", + "^Viewer(.*)$": "obojobo-document-engine/src/scripts/viewer$1", + "\\.(svg|scss)$": "obojobo-lib-utils/__mocks__/ignore-file-stub.js" + }, + "coverageThreshold": { + "global": { + "branches": 100, + "functions": 100, + "lines": 100, + "statements": 100 + } + } + } +} diff --git a/packages/obonode/obojobo-chunks-materia-assessment/server/index.js b/packages/obonode/obojobo-chunks-materia-assessment/server/index.js new file mode 100644 index 0000000000..017d06db53 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/server/index.js @@ -0,0 +1,46 @@ +const router = require('express').Router() //eslint-disable-line new-cap +const db = require('obojobo-express/server/db') +const oboEvents = require('obojobo-express/server/obo_events') + +const { + requireCurrentUser, + requireCurrentVisit +} = require('obojobo-express/server/express_validators') + +oboEvents.on('materia:ltiScorePassback', event => { + db.none( + `INSERT INTO external_tool_data + (payload, user_id, visit_id, draft_content_id, node_id) + VALUES ($[payload], $[userId], $[visitId], $[contentId], $[nodeId])`, + event + ) +}) + +router + .route('/materia-lti-score-verify') + .get([requireCurrentUser, requireCurrentVisit]) + .get(async (req, res) => { + await db + .one( + `SELECT payload + FROM external_tool_data + WHERE user_id = $[userId] + AND draft_content_id = $[draftContentId] + AND node_id = $[nodeId] + ORDER BY created_at DESC + LIMIT 1`, + { + userId: req.currentUser.id, + draftContentId: req.currentVisit.draft_content_id, + nodeId: req.query.nodeId + } + ) + .then(result => { + res.send({ + score: result.payload.score, + success: result.payload.success + }) + }) + }) + +module.exports = router diff --git a/packages/obonode/obojobo-chunks-materia-assessment/server/materiaassessment.js b/packages/obonode/obojobo-chunks-materia-assessment/server/materiaassessment.js new file mode 100644 index 0000000000..2f0e052542 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/server/materiaassessment.js @@ -0,0 +1,22 @@ +const DraftNode = require('obojobo-express/server/models/draft_node') + +class MateriaAssessmentNodeAssessment extends DraftNode { + static get nodeName() { + return 'ObojoboDraft.Chunks.MateriaAssessment' + } + + constructor(draftTree, node, initFn) { + super(draftTree, node, initFn) + this.registerEvents({ + 'ObojoboDraft.Chunks.Question:calculateScore': this.onCalculateScore + }) + } + + onCalculateScore(app, question, responseRecord, setScore) { + if (!question.contains(this.node)) return + + setScore(responseRecord.response.score) + } +} + +module.exports = MateriaAssessmentNodeAssessment diff --git a/packages/obonode/obojobo-chunks-materia-assessment/viewer-component.jsx b/packages/obonode/obojobo-chunks-materia-assessment/viewer-component.jsx new file mode 100644 index 0000000000..49e6ba77df --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/viewer-component.jsx @@ -0,0 +1,90 @@ +import React from 'react' + +import API from 'obojobo-document-engine/src/scripts/viewer/util/api' + +import Viewer from 'obojobo-document-engine/src/scripts/viewer' +const { OboQuestionAssessmentComponent } = Viewer.components + +import Materia from 'obojobo-chunks-materia/viewer-component' + +import './viewer-component.scss' + +export default class MateriaAssessment extends OboQuestionAssessmentComponent { + constructor(props) { + super(props) + + this.handleScorePassback = this.handleScorePassback.bind(this) + + this.inputRef = React.createRef() + + this.state = { + score: null, + verifiedScore: null, + scoreUrl: null + } + } + + static isResponseEmpty(response) { + return !response.verifiedScore + } + + getInstructions() { + return ( + + Embedded Materia widget. + Play the embedded Materia widget to receive a score. Your highest score will be saved. + + ) + } + + calculateScore() { + if (!this.state.score) { + return null + } + + return { + score: this.state.score, + details: { + scoreUrl: this.state.scoreUrl + } + } + } + + handleScorePassback(event, data) { + // this should probably be abstracted in a util function somewhere + return API.get( + `/materia-lti-score-verify?visitId=${this.props.moduleData.navState.visitId}&nodeId=${this.props.model.id}`, + 'json' + ) + .then(API.processJsonResults) + .then(result => { + this.setState({ + score: result.score, + verifiedScore: true, + scoreUrl: data.score_url + }) + + this.props.onSaveAnswer(null) + }) + } + + handleFormChange() { + return { + state: this.state, + targetId: null, + sendResponseImmediately: true + } + } + + + render() { + return ( + + ) + } +} \ No newline at end of file diff --git a/packages/obonode/obojobo-chunks-materia-assessment/viewer-component.scss b/packages/obonode/obojobo-chunks-materia-assessment/viewer-component.scss new file mode 100644 index 0000000000..1e04d71965 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/viewer-component.scss @@ -0,0 +1,8 @@ +.hidden { + display: block; + padding: 0; + margin: 0; + opacity: 0; + height: 0; + width: 0; +} diff --git a/packages/obonode/obojobo-chunks-materia-assessment/viewer-component.test.js b/packages/obonode/obojobo-chunks-materia-assessment/viewer-component.test.js new file mode 100644 index 0000000000..0ffc1a5a8b --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/viewer-component.test.js @@ -0,0 +1,248 @@ +jest.mock('obojobo-document-engine/src/scripts/viewer/util/api', () => ({ + get: jest.fn(), + processJsonResults: jest.fn() +})) + +jest.mock('obojobo-document-engine/src/scripts/viewer', () => ({ + components: { + OboQuestionAssessmentComponent: jest.requireActual( + 'obojobo-document-engine/src/scripts/viewer/components/obo-question-assessment-component' + ).default + }, + util: { + NavUtil: { + getContext: jest.fn().mockReturnValue('mock:module:context') + } + } +})) + +jest.mock('react-dom') +jest.mock( + 'obojobo-chunks-iframe/viewer-component', + () => require('obojobo-document-engine/__mocks__/mock-class-component').default +) +jest.mock( + 'obojobo-document-engine/src/scripts/common/chunk/text-chunk/text-group-el', + () => require('obojobo-document-engine/__mocks__/mock-class-component').default +) + +import Materia from 'obojobo-chunks-materia/viewer-component' + +import MateriaAssessment from './viewer-component' +import OboModel from 'obojobo-document-engine/__mocks__/obo-model-mock' +import React from 'react' +import renderer from 'react-test-renderer' + +require('./viewer') // used to register this oboModel + +describe('MateriaAssessment viewer component', () => { + let model + let moduleData + let questionModel + let onSaveAnswer + + const API = require('obojobo-document-engine/src/scripts/viewer/util/api') + + const Viewer = require('obojobo-document-engine/src/scripts/viewer') + const { NavUtil } = Viewer.util + + const mockModuleTitle = 'mocked-module-title' + const mockVisitId = 'mock-visit-id' + const mockNodeId = 'mock-obo-id' + const mockQuestionId = 'mock-question-id' + + beforeEach(() => { + jest.resetAllMocks() + jest.spyOn(window, 'addEventListener') + jest.spyOn(window, 'removeEventListener') + + OboModel.__setNextGeneratedLocalId('mock-uuid') + model = OboModel.create({ + id: mockNodeId, + type: 'ObojoboDraft.Chunks.MateriaAssessment', + content: { + src: 'http://www.example.com' + } + }) + + moduleData = { + model: { + title: mockModuleTitle + }, + navState: { + visitId: mockVisitId + } + } + + questionModel = { + get: jest.fn().mockReturnValue(mockQuestionId) + } + + onSaveAnswer = jest.fn() + }) + + afterEach(() => {}) + + test('renders', () => { + expect.hasAssertions() + const props = { + model, + moduleData + } + const component = renderer.create() + expect(component.toJSON()).toMatchSnapshot() + + expect(component.root.children.length).toBe(1) + expect(component.root.children[0].type).toBe(Materia) + }) + + test('handleScorePassback makes an API call to verify the score', () => { + API.get = jest.fn().mockResolvedValue(true) + API.processJsonResults = jest.fn().mockResolvedValue({ score: 100, success: true }) + + NavUtil.getContext = jest.fn().mockReturnValue('mock:module:context') + + expect.hasAssertions() + const props = { + model, + moduleData, + questionModel, + onSaveAnswer + } + + const component = renderer.create() + const inst = component.getInstance() + + const mockData = { + type: 'materiaScoreRecorded', + score: 100, + score_url: 'http://localhost/score' + } + const mockEvent = { + origin: 'http://localhost', + source: 'http://localhost/whatever', + data: JSON.stringify(mockData) + } + + return inst.handleScorePassback(mockEvent, mockData).then(() => { + expect(API.get).toHaveBeenCalledTimes(1) + expect(API.get).toHaveBeenCalledWith( + `/materia-lti-score-verify?visitId=${mockVisitId}&nodeId=${mockNodeId}`, + 'json' + ) + + expect(inst.state).toHaveProperty('score', 100) + expect(onSaveAnswer).toHaveBeenCalledTimes(1) + }) + }) + + test('isResponseEmpty returns true if the response score is not verified - does not exist', () => { + expect(MateriaAssessment.isResponseEmpty({})).toBe(true) + expect(MateriaAssessment.isResponseEmpty({ score: 100, verifiedScore: false })).toBe(true) + }) + test('isResponseEmpty returns true if the response score is not verified - false value', () => { + expect(MateriaAssessment.isResponseEmpty({ score: 100, verifiedScore: false })).toBe(true) + }) + test('isResponseEmpty returns false if the response score is verified', () => { + expect(MateriaAssessment.isResponseEmpty({ score: 100, verifiedScore: true })).toBe(false) + }) + + test('calculateScore returns null when no score is recorded', () => { + const props = { + model, + moduleData + } + const component = renderer.create() + + const inst = component.getInstance() + expect(inst.calculateScore()).toBe(null) + }) + + test('calculateScore returns properly when a score is recorded', () => { + const mockScore = 90 + const mockScoreUrl = 'url:to.score/screen' + + const props = { + model, + moduleData + } + const component = renderer.create() + + const inst = component.getInstance() + + // Ordinarily we would never set this manually, it would be a product of the score verification + // But we're only really checking this one function's reactions, so it should be fine. + inst.state = { + score: mockScore, + verifiedScore: true, + scoreUrl: mockScoreUrl + } + + expect(inst.calculateScore()).toEqual({ score: mockScore, details: { scoreUrl: mockScoreUrl } }) + }) + + test('getInstructions returns properly', () => { + const props = { + model, + moduleData + } + const component = renderer.create() + + const inst = component.getInstance() + const instructionsFragment = renderer.create(inst.getInstructions()) + + const fragmentRender = instructionsFragment.root.findByProps({ + className: 'for-screen-reader-only' + }) + expect(fragmentRender.children[0]).toBe('Embedded Materia widget.') + expect(fragmentRender.parent.children[1]).toBe( + 'Play the embedded Materia widget to receive a score. Your highest score will be saved.' + ) + }) + + test('handleFormChange returns the expected object', () => { + let mockScore = 90 + let mockScoreUrl = 'url:to.score/screen' + + const props = { + model, + moduleData + } + const component = renderer.create() + + const inst = component.getInstance() + + let mockNewState = { + score: mockScore, + verifiedScore: true, + scoreUrl: mockScoreUrl + } + + // Ordinarily we would never set this manually, it would be a product of the score verification + // But we're only really checking this one function's reactions, so it should be fine. + inst.state = mockNewState + + expect(inst.handleFormChange()).toEqual({ + state: mockNewState, + targetId: null, + sendResponseImmediately: true + }) + + mockScore = 100 + mockScoreUrl = 'url:to.some/other/score/screen' + + mockNewState = { + score: mockScore, + verifiedScore: true, + scoreUrl: mockScoreUrl + } + + inst.state = mockNewState + + expect(inst.handleFormChange()).toEqual({ + state: mockNewState, + targetId: null, + sendResponseImmediately: true + }) + }) +}) diff --git a/packages/obonode/obojobo-chunks-materia-assessment/viewer.js b/packages/obonode/obojobo-chunks-materia-assessment/viewer.js new file mode 100644 index 0000000000..7f3aac4978 --- /dev/null +++ b/packages/obonode/obojobo-chunks-materia-assessment/viewer.js @@ -0,0 +1,9 @@ +import adapter from 'obojobo-chunks-materia/adapter' +import Common from 'obojobo-document-engine/src/scripts/common' +import ViewerComponent from './viewer-component' + +Common.Registry.registerModel('ObojoboDraft.Chunks.MateriaAssessment', { + adapter: adapter, + componentClass: ViewerComponent, + type: 'chunk' +}) diff --git a/packages/obonode/obojobo-chunks-materia/__snapshots__/adapter.test.js.snap b/packages/obonode/obojobo-chunks-materia/__snapshots__/adapter.test.js.snap index 7a8f26961e..25846dedae 100644 --- a/packages/obonode/obojobo-chunks-materia/__snapshots__/adapter.test.js.snap +++ b/packages/obonode/obojobo-chunks-materia/__snapshots__/adapter.test.js.snap @@ -4,6 +4,7 @@ exports[`Materia adapter construct builds without attributes 1`] = ` Object { "height": 600, "icon": null, + "noFooter": true, "src": null, "textGroup": TextGroup { "dataTemplate": Object {}, @@ -31,6 +32,7 @@ Object { "content": Object { "height": 600, "icon": null, + "noFooter": true, "src": "mockSrc", "textGroup": Array [ Object { diff --git a/packages/obonode/obojobo-chunks-materia/adapter.js b/packages/obonode/obojobo-chunks-materia/adapter.js index 11910c5804..39dcb15f6f 100644 --- a/packages/obonode/obojobo-chunks-materia/adapter.js +++ b/packages/obonode/obojobo-chunks-materia/adapter.js @@ -6,7 +6,7 @@ const MIN_DIMENSION = 100 const DEFAULT_WIDTH = 800 const DEFAULT_HEIGHT = 600 -const propsList = ['height', 'icon', 'src', 'widgetEngine', 'width'] +const propsList = ['height', 'icon', 'noFooter', 'src', 'widgetEngine', 'width'] export default { construct(model, attrs) { @@ -26,6 +26,7 @@ export default { ) model.setStateProp('widgetEngine', null) model.setStateProp('icon', null) + model.setStateProp('noFooter', true) }, clone(model, clone) { diff --git a/packages/obonode/obojobo-chunks-materia/adapter.test.js b/packages/obonode/obojobo-chunks-materia/adapter.test.js index 06c6d326c9..215d1a34e8 100644 --- a/packages/obonode/obojobo-chunks-materia/adapter.test.js +++ b/packages/obonode/obojobo-chunks-materia/adapter.test.js @@ -44,6 +44,12 @@ describe('Materia adapter', () => { expect(model.modelState.widgetEngine).toBe('') }) + test('adapter sets noFooter to true', () => { + model = new MockOboModel({}) + MateriaAdapter.construct(model) + expect(model.modelState.noFooter).toBe(true) + }) + test('adapter sets modelState.icon to a number if specified and null otherwise', () => { model = new MockOboModel({}) MateriaAdapter.construct(model) diff --git a/packages/obonode/obojobo-chunks-materia/server/materia-event.js b/packages/obonode/obojobo-chunks-materia/server/materia-event.js index d7a44e01fd..e740f1c6a5 100644 --- a/packages/obonode/obojobo-chunks-materia/server/materia-event.js +++ b/packages/obonode/obojobo-chunks-materia/server/materia-event.js @@ -1,5 +1,7 @@ const insertEvent = require('obojobo-express/server/insert_event') const logger = require('obojobo-express/server/logger') +const oboEvents = require('obojobo-express/server/obo_events') +const { expandLisResultSourcedId } = require('./route-helpers') // EVENTS const insertLtiLaunchWidgetEvent = ({ @@ -93,9 +95,19 @@ const insertLtiScorePassbackEvent = ({ metadata: {}, draftId, contentId - }).catch(err => { - logger.error(`There was an error inserting the ${action} event`, err) }) + .then(() => { + const payload = { + score + } + + const { nodeId } = expandLisResultSourcedId(lisResultSourcedId) + + oboEvents.emit(action, { userId, visitId, contentId, nodeId, payload }) + }) + .catch(err => { + logger.error(`There was an error inserting the ${action} event`, err) + }) } module.exports = { diff --git a/packages/obonode/obojobo-chunks-materia/server/route-helpers.js b/packages/obonode/obojobo-chunks-materia/server/route-helpers.js index a2a418e3b6..63002446c2 100644 --- a/packages/obonode/obojobo-chunks-materia/server/route-helpers.js +++ b/packages/obonode/obojobo-chunks-materia/server/route-helpers.js @@ -58,7 +58,7 @@ const widgetLaunchParams = (document, visit, user, materiaOboNodeId, baseUrl) => } // materia currently uses context_id to group scores and attempts - // obojobo doesn't support materia as scoreable questions yet, so the key in use here is intended to: + // the key in use here is intended to: // * support materia in content pages // * re lti launch will reset scores/attempts // * browser reload of the window will resume an attempt/score window diff --git a/packages/obonode/obojobo-chunks-materia/viewer-component.js b/packages/obonode/obojobo-chunks-materia/viewer-component.js index 5a1236d99d..96725a91c3 100644 --- a/packages/obonode/obojobo-chunks-materia/viewer-component.js +++ b/packages/obonode/obojobo-chunks-materia/viewer-component.js @@ -10,13 +10,19 @@ import IFrameSizingTypes from 'obojobo-chunks-iframe/iframe-sizing-types' export default class Materia extends React.Component { constructor(props) { super(props) + this.iframeRef = React.createRef() + let iframeSrc = this.srcToLTILaunchUrl(props.moduleData.navState.visitId, props.model.id) + if (props.mode === 'review') { + iframeSrc = props.response.scoreUrl + } + // manipulate iframe settings const model = props.model.clone() model.modelState = { ...model.modelState, - src: this.srcToLTILaunchUrl(props.moduleData.navState.visitId, props.model.id), + src: iframeSrc, border: true, fit: 'scale', initialZoom: 1, @@ -27,8 +33,8 @@ export default class Materia extends React.Component { // state setup this.state = { model, - score: null, - verifiedScore: false, + visitId: props.moduleData.navState.visitId, + nodeId: props.model.id, open: false } @@ -38,9 +44,17 @@ export default class Materia extends React.Component { } onPostMessageFromMateria(event) { - // iframe isn't present OR - // postmessage didn't come from the iframe we're listening to - if (!this.iframeRef.current || event.source !== this.iframeRef.current.contentWindow) { + // no callback registered to do anything with a score event + if (!this.props.handleScorePassback) { + return + } + + // iframe isn't present + if (!this.iframeRef || !this.iframeRef.current || !this.iframeRef.current.refs.iframe) { + return + } + // OR postmessage didn't come from the iframe we're listening to + if (event.source !== this.iframeRef.current.refs.iframe.contentWindow) { return } @@ -58,7 +72,7 @@ export default class Materia extends React.Component { switch (data.type) { case 'materiaScoreRecorded': - this.setState({ score: data.score }) + this.props.handleScorePassback(event, data) break } } catch (e) { @@ -82,21 +96,35 @@ export default class Materia extends React.Component { this.setState({ open: true }) } - renderTextCaption() { - return this.state.model.modelState.textGroup.first.text ? ( -
- -
- ) : null + renderCaptionAndScore() { + let textCaptionRender = null + + let scoreRender = null + if (this.props.score && this.props.verifiedScore) { + scoreRender = ( + Your highest score: {this.props.score}% + ) + } + + if (this.state.model.modelState.textGroup.first.text) { + textCaptionRender = ( +
+ + {scoreRender} +
+ ) + } + + return textCaptionRender } - renderCaptionOrScore() { + renderTextCaption() { try { - return this.renderTextCaption() + return this.renderCaptionAndScore() } catch (e) { console.error('Error building Materia Caption') // eslint-disable-line no-console return null @@ -113,7 +141,7 @@ export default class Materia extends React.Component { title={`${this.state.model.modelState.widgetEngine || 'Materia'} Widget`} onShow={this.onShow} /> - {this.renderCaptionOrScore()} + {this.renderTextCaption()} ) } diff --git a/packages/obonode/obojobo-chunks-materia/viewer-component.scss b/packages/obonode/obojobo-chunks-materia/viewer-component.scss index 01fc7ba159..715c739252 100644 --- a/packages/obonode/obojobo-chunks-materia/viewer-component.scss +++ b/packages/obonode/obojobo-chunks-materia/viewer-component.scss @@ -9,12 +9,15 @@ .materia-score { display: block; text-align: center; - margin: 0 auto; - margin-bottom: 2em; + margin: -5em auto 0; font-size: 0.7em; - opacity: 0.5; - margin-top: -3.5em; + opacity: 1; z-index: 2; + transition: opacity 0.4s; + + &.is-not-verified { + opacity: 0; + } } .label { diff --git a/packages/obonode/obojobo-chunks-materia/viewer-component.test.js b/packages/obonode/obojobo-chunks-materia/viewer-component.test.js index 943e0a7e72..d6972d207b 100644 --- a/packages/obonode/obojobo-chunks-materia/viewer-component.test.js +++ b/packages/obonode/obojobo-chunks-materia/viewer-component.test.js @@ -18,6 +18,18 @@ require('./viewer') // used to register this oboModel describe('Materia viewer component', () => { let model let moduleData + let handleScorePassback + + let standardProps + + const mockModuleTitle = 'mocked-module-title' + const mockVisitId = 'mock-visit-id' + const mockNodeId = 'mock-obo-id' + + const standardPostMessageContent = { + score: 100, + score_url: 'url:to/score.screen' + } beforeEach(() => { jest.resetAllMocks() @@ -26,7 +38,7 @@ describe('Materia viewer component', () => { OboModel.__setNextGeneratedLocalId('mock-uuid') model = OboModel.create({ - id: 'mock-obo-id', + id: mockNodeId, type: 'ObojoboDraft.Chunks.Materia', content: { src: 'http://www.example.com' @@ -35,32 +47,39 @@ describe('Materia viewer component', () => { moduleData = { model: { - title: 'mocked-module-title' + title: mockModuleTitle }, navState: { - visitId: 'mock-visit-id' + visitId: mockVisitId } } + + handleScorePassback = jest.fn() + + standardProps = { + model, + moduleData, + handleScorePassback + } }) afterEach(() => {}) test('renders', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() expect(component.toJSON()).toMatchSnapshot() + + const inst = component.getInstance() + expect(inst.state.model.modelState.src).toBe( + `http://localhost/materia-lti-launch?visitId=${mockVisitId}&nodeId=${mockNodeId}` + ) }) test('adds and removes listener for postmessage when mounting and unmounting', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() @@ -86,145 +105,172 @@ describe('Materia viewer component', () => { ) }) - test('onPostMessageFromMateria without an iframe ref doesnt update state', () => { + test('onPostMessageFromMateria does nothing if post message handler callback is not defined', () => { expect.hasAssertions() const props = { - model, - moduleData + ...standardProps } + delete props.handleScorePassback const component = renderer.create() const inst = component.getInstance() inst.iframeRef = {} - const event = { source: '', data: JSON.stringify({ score: 100 }) } + const event = { source: '', data: JSON.stringify(standardPostMessageContent) } inst.onPostMessageFromMateria(event) - expect(inst.state).toHaveProperty('score', null) + expect(handleScorePassback).toHaveBeenCalledTimes(0) }) - test('onPostMessageFromMateria without a matching iframe and event source doesnt update state', () => { + test('onPostMessageFromMateria with an empty iframe ref does not call post message handler callback', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() const inst = component.getInstance() - inst.iframeRef = { current: { contentWindow: 'different-mock-window' } } - const event = { source: 'mock-window', data: JSON.stringify({ score: 100 }) } + inst.iframeRef = {} + const event = { source: '', data: JSON.stringify(standardPostMessageContent) } inst.onPostMessageFromMateria(event) - expect(inst.state).toHaveProperty('score', null) + expect(handleScorePassback).toHaveBeenCalledTimes(0) + }) + + test('onPostMessageFromMateria without an iframe ref object does not call post message handler callback', () => { + expect.hasAssertions() + const props = standardProps + + const component = renderer.create() + + const inst = component.getInstance() + inst.iframeRef = null + const event = { source: '', data: JSON.stringify(standardPostMessageContent) } + inst.onPostMessageFromMateria(event) + expect(handleScorePassback).toHaveBeenCalledTimes(0) + }) + + test('onPostMessageFromMateria without a matching iframe and event source does not call post message handler callback', () => { + expect.hasAssertions() + const props = standardProps + + const component = renderer.create() + + const inst = component.getInstance() + inst.iframeRef = { current: { refs: { iframe: { contentWindow: 'different-mock-window' } } } } + const event = { source: 'mock-window', data: JSON.stringify(standardPostMessageContent) } + inst.onPostMessageFromMateria(event) + expect(handleScorePassback).toHaveBeenCalledTimes(0) }) test('onPostMessageFromMateria blocks events not coming fom the src domain', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() const inst = component.getInstance() - inst.iframeRef = { current: { contentWindow: 'http://not-localhost/' } } - const event = { source: 'http://localhost/whatever', data: JSON.stringify({ score: 100 }) } + inst.iframeRef = { + current: { refs: { iframe: { contentWindow: 'http://not-localhost/whatever' } } } + } + const event = { + source: 'http://localhost/whatever', + data: JSON.stringify(standardPostMessageContent) + } inst.onPostMessageFromMateria(event) - expect(inst.state).toHaveProperty('score', null) + expect(handleScorePassback).toHaveBeenCalledTimes(0) }) test('onPostMessageFromMateria blocks events with an origin that doesnt match modelState srcn', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() const inst = component.getInstance() - inst.iframeRef = { current: { contentWindow: 'http://localhost/whatever' } } + inst.iframeRef = { + current: { refs: { iframe: { contentWindow: 'http://localhost/whatever' } } } + } const event = { origin: 'http://not-localhost', source: 'http://localhost/whatever', - data: JSON.stringify({ score: 100 }) + data: JSON.stringify(standardPostMessageContent) } inst.onPostMessageFromMateria(event) - expect(inst.state).toHaveProperty('score', null) + expect(handleScorePassback).toHaveBeenCalledTimes(0) }) test('onPostMessageFromMateria ignores messages where data is not a string', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() const inst = component.getInstance() - inst.iframeRef = { current: { contentWindow: 'http://localhost/whatever' } } + inst.iframeRef = { + current: { refs: { iframe: { contentWindow: 'http://localhost/whatever' } } } + } const event = { origin: 'http://localhost', source: 'http://localhost/whatever', - data: { score: 100 } + data: standardPostMessageContent } inst.onPostMessageFromMateria(event) - expect(inst.state).toHaveProperty('score', null) + expect(handleScorePassback).toHaveBeenCalledTimes(0) }) test('onPostMessageFromMateria ignores messages with a data type that isnt materiaScoreRecorded', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() const inst = component.getInstance() - inst.iframeRef = { current: { contentWindow: 'http://localhost/whatever' } } + inst.iframeRef = { + current: { refs: { iframe: { contentWindow: 'http://localhost/whatever' } } } + } const event = { origin: 'http://localhost', source: 'http://localhost/whatever', data: JSON.stringify({ type: 'notmateriaScoreRecorded', score: 100 }) } inst.onPostMessageFromMateria(event) - expect(inst.state).toHaveProperty('score', null) + expect(handleScorePassback).toHaveBeenCalledTimes(0) }) - test('onPostMessageFromMateria updates the score', () => { + test('onPostMessageFromMateria calls post message handler when all checks pass', () => { expect.hasAssertions() - const props = { - model, - moduleData - } - + const props = standardProps const component = renderer.create() + // While we're here, make sure the score dialog appears correctly. + // It shouldn't exist by default + expect(component.root.findAllByProps({ className: 'materia-score verified' }).length).toBe(0) + const inst = component.getInstance() - inst.iframeRef = { current: { contentWindow: 'http://localhost/whatever' } } + inst.iframeRef = { + current: { refs: { iframe: { contentWindow: 'http://localhost/whatever' } } } + } const event = { origin: 'http://localhost', source: 'http://localhost/whatever', - data: JSON.stringify({ type: 'materiaScoreRecorded', score: 100 }) + data: JSON.stringify({ + type: 'materiaScoreRecorded', + score: 100, + score_url: 'http://localhost/score' + }) } inst.onPostMessageFromMateria(event) - expect(inst.state).toHaveProperty('score', 100) + expect(handleScorePassback).toHaveBeenCalledTimes(1) }) test('onPostMessageFromMateria handles json parsing errors', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() const inst = component.getInstance() - inst.iframeRef = { current: { contentWindow: 'http://localhost/whatever' } } + inst.iframeRef = { + current: { refs: { iframe: { contentWindow: 'http://localhost/whatever' } } } + } const event = { origin: 'http://localhost', source: 'http://localhost/whatever', @@ -233,16 +279,13 @@ describe('Materia viewer component', () => { jest.spyOn(console, 'error') console.error.mockReturnValueOnce() // eslint-disable-line no-console inst.onPostMessageFromMateria(event) - expect(inst.state).toHaveProperty('score', null) + expect(handleScorePassback).toHaveBeenCalledTimes(0) expect(console.error).toHaveBeenCalled() // eslint-disable-line no-console }) test('srcToLTILaunchUrl formats strings as expected', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() const inst = component.getInstance() @@ -256,10 +299,7 @@ describe('Materia viewer component', () => { test('onShow changes state to open', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() const inst = component.getInstance() @@ -268,11 +308,24 @@ describe('Materia viewer component', () => { expect(inst.state).toHaveProperty('open', true) }) - test('renderCaptionOrScore renders text', () => { + test('renderCaptionOrScore renders text but no score when score is not set', () => { + expect.hasAssertions() + const props = standardProps + + const component = renderer.create() + + // find the textGroupEL by a unique prop + // if it's found, it was rendered + expect(component.root.findAllByProps({ groupIndex: '0' })).toHaveLength(1) + expect(component.root.findAllByProps({ className: 'materia-score verified' }).length).toBe(0) + }) + + test('renderCaptionOrScore renders text and score when score is set', () => { expect.hasAssertions() const props = { - model, - moduleData + ...standardProps, + score: 100, + verifiedScore: true } const component = renderer.create() @@ -280,14 +333,13 @@ describe('Materia viewer component', () => { // find the textGroupEL by a unique prop // if it's found, it was rendered expect(component.root.findAllByProps({ groupIndex: '0' })).toHaveLength(1) + const scoreRender = component.root.findByProps({ className: 'materia-score verified' }) + expect(scoreRender.children.join('')).toBe('Your highest score: 100%') }) test('renderCaptionOrScore captures errors', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() @@ -304,10 +356,7 @@ describe('Materia viewer component', () => { test('renderTextCaption renders null when there is no textgroup text', () => { expect.hasAssertions() - const props = { - model, - moduleData - } + const props = standardProps const component = renderer.create() @@ -321,4 +370,21 @@ describe('Materia viewer component', () => { // it shouldn't render one expect(component.root.findAllByProps({ groupIndex: '0' })).toHaveLength(0) }) + + test('rendering in review mode changes the iframe src', () => { + const mockScoreUrl = 'http://localhost/url/to/score/screen' + const props = { + model, + moduleData, + mode: 'review', + response: { + scoreUrl: mockScoreUrl + } + } + const component = renderer.create() + + const inst = component.getInstance() + + expect(inst.state.model.modelState.src).toBe(mockScoreUrl) + }) }) diff --git a/packages/obonode/obojobo-chunks-question/__snapshots__/adapter.test.js.snap b/packages/obonode/obojobo-chunks-question/__snapshots__/adapter.test.js.snap index 31f0557f5c..6836ed7cab 100644 --- a/packages/obonode/obojobo-chunks-question/__snapshots__/adapter.test.js.snap +++ b/packages/obonode/obojobo-chunks-question/__snapshots__/adapter.test.js.snap @@ -16,6 +16,11 @@ Object { "z", ], "needsUpdate": false, + "partialLabels": Array [ + "l", + "m", + "n", + ], "revealAnswer": "when-incorrect", "solution": Object { "children": Array [ @@ -60,6 +65,7 @@ Object { "editing": false, "incorrectLabels": null, "needsUpdate": false, + "partialLabels": null, "revealAnswer": "default", "solution": null, "type": "default", @@ -74,6 +80,7 @@ Object { "editing": false, "incorrectLabels": null, "needsUpdate": false, + "partialLabels": null, "revealAnswer": "default", "solution": null, "type": "default", @@ -86,6 +93,7 @@ Object { "collapsed": false, "correctLabels": "a|b|c", "incorrectLabels": "d|e|f", + "partialLabels": null, "revealAnswer": "default", "solution": Object { "children": Array [ @@ -129,6 +137,7 @@ Object { "collapsed": false, "correctLabels": null, "incorrectLabels": null, + "partialLabels": null, "revealAnswer": "default", "solution": null, "type": "default", diff --git a/packages/obonode/obojobo-chunks-question/adapter.js b/packages/obonode/obojobo-chunks-question/adapter.js index f6df18a786..f4e92fd155 100644 --- a/packages/obonode/obojobo-chunks-question/adapter.js +++ b/packages/obonode/obojobo-chunks-question/adapter.js @@ -34,6 +34,7 @@ const Adapter = { } model.setStateProp('correctLabels', null, p => p.split('|')) + model.setStateProp('partialLabels', null, p => p.split('|')) model.setStateProp('incorrectLabels', null, p => p.split('|')) }, @@ -42,6 +43,9 @@ const Adapter = { clone.modelState.correctLabels = model.modelState.correctLabels ? model.modelState.correctLabels.slice(0) : null + clone.modelState.partialLabels = model.modelState.partialLabels + ? model.modelState.partialLabels.slice(0) + : null clone.modelState.incorrectLabels = model.modelState.incorrectLabels ? model.modelState.incorrectLabels.slice(0) : null @@ -59,6 +63,9 @@ const Adapter = { json.content.correctLabels = model.modelState.correctLabels ? model.modelState.correctLabels.join('|') : null + json.content.partialLabels = model.modelState.partialLabels + ? model.modelState.partialLabels.join('|') + : null json.content.incorrectLabels = model.modelState.incorrectLabels ? model.modelState.incorrectLabels.join('|') : null diff --git a/packages/obonode/obojobo-chunks-question/adapter.test.js b/packages/obonode/obojobo-chunks-question/adapter.test.js index 005e7bee24..6df49fda12 100644 --- a/packages/obonode/obojobo-chunks-question/adapter.test.js +++ b/packages/obonode/obojobo-chunks-question/adapter.test.js @@ -32,6 +32,7 @@ describe('Question adapter', () => { content: { type: 'default', correctLabels: 'a|b|c', + partialLabels: 'l|m|n', incorrectLabels: 'x|y|z', revealAnswer: 'when-incorrect', solution: { @@ -72,6 +73,7 @@ describe('Question adapter', () => { const attrs = { content: { correctLabels: 'a|b|c', + partialLabels: 'l|m|n', incorrectLabels: 'd|e|f' } } @@ -180,6 +182,19 @@ describe('Question adapter', () => { expect(json).toMatchSnapshot() }) + test('toJSON builds a JSON representation with partial labels', () => { + const json = { content: {} } + const attrs = { + content: { + partialLabels: 'l|m|n' + } + } + const model = new OboModel(attrs) + + QuestionAdapter.construct(model, attrs) + QuestionAdapter.toJSON(model, json) + }) + test('Adapter grabs correctLabels and incorrectLabels from child components', () => { const attrs = { id: 'question', @@ -192,6 +207,7 @@ describe('Question adapter', () => { type: 'ObojoboDraft.Chunks.MCAssessment', content: { correctLabels: 'd|e|f', + partialLabels: 'l|m|n', incorrectLabels: 'x|y|z' }, children: [] @@ -212,4 +228,47 @@ describe('Question adapter', () => { spy.mockRestore() }) + + test('modelState uses correctLabels from content if MCAssessment child does not have any set', () => { + const attrs = { + id: 'question', + content: { + correctLabels: 'a|b|c' + }, + children: [ + { + id: 'mcassessment', + type: 'ObojoboDraft.Chunks.MCAssessment', + children: [] + } + ] + } + + const model = new OboModel(attrs) + QuestionAdapter.construct(model, attrs) + + expect(model.modelState.correctLabels).toEqual(['a', 'b', 'c']) + }) + + test('modelState uses correctLabels from MCAssessment child if content does not have any set', () => { + const attrs = { + id: 'question', + content: {}, + children: [ + { + id: 'mcassessment', + type: 'ObojoboDraft.Chunks.MCAssessment', + content: { + correctLabels: 'd|e|f' + }, + children: [] + } + ] + } + + const model = new OboModel(attrs) + QuestionAdapter.construct(model, attrs) + + expect(model.modelState.correctLabels).toEqual(['d', 'e', 'f']) + }) }) diff --git a/packages/obonode/obojobo-chunks-question/converter.js b/packages/obonode/obojobo-chunks-question/converter.js index 932b13e968..08c9990acf 100644 --- a/packages/obonode/obojobo-chunks-question/converter.js +++ b/packages/obonode/obojobo-chunks-question/converter.js @@ -6,6 +6,7 @@ const QUESTION_NODE = 'ObojoboDraft.Chunks.Question' const SOLUTION_NODE = 'ObojoboDraft.Chunks.Question.Solution' const MCASSESSMENT_NODE = 'ObojoboDraft.Chunks.MCAssessment' const NUMERIC_ASSESSMENT_NODE = 'ObojoboDraft.Chunks.NumericAssessment' +const MATERIA_ASSESSMENT_NODE = 'ObojoboDraft.Chunks.MateriaAssessment' const PAGE_NODE = 'ObojoboDraft.Pages.Page' /** @@ -34,10 +35,8 @@ const slateToObo = node => { break case MCASSESSMENT_NODE: - children.push(Common.Registry.getItemForType(child.type).slateToObo(child)) - break - case NUMERIC_ASSESSMENT_NODE: + case MATERIA_ASSESSMENT_NODE: children.push(Common.Registry.getItemForType(child.type).slateToObo(child)) break @@ -66,7 +65,11 @@ const oboToSlate = node => { const clonedNode = JSON.parse(JSON.stringify(node)) clonedNode.children = clonedNode.children.map(child => { - if (child.type === MCASSESSMENT_NODE || child.type === NUMERIC_ASSESSMENT_NODE) { + if ( + child.type === MCASSESSMENT_NODE || + child.type === NUMERIC_ASSESSMENT_NODE || + child.type === MATERIA_ASSESSMENT_NODE + ) { return Common.Registry.getItemForType(child.type).oboToSlate(child) } else { return Component.helpers.oboToSlate(child) diff --git a/packages/obonode/obojobo-chunks-question/editor-component.js b/packages/obonode/obojobo-chunks-question/editor-component.js index 8f45b50905..9c73b17f99 100644 --- a/packages/obonode/obojobo-chunks-question/editor-component.js +++ b/packages/obonode/obojobo-chunks-question/editor-component.js @@ -13,6 +13,7 @@ const QUESTION_NODE = 'ObojoboDraft.Chunks.Question' const SOLUTION_NODE = 'ObojoboDraft.Chunks.Question.Solution' const MCASSESSMENT_NODE = 'ObojoboDraft.Chunks.MCAssessment' const NUMERIC_ASSESSMENT_NODE = 'ObojoboDraft.Chunks.NumericAssessment' +const MATERIA_ASSESSMENT_NODE = 'ObojoboDraft.Chunks.MateriaAssessment' const ASSESSMENT_NODE = 'ObojoboDraft.Sections.Assessment' const PAGE_NODE = 'ObojoboDraft.Pages.Page' const TEXT_NODE = 'ObojoboDraft.Chunks.Text' @@ -68,6 +69,7 @@ const Question = props => { const type = event.target.value const item = Common.Registry.getItemForType(type) + const newBlock = item.cloneBlankNode() // preserve whether this question is a survey or not @@ -180,6 +182,24 @@ const Question = props => { ` is-type-${content.type}` + ` is-${content.collapsed ? 'collapsed' : 'not-collapsed'}` + const questionTypeOptions = [ + , + + ] + + // only add the Materia widget option if the Materia assessment node has been registered + if (Common.Registry.contentTypes.includes(MATERIA_ASSESSMENT_NODE)) { + questionTypeOptions.push( + + ) + } + return ( {
- {!isAssessmentQuestion ? ( + {!isAssessmentQuestion && !noFooter ? ( { expect(tree).toMatchSnapshot() }) + + test('Does not render a QuestionFooter in practice mode if the assessment has the appropriate setting', () => { + const props = getDefaultProps({ + questionType: 'default', + mode: 'practice', + viewState: 'active', + response: null, + shouldShowRevealAnswerButton: false, + isAnswerRevealed: false, + isShowingExplanation: false, + isShowingExplanationButton: false, + score: null + }) + props.questionAssessmentModel.modelState = { noFooter: true } + + const component = renderer.create() + + expect(component.root.findAllByType(QuestionFooter).length).toBe(0) + }) + + test('Renders a QuestionFooter in review mode regardles of the noFooter setting', () => { + const props = getDefaultProps({ + questionType: 'default', + mode: 'review', + viewState: 'active', + response: null, + shouldShowRevealAnswerButton: false, + isAnswerRevealed: false, + isShowingExplanation: false, + isShowingExplanationButton: false, + score: null + }) + props.questionAssessmentModel.modelState = { noFooter: true } + + const component = renderer.create() + + expect(component.root.findAllByType(QuestionFooter).length).toBe(1) + }) }) diff --git a/packages/obonode/obojobo-chunks-question/question-outcome.js b/packages/obonode/obojobo-chunks-question/question-outcome.js index 71e3224a92..449b9497d2 100644 --- a/packages/obonode/obojobo-chunks-question/question-outcome.js +++ b/packages/obonode/obojobo-chunks-question/question-outcome.js @@ -4,7 +4,8 @@ const QuestionOutcome = props => { const isModeSurvey = props.type === 'survey' const score = props.score const isForScreenReader = props.isForScreenReader - const isCorrect = score === 100 + const isCorrect = score >= 100 + const isIncorrect = score === 0 || score === 'no-score' if (isModeSurvey) { return ( @@ -30,7 +31,7 @@ const QuestionOutcome = props => { ) } - if (!isCorrect && isForScreenReader) { + if (isIncorrect && isForScreenReader) { return (

{`${props.feedbackText} - You received a ${score}% on this question.`}

@@ -38,12 +39,65 @@ const QuestionOutcome = props => { ) } + if (isIncorrect && !isForScreenReader) { + return ( + + ) + } + /*if (!isCorrect && !isForScreenReader) */ + // return ( + // + // ) return ( ) } export default QuestionOutcome + +// import React from 'react' + +// const QuestionOutcome = props => { +// const isModeSurvey = props.type === 'survey' +// const score = props.score +// const isForScreenReader = props.isForScreenReader + +// let resultClass = '' +// let resultText = `${props.feedbackText} - You received a ${score}% on this question.` + +// switch (true) { +// case score === 'no-score': +// resultText = props.feedbackText +// resultClass = 'correct' +// break +// case score === 0: +// if (isModeSurvey || !isForScreenReader) resultText = props.feedbackText +// resultClass = 'incorrect' +// break +// case score >= 100: +// if (isModeSurvey || !isForScreenReader) resultText = props.feedbackText +// resultClass = 'correct' +// break +// case score > 0 && score < 100: +// default: +// resultClass = 'partially-correct' +// break +// } +// // survey overrides other types +// if (isModeSurvey) resultClass = 'survey' + +// return ( +//
+//

{resultText}

+//
+// ) +// } + +// export default QuestionOutcome diff --git a/packages/obonode/obojobo-chunks-question/viewer-component.js b/packages/obonode/obojobo-chunks-question/viewer-component.js index 5886620e23..723d349839 100644 --- a/packages/obonode/obojobo-chunks-question/viewer-component.js +++ b/packages/obonode/obojobo-chunks-question/viewer-component.js @@ -39,6 +39,7 @@ export default class Question extends React.Component { this.onClickReveal = this.onClickReveal.bind(this) this.onFormSubmit = this.onFormSubmit.bind(this) this.onFormChange = this.onFormChange.bind(this) + this.onSaveAnswer = this.onSaveAnswer.bind(this) this.onClickShowExplanation = this.onClickShowExplanation.bind(this) this.onClickHideExplanation = this.onClickHideExplanation.bind(this) this.isShowingExplanation = this.isShowingExplanation.bind(this) @@ -79,7 +80,9 @@ export default class Question extends React.Component { return true } - onFormChange(event) { + // This is passed down the chain directly to assessment chunks + // In the event that they are not forms, they can still submit answers + onSaveAnswer(event) { if (this.props.isReview) return const prevResponse = QuestionUtil.getResponse( @@ -103,6 +106,10 @@ export default class Question extends React.Component { this.nextFocus = FOCUS_TARGET_RESULTS } + onFormChange(event) { + this.onSaveAnswer(event) + } + onFormSubmit(event) { event.preventDefault() @@ -181,6 +188,7 @@ export default class Question extends React.Component { const feedbackText = getLabel( modelState.correctLabels, + modelState.partialLabels, modelState.incorrectLabels, calculatedScoreResponse.score, this.props.mode === 'review', @@ -339,6 +347,7 @@ export default class Question extends React.Component { return getLabel( this.props.model.modelState.correctLabels, + this.props.model.modelState.partialLabels, this.props.model.modelState.incorrectLabels, this.getScore(), this.getMode() === 'review', @@ -536,6 +545,7 @@ export default class Question extends React.Component { onClickShowExplanation={this.onClickShowExplanation} onClickHideExplanation={this.onClickHideExplanation} onClickBlocker={this.onClickBlocker} + onSaveAnswer={this.onSaveAnswer} onEnterPress={this.onEnterPress} /> ) diff --git a/packages/obonode/obojobo-chunks-question/viewer-component.scss b/packages/obonode/obojobo-chunks-question/viewer-component.scss index 6c00c4c96e..c7203c9279 100644 --- a/packages/obonode/obojobo-chunks-question/viewer-component.scss +++ b/packages/obonode/obojobo-chunks-question/viewer-component.scss @@ -618,6 +618,10 @@ background-color: $color-correct; } + &.partially-correct { + background-color: $color-partially-correct; + } + &.incorrect { background-color: $color-incorrect; } diff --git a/packages/obonode/obojobo-chunks-question/viewer-component.test.js b/packages/obonode/obojobo-chunks-question/viewer-component.test.js index 8d0410583d..4ac4f76a74 100644 --- a/packages/obonode/obojobo-chunks-question/viewer-component.test.js +++ b/packages/obonode/obojobo-chunks-question/viewer-component.test.js @@ -52,6 +52,7 @@ const questionJSON = { type: 'ObojoboDraft.Chunks.MCAssessment', content: { correctLabels: 'mock-correct-labels', + partialLabels: 'mock-partial-labels', incorrectLabels: 'mock-incorrect-labels' }, children: [ diff --git a/packages/obonode/obojobo-sections-assessment/components/assessment-score-report-view.scss b/packages/obonode/obojobo-sections-assessment/components/assessment-score-report-view.scss index 0b2a7b0344..a3819eb5a8 100644 --- a/packages/obonode/obojobo-sections-assessment/components/assessment-score-report-view.scss +++ b/packages/obonode/obojobo-sections-assessment/components/assessment-score-report-view.scss @@ -3,6 +3,7 @@ .obojobo-draft--sections--assessment--components--score-report { $color-hr: transparentize(rgba(0, 0, 0, 0.3), 0.2); $color-correct: #38ae24; + $color-partially-correct: #ffc802; $color-incorrect: #c21f00; $color-bg: white; $color-bg2: #f4f4f4; diff --git a/packages/obonode/obojobo-sections-assessment/components/full-review/__snapshots__/basic-review.test.js.snap b/packages/obonode/obojobo-sections-assessment/components/full-review/__snapshots__/basic-review.test.js.snap index d6dc4ec098..e928d8a4a7 100644 --- a/packages/obonode/obojobo-sections-assessment/components/full-review/__snapshots__/basic-review.test.js.snap +++ b/packages/obonode/obojobo-sections-assessment/components/full-review/__snapshots__/basic-review.test.js.snap @@ -174,6 +174,7 @@ exports[`BasicReview Basic component correct 1`] = ` "collapsed": false, "correctLabels": null, "incorrectLabels": null, + "partialLabels": null, "revealAnswer": "default", "solution": Object { "children": Array [ @@ -399,6 +400,7 @@ exports[`BasicReview Basic component incorrect 1`] = ` "collapsed": false, "correctLabels": null, "incorrectLabels": null, + "partialLabels": null, "revealAnswer": "default", "solution": Object { "children": Array [ @@ -624,6 +626,7 @@ exports[`BasicReview Basic component survey 1`] = ` "collapsed": false, "correctLabels": null, "incorrectLabels": null, + "partialLabels": null, "revealAnswer": "default", "solution": Object { "children": Array [