├── .babelrc ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── bundle.js ├── favicon.ico ├── images │ ├── rsp-logo.png │ └── s4-logo.png └── index.html ├── package-lock.json ├── package.json ├── src ├── docs │ ├── components │ │ ├── ApiStatus.js │ │ ├── App.js │ │ └── Learner.js │ ├── favicon.ico │ ├── img │ │ ├── rsp-logo.png │ │ ├── s4-logo-globe.png │ │ └── s4-logo.png │ ├── index.html │ ├── index.js │ └── styles │ │ ├── normalize.css │ │ ├── skeleton.css │ │ └── styles.css ├── lib │ ├── ScormProvider.js │ ├── index.js │ └── withScorm.js └── tests │ ├── ScormProvider_v1_2.test.js │ ├── ScormProvider_v2004.test.js │ ├── __mocks__ │ └── ContextConsumer.js │ ├── helpers.js │ └── setupTests.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react"], 3 | "plugins": [ 4 | "@babel/plugin-proposal-object-rest-spread", 5 | "@babel/plugin-proposal-class-properties", 6 | ["@babel/plugin-transform-runtime", 7 | { 8 | "regenerator": true 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /lib -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs 2 | src 3 | .babelrc 4 | webpack.config.js -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.4 (April 6, 2020) 2 | * Security updates (dependencies) 3 | 4 | # 0.2.3 (March 16, 2020) 5 | * Security updates (dependencies) 6 | 7 | # 0.2.2 (March 9, 2020) 8 | * remove value validation check in setSuspendData method 9 | 10 | # 0.2.1 (March 9, 2020) 11 | * fix default context to add setScore method 12 | 13 | # 0.2.0 (March 6, 2020) 14 | * add setScore method 15 | * add clearSuspendData method 16 | * the following methods now return promises 17 | * getSuspendData 18 | * setSuspendData 19 | * clearSuspendData 20 | * setStatus 21 | * setScore 22 | * set 23 | * add jest / enzyme test suite 24 | 25 | # 0.1.6 (Feb 19, 2020) 26 | * Transfer repository ownership to S4 NetQuest 27 | * Add related images and links for S4 NetQuest 28 | * Update any repository links to point to S4 NetQuest Github 29 | * Change all personal and authorship references 30 | 31 | # 0.1.5 (Feb 6, 2020) 32 | * Correct prop types for ScormProvider 33 | * Remove unnecessary console log calls 34 | * Update vulnerable dependencies 35 | * Update License copyright year 36 | 37 | # 0.1.4 (Aug 30, 2019) 38 | 39 | ### Maintenance 40 | * Update vulnerable dependencies 41 | 42 | # 0.1.3 (May 3, 2019) 43 | 44 | ### Bug Fix 45 | * Dependencies 46 | * Remove prop-types and pipwerks-scorm-api-wrapper from devDependencies and add them to dependencies 47 | 48 | ### Maintenance 49 | * Removal of warning at top of README.md 50 | * Update License copyright year 51 | * Correct the CHANGELOG.md formatting 52 | 53 | # 0.1.2 (July 24, 2018) 54 | 55 | Improved documentation. 56 | 57 | ### Bug Fix 58 | * ScormProvider 59 | * On creation of the SCORM API connection, learnerName now performs a check for the SCORM version before attempting to get the learner name value and makes the correct API call based on the version. 60 | 61 | # 0.1.1 (July 20, 2018) 62 | 63 | ### Bug Fix 64 | * withScorm HOC 65 | * Fix to return a WithScorm component so React DevTools does not show the return from withScorm() as an `` component. 66 | 67 | ### Feature Change 68 | * ScormProvider Component 69 | * Now accepts 2 optional configuration props: 70 | * version: Specify the SCORM API version, accepts "1.2" or "2004". This is optional and probably not needed, as the included API wrapper will automatically attempt to connect to any SCORM API it can find 71 | * debug: Optional (defaults to true), accepts boolean type. Specify if the SCORM API should be in debug mode. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 S4 NetQuest 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React SCORM Provider 2 | Presented by 3 | 4 | S4 NetQuest Logo 5 | 6 | ### Overview 7 | 8 | React-scorm-provider (RSP) is a set of React Components that simplify the inclusion of the [SCORM API](https://scorm.com/scorm-explained/) into your React projects. It utilizes the great SCORM API wrapper from [pipwerks](https://github.com/pipwerks/scorm-api-wrapper). Use RSP to easily add SCORM capabilities to your learning modules, resources, games, or *any* web content you are creating with React. RSP in its current form is meant for single SCO packages and relatively simple communications to the LMS, however it can easily be extended and modified to work for more complex projects. 9 | 10 | Keep in mind that this project does not include any kind of packaging or bundling for SCORM. It simply enables SCORM API calls inside your React code. For SCORM packaging your React app build, check out [simple-scorm-packager](https://github.com/lmihaidaniel/simple-scorm-packager). 11 | 12 | There are two major components of RSP, `ScormProvider` and `withScorm`. 13 | 14 | [View the live demo](https://s4-netquest.github.io/react-scorm-provider) 15 | 16 | --- 17 | 18 | ### ScormProvider Component 19 | 20 | `` 21 | 22 | A wrapper component which should only be included ONCE in your React application component tree. It should be included as close to the root of your component tree as possible so that child components can take advantage of the `withScorm` Higher-Order Component. The `ScormProvider` component automatically establishes a connection to the SCORM API and retrieves some initial data from the LMS via the SCORM API. Once the `ScormProvider` Component is included, any other component that is a child of the `ScormProvider` can access the SCORM-related properties and functions via the `withScorm` Higher-Order Component described below. 23 | 24 | Note that the `ScormProvider` component itself does not pass props to any of its children. In order to access properties and methods of the `ScormProvider` component and make calls to the SCORM API, you *must* use the provided `withScorm` Higher-Order Component. 25 | 26 | #### Configuration 27 | The ScormProvider Component accepts two optional configuration props: 28 | * **version:** (String) (Optional) Specify the SCORM API version, accepts "1.2" or "2004". This is completely optional and probably not needed, as the included pipwerks SCORM API wrapper will automatically attempt to connect to any SCORM API it can find. The version found will the be stored to the ScormProvider Component. 29 | * **debug:** (Boolean) (Optional) (Default: true) Specify if the SCORM API should be in debug mode and emit messages to the console. 30 | 31 | Putting it together: 32 | ``` 33 | // adding a ScormProvider 34 | 35 | import React from 'react'; 36 | import ScormProvider from 'react-scorm-provider'; 37 | 38 | const App = () => { 39 | return ( 40 | 41 |

Hello SCORM world!

42 |

43 | Although I can't yet access any props or methods from the ScormProvider (because I haven't yet included 'withScorm()'), a connection to the SCORM API will be made, initial values retrieved from the LMS via that API, and stored in the ScormProvider Component for use with 'withScorm()'. 44 |

45 |
46 | ); 47 | }; 48 | 49 | export default App; 50 | ``` 51 | 52 | --- 53 | 54 | ### withScorm Higher-Order Component 55 | 56 | `const YourEnhancedComponent = withScorm()(YourComponent)` 57 | 58 | This Higher-Order Component provides access to a number of properties and functions of the `ScormProvider`. Use this to enhance your components with SCORM goodness! All exposed properties and functions are passed to your enhanced component via the 'sco' prop that is added to your component. 59 | 60 | The 'sco' prop object contains the following properties: 61 | ``` 62 | // props.sco 63 | { 64 | // status of the connection to the SCORM API 65 | apiConnected: Bool, 66 | 67 | // cmi.core.student_name (SCORM 1.2) || cmi.learner_name (SCORM 2004) 68 | learnerName: String, 69 | 70 | // indication of course status 71 | completionStatus: String, 72 | 73 | // cmi.suspend_data parsed as an object (all suspend_data must be a JSON.stringify'd object for the suspend_data to work properly with RSP) 74 | suspendData: Object, 75 | 76 | // SCORM API version that is connected ('1.2' or '2004') 77 | scormVersion: String, 78 | 79 | // calling this function will update props.sco.suspendData with the current suspend_data from the LMS 80 | getSuspendData: Function () returns a Promise, 81 | 82 | // this function takes the required key and value arguments and merges them into the suspendData Object, overwriting the value if the key already exists. It then stringifies the object and saves it to the LMS as suspend_data 83 | setSuspendData: Function (key, val) returns a Promise, 84 | 85 | // resets the suspend_data to an empty object, clearing any existing key:value pairs 86 | clearSuspendData: Function () returns a Promise, 87 | 88 | // sends an updated course status to the LMS, accepts one of: "passed", "completed", "failed", "incomplete", "browsed", "not attempted" 89 | setStatus: Function (string) returns a Promise, 90 | 91 | // sends a score to the LMS via an object argument -- { value: Number - score (required), min: Number - min score (optional), max: Number - max score (optional), status: String - same as setStatus method (optional) } 92 | setScore: Function ({ value, min, max, status }) returns a Promise, 93 | 94 | // sets a SCORM value, ex: props.sco.set('cmi.score.scaled', 100) 95 | set: Function (string, val) returns a Promise, 96 | 97 | // gets a SCORM value from the LMS, ex: props.sco.get('cmi.score.scaled') 98 | get: Function (string) returns the LMS value 99 | } 100 | ``` 101 | 102 | All you have to do to provide the `sco` prop object to a component is wrap the component with `withScorm`. In order for `withScorm` to work, the component it is enhancing must be a child of `ScormProvider`. 103 | 104 | Example: 105 | ``` 106 | // enhancing a component and adding the sco object to its props 107 | 108 | import React from 'react'; 109 | import { withScorm } from 'react-scorm-provider'; 110 | 111 | const StandardFunctionalComponent = (props) => { 112 | return ( 113 |
114 |

Welcome, {props.sco.learnerName}!

115 |

Your course status is currently: {props.sco.completionStatus}

116 |

Click the button below to complete the course!

117 | 118 |
119 | ); 120 | }; 121 | 122 | const EnhancedComponent = withScorm()(StandardFunctionalComponent); 123 | 124 | export default EnhancedComponent; 125 | ``` 126 | 127 | --- 128 | 129 | ### Full Example 130 | 131 | ``` 132 | import React from 'react'; 133 | import ScormProvider, { withScorm } from 'react-scorm-provider'; 134 | 135 | const Learner = (props) => { 136 | return ( 137 |
138 |

Welcome, {props.sco.learnerName}!

139 |

Your course status is currently: {props.sco.completionStatus}

140 |

Click the button below to complete the course!

141 | 142 |
143 | ) 144 | } 145 | 146 | const ScoLearner = withScorm()(Learner); 147 | 148 | 149 | const App = () => { 150 | return ( 151 | 152 |

We've got a connection just by including ScormProvider!

153 |

I'm a child with no access to the ScormProvider's props. But the ScoLearner component is enhanced with withScorm()!

154 | 155 |
156 | ); 157 | }; 158 | 159 | export default App; 160 | ``` 161 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/S4-NetQuest/react-scorm-provider/b4c31bb8f27b09789eeef2b35cf5c7462d1dfd91/docs/favicon.ico -------------------------------------------------------------------------------- /docs/images/rsp-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/S4-NetQuest/react-scorm-provider/b4c31bb8f27b09789eeef2b35cf5c7462d1dfd91/docs/images/rsp-logo.png -------------------------------------------------------------------------------- /docs/images/s4-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/S4-NetQuest/react-scorm-provider/b4c31bb8f27b09789eeef2b35cf5c7462d1dfd91/docs/images/s4-logo.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | React SCORM Provider Demo 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-scorm-provider", 3 | "version": "0.2.4", 4 | "description": "Components to easily enable SCORM API communication in React projects.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "dev": "concurrently \"npm run lib:watch\" \"npm run docs\"", 8 | "lib": "babel src/lib -d lib --copy-files", 9 | "lib:watch": "babel src/lib -w -d lib --copy-files", 10 | "docs": "webpack-dev-server --mode development", 11 | "docs:prod": "webpack --mode production", 12 | "test": "jest --verbose" 13 | }, 14 | "keywords": [ 15 | "react", 16 | "scorm", 17 | "scorm1.2", 18 | "scorm2004", 19 | "lms", 20 | "sco", 21 | "elearning", 22 | "e-learning", 23 | "wrapper", 24 | "api" 25 | ], 26 | "license": "MIT", 27 | "jest": { 28 | "setupFilesAfterEnv": [ 29 | "src/tests/setupTests.js" 30 | ] 31 | }, 32 | "peerDependencies": { 33 | "react": "^16.4.2", 34 | "react-dom": "^16.4.2" 35 | }, 36 | "dependencies": { 37 | "pipwerks-scorm-api-wrapper": "^0.1.2", 38 | "prop-types": "^15.7.2", 39 | "react-autobind": "^1.0.6" 40 | }, 41 | "devDependencies": { 42 | "@babel/cli": "^7.4.4", 43 | "@babel/core": "^7.4.4", 44 | "@babel/plugin-proposal-class-properties": "^7.4.4", 45 | "@babel/plugin-proposal-object-rest-spread": "^7.4.4", 46 | "@babel/plugin-transform-runtime": "^7.8.3", 47 | "@babel/preset-env": "^7.4.4", 48 | "@babel/preset-react": "^7.0.0", 49 | "@babel/runtime": "^7.8.4", 50 | "babel-jest": "^25.1.0", 51 | "babel-loader": "^8.0.5", 52 | "concurrently": "^4.1.0", 53 | "css-loader": "^2.1.1", 54 | "enzyme": "^3.11.0", 55 | "enzyme-adapter-react-16": "^1.15.2", 56 | "enzyme-async-helpers": "^0.9.1", 57 | "enzyme-cleanup": "^1.1.2", 58 | "file-loader": "^3.0.1", 59 | "html-webpack-plugin": "^3.2.0", 60 | "jest": "^25.1.0", 61 | "react": "^16.4.2", 62 | "react-dom": "^16.4.2", 63 | "react-test-renderer": "^16.13.0", 64 | "style-loader": "^0.23.1", 65 | "webpack": "^4.30.0", 66 | "webpack-cli": "^3.3.1", 67 | "webpack-dev-server": "^3.3.1" 68 | }, 69 | "author": "S4 NetQuest", 70 | "homepage": "https://github.com/S4-NetQuest/react-scorm-provider", 71 | "repository": { 72 | "type": "git", 73 | "url": "git@github.com:S4-NetQuest/react-scorm-provider.git" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/docs/components/ApiStatus.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import autoBind from 'react-autobind'; 3 | import { withScorm } from '../../lib/index'; 4 | 5 | class ApiStatus extends Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | key: '', 11 | val: '', 12 | score: { 13 | value: 0, 14 | min: 0, 15 | max: 100, 16 | status: "0" 17 | } 18 | } 19 | 20 | autoBind(this); 21 | } 22 | 23 | handleSubmit(e) { 24 | e.preventDefault(); 25 | this.props.sco.setSuspendData(this.state.key, this.state.val); 26 | this.setState({ 27 | key: '', 28 | val: '' 29 | }); 30 | } 31 | 32 | onKeyChange(e) { 33 | this.setState({ 34 | key: e.target.value 35 | }); 36 | } 37 | 38 | onValChange(e) { 39 | this.setState({ 40 | val: e.target.value 41 | }); 42 | } 43 | 44 | onScoreChange(e) { 45 | let field = e.target.name; 46 | let score = { ...this.state.score } 47 | score[field] = field === 'status' ? e.target.value : Number(e.target.value); 48 | this.setState({ 49 | score 50 | }); 51 | } 52 | 53 | handleScoreSubmit(e) { 54 | e.preventDefault(); 55 | if (this.state.score.status === "0") return; 56 | this.props.sco.setScore(this.state.score) 57 | .then(res => { 58 | alert(`Successfully set the score: ${JSON.stringify(res)}`); 59 | }) 60 | .catch(err => { 61 | alert('Something went wrong... score not set.'); 62 | }); 63 | } 64 | 65 | render() { 66 | const { setStatus, apiConnected, completionStatus, scormVersion, suspendData } = this.props.sco; 67 | 68 | return ( 69 |
70 |

SCORM information and status

71 |

SCORM version: {scormVersion}

72 |

{apiConnected ? "api connected" : "api not connected"}

73 |

completion status: {completionStatus}

74 |

suspend_data:

75 | { Object.keys(suspendData).length > 0 ? ( 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | { Object.keys(suspendData).map((key) => { 85 | return ( 86 | 87 | 88 | 89 | 90 | ) 91 | })} 92 | 93 |
KeyValue
{key}{suspendData[key]}
94 | ) 95 | :

no suspend data present

96 | } 97 |
98 |

Send a new status to the API:

99 | 100 | 101 | 102 | 103 | 104 | 105 |
106 |

Add new suspend_data

107 |
108 |
109 |
110 | 111 |
112 |
113 | 114 |
115 |
116 |
117 | 118 |
119 |
120 |
121 |

Send a score to the API

122 |
123 |
124 |
125 | 126 | 127 |
128 |
129 | 130 | 131 |
132 |
133 | 134 | 135 |
136 |
137 | 138 | 147 |
148 |
149 |
150 | 151 |
152 |
153 |
154 | ); 155 | } 156 | } 157 | 158 | export default withScorm()(ApiStatus); -------------------------------------------------------------------------------- /src/docs/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ScormProvider from '../../lib/index'; 3 | import Learner from './Learner'; 4 | import ApiStatus from './ApiStatus'; 5 | import logo from '../img/rsp-logo.png'; 6 | import s4logo from '../img/s4-logo.png'; 7 | 8 | const App = () => { 9 | return ( 10 | 11 |
12 |
13 |
14 |
15 | React Scorm Provider Logo 16 |
17 |
18 |

React SCORM Provider

19 |
20 |

Presented by:

21 | 22 | S4 NetQuest Logo 23 | 24 |
25 |
26 |
27 |
28 |
29 |

What is this?

30 |

31 | React-scorm-provider (RSP) is a set of React Components that simplify the inclusion of the SCORM API into your React projects. It utilizes the great SCORM API wrapper from pipwerks. Use RSP to easily add SCORM capabilities to your learning modules, resources, games, or any web content you are creating with React. 32 |

33 |

34 | Keep in mind that this project does not include any kind of packaging or bundling for SCORM. It simply enables SCORM API calls inside your React code. For SCORM packaging your React app build, check out simple-scorm-packager. RSP in its current form is meant for single SCO packages and relatively simple communications to the LMS, however it can easily be extended and modified to work for more complex projects. 35 |

36 |

37 | There are two major components of RSP, ScormProvider and withScorm. 38 |

39 |
40 | 41 |
42 |

The Components:

43 | 44 |

ScormProvider

45 |

46 | {''} 47 |

48 |

49 | A wrapper component which should only be included ONCE in your React application component tree. It should be included as close to the root of your component tree as possible so that child components can take advantage of the withScorm Higher-Order Component. The ScormProvider component automatically establishes a connection to the SCORM API and retrieves some initial data from the LMS via the SCORM API. Once the ScormProvider Component is included, any other component that is a child of the ScormProvider can access the SCORM-related properties and functions via the withScorm Higher-Order Component described below. 50 |

51 | 52 |
Configuration
53 |

The ScormProvider Component accepts two optional configuration props:

54 |
    55 |
  • 56 | version: (String) (Optional) Specify the SCORM API version, accepts "1.2" or "2004". This is completely optional and probably not needed, as the included pipwerks SCORM API wrapper will automatically attempt to connect to any SCORM API it can find. The version found will the be stored to the ScormProvider Component. 57 |
  • 58 |
  • 59 | debug: (Boolean) (Optional) (Default: true) Specify if the SCORM API should be in debug mode and emit messages to the console. 60 |
  • 61 |
62 | 63 |

Putting it together:

64 |
{`
 65 | // adding a ScormProvider
 66 | 
 67 | import React from 'react';
 68 | import ScormProvider from 'react-scorm-provider';
 69 | 
 70 | const App = () => {
 71 |   return (
 72 |     
 73 |       

Hello SCORM world!

74 |

75 | Although I can't yet access any props or methods from the ScormProvider (because I haven't yet included 'withScorm()'), a connection to the SCORM API will be made, initial values retrieved from the LMS via that API and stored in the ScormProvider Component for use with 'withScorm()'. 76 |

77 |
78 | ); 79 | }; 80 | 81 | export default App;`} 82 |
83 | 84 |

withScorm Higher Order Component

85 |

const YourEnhancedComponent = withScorm()(YourComponent)

86 |

87 | This Higher-Order Component provides access to a number of properties and functions of the ScormProvider. Use this to enhance your components with SCORM goodness! All exposed properties and functions are passed to your enhanced component via the 'sco' prop that is added to your component. 88 |

89 |

The 'sco' prop object contains the following properties:

90 |
 91 |             {`
 92 | {
 93 |   // status of the connection to the SCORM API
 94 |   apiConnected: Bool,
 95 | 
 96 |   // cmi.core.student_name (SCORM 1.2) || cmi.learner_name (SCORM 2004)
 97 |   learnerName: String,
 98 | 
 99 |   // indication of course status
100 |   completionStatus: String,
101 | 
102 |   // cmi.suspend_data parsed as an object (all suspend_data must be a JSON.stringify'd object for the suspend_data to work properly with RSP)
103 |   suspendData: Object,
104 | 
105 |   // SCORM API version that is connected ('1.2' or '2004')
106 |   scormVersion: String,
107 | 
108 |   // calling this function will update props.sco.suspendData with the current suspend_data from the LMS
109 |   getSuspendData: Function () returns a Promise,
110 | 
111 |   // this function takes the required key and value arguments and merges them into the suspendData Object, overwriting the value if the key already exists. It then stringifies the object and saves it to the LMS as suspend_data
112 |   setSuspendData: Function (key, val) returns a Promise,
113 | 
114 |   // resets the suspend_data to an empty object, clearing any existing key:value pairs
115 |   clearSuspendData: Function () returns a Promise,
116 | 
117 |   // sends an updated course status to the LMS, accepts one of: "passed", "completed", "failed", "incomplete", "browsed", "not attempted"
118 |   setStatus: Function (string) returns a Promise,
119 | 
120 |   // sends a score to the LMS via an object argument -- { value: Number - score (required), min: Number - min score (optional), max: Number - max score (optional), status: String - same as setStatus method (optional) }
121 |   setScore: Function ({ value, min, max, status }) returns a Promise,
122 | 
123 |   // sets a SCORM value, ex: props.sco.set('cmi.score.scaled', 100)
124 |   set: Function (string, val) returns a Promise,
125 | 
126 |   // gets a SCORM value from the LMS, ex: props.sco.get('cmi.score.scaled')
127 |   get: Function (string) returns the LMS value
128 | }
129 |             `}
130 |           
131 | 132 |

133 | All you have to do to provide the sco prop object to a component is wrap the component with withScorm. In order for withScorm to work, the component it is enhancing must be a child of ScormProvider. 134 |

135 | 136 |

Example:

137 |
138 |             {`
139 | // enhancing a component and adding the sco object to its props
140 | 
141 | import React from 'react';
142 | import { withScorm } from 'react-scorm-provider';
143 | 
144 | const StandardFunctionalComponent = (props) => {
145 |   return (
146 |     
147 |

Welcome, {props.sco.learnerName}!

148 |

Your course status is currently: {props.sco.completionStatus}

149 |

Click the button below to complete the course!

150 | 151 |
152 | ); 153 | }; 154 | 155 | const EnhancedComponent = withScorm()(StandardFunctionalComponent); 156 | 157 | export default EnhancedComponent; 158 | `}
159 |
160 | 161 | 162 |
163 | 164 |
165 |

Full Example Code:

166 |
167 |             {`
168 | import React from 'react';
169 | import ScormProvider, { withScorm } from 'react-scorm-provider';
170 | 
171 | const Learner = (props) => {
172 |   return (
173 |     
174 |

Welcome, {props.sco.learnerName}!

175 |

Your course status is currently: {props.sco.completionStatus}

176 |

Click the button below to complete the course!

177 | 178 |
179 | ) 180 | } 181 | 182 | const ScoLearner = withScorm()(Learner); 183 | 184 | 185 | const App = () => { 186 | return ( 187 | 188 |

We've got a connection just by including ScormProvider!

189 |

I'm a child with no access to the ScormProvider's props. But the ScoLearner component is enhanced with withScorm()!

190 | 191 |
192 | ); 193 | }; 194 | 195 | export default App; 196 | `}
197 |
198 |
199 | 200 |
201 |

Working Demonstration

202 |

203 | This demo website has RSP integrated. However, this is a website, not a SCORM package. Therefore this example is somewhat limited. A mock SCORM API has been included on this page to respond to API calls. Keep in mind the React components provided by this RSP do nothing to properly prepare and package your application as a SCORM compliant LMS package. 204 |

205 |

206 | Check out the sections below to see some very basic values retrieved from the API, and some buttons that can set values. Our preference is to use suspend_data as a key:value store so there are specific methods for serializing/deserializing objects to suspend_data. See the documentation for more information. 207 |

208 |
209 | 210 | 211 |
212 |
213 | ); 214 | }; 215 | 216 | export default App; -------------------------------------------------------------------------------- /src/docs/components/Learner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withScorm } from '../../lib/index'; 3 | 4 | const Learner = ({ sco }) => { 5 | return ( 6 |
7 |

Learner Information Retreived from the mock API:

8 |

student_name: {sco.learnerName}

9 |
10 | ) 11 | }; 12 | 13 | export default withScorm()(Learner); -------------------------------------------------------------------------------- /src/docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/S4-NetQuest/react-scorm-provider/b4c31bb8f27b09789eeef2b35cf5c7462d1dfd91/src/docs/favicon.ico -------------------------------------------------------------------------------- /src/docs/img/rsp-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/S4-NetQuest/react-scorm-provider/b4c31bb8f27b09789eeef2b35cf5c7462d1dfd91/src/docs/img/rsp-logo.png -------------------------------------------------------------------------------- /src/docs/img/s4-logo-globe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/S4-NetQuest/react-scorm-provider/b4c31bb8f27b09789eeef2b35cf5c7462d1dfd91/src/docs/img/s4-logo-globe.png -------------------------------------------------------------------------------- /src/docs/img/s4-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/S4-NetQuest/react-scorm-provider/b4c31bb8f27b09789eeef2b35cf5c7462d1dfd91/src/docs/img/s4-logo.png -------------------------------------------------------------------------------- /src/docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | React SCORM Provider Demo 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/docs/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import App from "./components/App"; 4 | import './styles/normalize.css'; 5 | import './styles/skeleton.css'; 6 | import './styles/styles.css'; 7 | 8 | render(, document.getElementById("app")); 9 | -------------------------------------------------------------------------------- /src/docs/styles/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } -------------------------------------------------------------------------------- /src/docs/styles/skeleton.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Skeleton V2.0.4 3 | * Copyright 2014, Dave Gamache 4 | * www.getskeleton.com 5 | * Free to use under the MIT license. 6 | * http://www.opensource.org/licenses/mit-license.php 7 | * 12/29/2014 8 | */ 9 | 10 | 11 | /* Table of contents 12 | –––––––––––––––––––––––––––––––––––––––––––––––––– 13 | - Grid 14 | - Base Styles 15 | - Typography 16 | - Links 17 | - Buttons 18 | - Forms 19 | - Lists 20 | - Code 21 | - Tables 22 | - Spacing 23 | - Utilities 24 | - Clearing 25 | - Media Queries 26 | */ 27 | 28 | 29 | /* Grid 30 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 31 | .container { 32 | position: relative; 33 | width: 100%; 34 | max-width: 960px; 35 | margin: 0 auto; 36 | padding: 0 20px; 37 | box-sizing: border-box; } 38 | .column, 39 | .columns { 40 | width: 100%; 41 | float: left; 42 | box-sizing: border-box; } 43 | 44 | /* For devices larger than 400px */ 45 | @media (min-width: 400px) { 46 | .container { 47 | width: 85%; 48 | padding: 0; } 49 | } 50 | 51 | /* For devices larger than 550px */ 52 | @media (min-width: 550px) { 53 | .container { 54 | width: 80%; } 55 | .column, 56 | .columns { 57 | margin-left: 4%; } 58 | .column:first-child, 59 | .columns:first-child { 60 | margin-left: 0; } 61 | 62 | .one.column, 63 | .one.columns { width: 4.66666666667%; } 64 | .two.columns { width: 13.3333333333%; } 65 | .three.columns { width: 22%; } 66 | .four.columns { width: 30.6666666667%; } 67 | .five.columns { width: 39.3333333333%; } 68 | .six.columns { width: 48%; } 69 | .seven.columns { width: 56.6666666667%; } 70 | .eight.columns { width: 65.3333333333%; } 71 | .nine.columns { width: 74.0%; } 72 | .ten.columns { width: 82.6666666667%; } 73 | .eleven.columns { width: 91.3333333333%; } 74 | .twelve.columns { width: 100%; margin-left: 0; } 75 | 76 | .one-third.column { width: 30.6666666667%; } 77 | .two-thirds.column { width: 65.3333333333%; } 78 | 79 | .one-half.column { width: 48%; } 80 | 81 | /* Offsets */ 82 | .offset-by-one.column, 83 | .offset-by-one.columns { margin-left: 8.66666666667%; } 84 | .offset-by-two.column, 85 | .offset-by-two.columns { margin-left: 17.3333333333%; } 86 | .offset-by-three.column, 87 | .offset-by-three.columns { margin-left: 26%; } 88 | .offset-by-four.column, 89 | .offset-by-four.columns { margin-left: 34.6666666667%; } 90 | .offset-by-five.column, 91 | .offset-by-five.columns { margin-left: 43.3333333333%; } 92 | .offset-by-six.column, 93 | .offset-by-six.columns { margin-left: 52%; } 94 | .offset-by-seven.column, 95 | .offset-by-seven.columns { margin-left: 60.6666666667%; } 96 | .offset-by-eight.column, 97 | .offset-by-eight.columns { margin-left: 69.3333333333%; } 98 | .offset-by-nine.column, 99 | .offset-by-nine.columns { margin-left: 78.0%; } 100 | .offset-by-ten.column, 101 | .offset-by-ten.columns { margin-left: 86.6666666667%; } 102 | .offset-by-eleven.column, 103 | .offset-by-eleven.columns { margin-left: 95.3333333333%; } 104 | 105 | .offset-by-one-third.column, 106 | .offset-by-one-third.columns { margin-left: 34.6666666667%; } 107 | .offset-by-two-thirds.column, 108 | .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } 109 | 110 | .offset-by-one-half.column, 111 | .offset-by-one-half.columns { margin-left: 52%; } 112 | 113 | } 114 | 115 | 116 | /* Base Styles 117 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 118 | /* NOTE 119 | html is set to 62.5% so that all the REM measurements throughout Skeleton 120 | are based on 10px sizing. So basically 1.5rem = 15px :) */ 121 | html { 122 | font-size: 62.5%; } 123 | body { 124 | font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ 125 | line-height: 1.6; 126 | font-weight: 400; 127 | font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; 128 | color: #222; } 129 | 130 | 131 | /* Typography 132 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 133 | h1, h2, h3, h4, h5, h6 { 134 | margin-top: 0; 135 | margin-bottom: 2rem; 136 | font-weight: 300; } 137 | h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} 138 | h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } 139 | h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } 140 | h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } 141 | h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } 142 | h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } 143 | 144 | /* Larger than phablet */ 145 | @media (min-width: 550px) { 146 | h1 { font-size: 5.0rem; } 147 | h2 { font-size: 4.2rem; } 148 | h3 { font-size: 3.6rem; } 149 | h4 { font-size: 3.0rem; } 150 | h5 { font-size: 2.4rem; } 151 | h6 { font-size: 1.5rem; } 152 | } 153 | 154 | p { 155 | margin-top: 0; } 156 | 157 | 158 | /* Links 159 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 160 | a { 161 | color: #1EAEDB; } 162 | a:hover { 163 | color: #0FA0CE; } 164 | 165 | 166 | /* Buttons 167 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 168 | .button, 169 | button, 170 | input[type="submit"], 171 | input[type="reset"], 172 | input[type="button"] { 173 | display: inline-block; 174 | height: 38px; 175 | padding: 0 30px; 176 | color: #555; 177 | text-align: center; 178 | font-size: 11px; 179 | font-weight: 600; 180 | line-height: 38px; 181 | letter-spacing: .1rem; 182 | text-transform: uppercase; 183 | text-decoration: none; 184 | white-space: nowrap; 185 | background-color: transparent; 186 | border-radius: 4px; 187 | border: 1px solid #bbb; 188 | cursor: pointer; 189 | box-sizing: border-box; } 190 | .button:hover, 191 | button:hover, 192 | input[type="submit"]:hover, 193 | input[type="reset"]:hover, 194 | input[type="button"]:hover, 195 | .button:focus, 196 | button:focus, 197 | input[type="submit"]:focus, 198 | input[type="reset"]:focus, 199 | input[type="button"]:focus { 200 | color: #333; 201 | border-color: #888; 202 | outline: 0; } 203 | .button.button-primary, 204 | button.button-primary, 205 | input[type="submit"].button-primary, 206 | input[type="reset"].button-primary, 207 | input[type="button"].button-primary { 208 | color: #FFF; 209 | background-color: #33C3F0; 210 | border-color: #33C3F0; } 211 | .button.button-primary:hover, 212 | button.button-primary:hover, 213 | input[type="submit"].button-primary:hover, 214 | input[type="reset"].button-primary:hover, 215 | input[type="button"].button-primary:hover, 216 | .button.button-primary:focus, 217 | button.button-primary:focus, 218 | input[type="submit"].button-primary:focus, 219 | input[type="reset"].button-primary:focus, 220 | input[type="button"].button-primary:focus { 221 | color: #FFF; 222 | background-color: #1EAEDB; 223 | border-color: #1EAEDB; } 224 | 225 | 226 | /* Forms 227 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 228 | input[type="email"], 229 | input[type="number"], 230 | input[type="search"], 231 | input[type="text"], 232 | input[type="tel"], 233 | input[type="url"], 234 | input[type="password"], 235 | textarea, 236 | select { 237 | height: 38px; 238 | padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ 239 | background-color: #fff; 240 | border: 1px solid #D1D1D1; 241 | border-radius: 4px; 242 | box-shadow: none; 243 | box-sizing: border-box; } 244 | /* Removes awkward default styles on some inputs for iOS */ 245 | input[type="email"], 246 | input[type="number"], 247 | input[type="search"], 248 | input[type="text"], 249 | input[type="tel"], 250 | input[type="url"], 251 | input[type="password"], 252 | textarea { 253 | -webkit-appearance: none; 254 | -moz-appearance: none; 255 | appearance: none; } 256 | textarea { 257 | min-height: 65px; 258 | padding-top: 6px; 259 | padding-bottom: 6px; } 260 | input[type="email"]:focus, 261 | input[type="number"]:focus, 262 | input[type="search"]:focus, 263 | input[type="text"]:focus, 264 | input[type="tel"]:focus, 265 | input[type="url"]:focus, 266 | input[type="password"]:focus, 267 | textarea:focus, 268 | select:focus { 269 | border: 1px solid #33C3F0; 270 | outline: 0; } 271 | label, 272 | legend { 273 | display: block; 274 | margin-bottom: .5rem; 275 | font-weight: 600; } 276 | fieldset { 277 | padding: 0; 278 | border-width: 0; } 279 | input[type="checkbox"], 280 | input[type="radio"] { 281 | display: inline; } 282 | label > .label-body { 283 | display: inline-block; 284 | margin-left: .5rem; 285 | font-weight: normal; } 286 | 287 | 288 | /* Lists 289 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 290 | ul { 291 | list-style: circle inside; } 292 | ol { 293 | list-style: decimal inside; } 294 | ol, ul { 295 | padding-left: 0; 296 | margin-top: 0; } 297 | ul ul, 298 | ul ol, 299 | ol ol, 300 | ol ul { 301 | margin: 1.5rem 0 1.5rem 3rem; 302 | font-size: 90%; } 303 | li { 304 | margin-bottom: 1rem; } 305 | 306 | 307 | /* Code 308 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 309 | code { 310 | padding: .2rem .5rem; 311 | margin: 0 .2rem; 312 | font-size: 90%; 313 | white-space: nowrap; 314 | background: #F1F1F1; 315 | border: 1px solid #E1E1E1; 316 | border-radius: 4px; } 317 | pre > code { 318 | display: block; 319 | padding: 1rem 1.5rem; 320 | white-space: pre; } 321 | 322 | 323 | /* Tables 324 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 325 | th, 326 | td { 327 | padding: 12px 15px; 328 | text-align: left; 329 | border-bottom: 1px solid #E1E1E1; } 330 | th:first-child, 331 | td:first-child { 332 | padding-left: 0; } 333 | th:last-child, 334 | td:last-child { 335 | padding-right: 0; } 336 | 337 | 338 | /* Spacing 339 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 340 | button, 341 | .button { 342 | margin-bottom: 1rem; } 343 | input, 344 | textarea, 345 | select, 346 | fieldset { 347 | margin-bottom: 1.5rem; } 348 | pre, 349 | blockquote, 350 | dl, 351 | figure, 352 | table, 353 | p, 354 | ul, 355 | ol, 356 | form { 357 | margin-bottom: 2.5rem; } 358 | 359 | 360 | /* Utilities 361 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 362 | .u-full-width { 363 | width: 100%; 364 | box-sizing: border-box; } 365 | .u-max-full-width { 366 | max-width: 100%; 367 | box-sizing: border-box; } 368 | .u-pull-right { 369 | float: right; } 370 | .u-pull-left { 371 | float: left; } 372 | 373 | 374 | /* Misc 375 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 376 | hr { 377 | margin-top: 3rem; 378 | margin-bottom: 3.5rem; 379 | border-width: 0; 380 | border-top: 1px solid #E1E1E1; } 381 | 382 | 383 | /* Clearing 384 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 385 | 386 | /* Self Clearing Goodness */ 387 | .container:after, 388 | .row:after, 389 | .u-cf { 390 | content: ""; 391 | display: table; 392 | clear: both; } 393 | 394 | 395 | /* Media Queries 396 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 397 | /* 398 | Note: The best way to structure the use of media queries is to create the queries 399 | near the relevant code. For example, if you wanted to change the styles for buttons 400 | on small devices, paste the mobile query code up in the buttons section and style it 401 | there. 402 | */ 403 | 404 | 405 | /* Larger than mobile */ 406 | @media (min-width: 400px) {} 407 | 408 | /* Larger than phablet (also point when grid becomes active) */ 409 | @media (min-width: 550px) {} 410 | 411 | /* Larger than tablet */ 412 | @media (min-width: 750px) {} 413 | 414 | /* Larger than desktop */ 415 | @media (min-width: 1000px) {} 416 | 417 | /* Larger than Desktop HD */ 418 | @media (min-width: 1200px) {} 419 | -------------------------------------------------------------------------------- /src/docs/styles/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #42275a; /* fallback for old browsers */ 3 | background: -webkit-linear-gradient(to left, #734b6d, #42275a); /* Chrome 10-25, Safari 5.1-6 */ 4 | background: linear-gradient(to left, #734b6d, #42275a); /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */ 5 | } 6 | 7 | #main-content-container { 8 | background-color: #F0EAEE; 9 | min-height: 100vh; 10 | -webkit-box-shadow: 2px 0px 6px 0px rgba(0,0,0,0.5); 11 | -moz-box-shadow: 2px 0px 6px 0px rgba(0,0,0,0.5); 12 | box-shadow: 2px 0px 6px 0px rgba(0,0,0,0.5); 13 | } 14 | 15 | .header .logo { 16 | text-align: center; 17 | } 18 | 19 | .header h1 { 20 | margin-top: 2rem; 21 | margin-left: 1rem; 22 | margin-right: 1rem; 23 | } 24 | 25 | @media screen and (min-width: 550px) { 26 | .header h1 { 27 | margin-top: 1rem; 28 | } 29 | } 30 | 31 | @media screen and (min-width: 886px) { 32 | .header h1 { 33 | margin-top: 4rem; 34 | } 35 | } 36 | 37 | @media screen and (min-width: 1070px) { 38 | .header h1 { 39 | margin-top: 6rem; 40 | } 41 | } 42 | 43 | .section { 44 | border-top: 1px solid #fff; 45 | margin: 4rem; 46 | padding-top: 2rem; 47 | } 48 | 49 | .button { 50 | margin: 0.5rem; 51 | } 52 | 53 | button[disabled] { 54 | opacity: 0.5; 55 | cursor: not-allowed; 56 | } 57 | 58 | .sponsor-info { 59 | width: 100%; 60 | display: flex; 61 | align-items: center; 62 | } 63 | 64 | .sponsor-info > p { 65 | margin: 0 1rem; 66 | } 67 | 68 | .sponsor-info > a { 69 | transition: background-color 0.2s ease-in-out; 70 | } 71 | 72 | .sponsor-info > a:hover, 73 | .sponsor-info > a:focus { 74 | background-color: rgba(255, 255, 255, 0.6); 75 | } 76 | 77 | .sponsor-info > a > img { 78 | display: block; 79 | max-width: 250px; 80 | } -------------------------------------------------------------------------------- /src/lib/ScormProvider.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import autoBind from 'react-autobind'; 4 | import { SCORM, debug } from 'pipwerks-scorm-api-wrapper'; 5 | 6 | function isNumOrString(item) { 7 | if (typeof item === 'number') return true; 8 | if (typeof item === 'string' && item.length > 0) return true; 9 | return false; 10 | } 11 | 12 | export const ScoContext = React.createContext({ 13 | apiConnected: false, 14 | learnerName: '', 15 | completionStatus: 'unknown', 16 | suspendData: {}, 17 | scormVersion: '', 18 | getSuspendData: () => {}, 19 | setSuspendData: () => {}, 20 | clearSuspendData: () => {}, 21 | setStatus: () => {}, 22 | setScore: () => {}, 23 | set: () => {}, 24 | get: () => {} 25 | }); 26 | 27 | class ScormProvider extends Component { 28 | constructor(props) { 29 | super(props); 30 | 31 | // this state will be passed in 'sco' to consumers 32 | this.state = { 33 | apiConnected: false, 34 | learnerName: '', 35 | completionStatus: 'unknown', 36 | suspendData: {}, 37 | scormVersion: '' 38 | }; 39 | 40 | autoBind(this); 41 | } 42 | 43 | componentDidMount() { 44 | this.createScormAPIConnection(); 45 | window.addEventListener("beforeunload", this.closeScormAPIConnection); 46 | } 47 | 48 | componentWillUnmount() { 49 | this.closeScormAPIConnection(); 50 | window.removeEventListener("beforeunload", this.closeScormAPIConnection); 51 | } 52 | 53 | createScormAPIConnection() { 54 | if (this.state.apiConnected) return; 55 | 56 | if (this.props.version) SCORM.version = this.props.version; 57 | if (typeof this.props.debug === "boolean") debug.isActive = this.props.debug; 58 | const scorm = SCORM.init(); 59 | if (scorm) { 60 | const version = SCORM.version; 61 | const learnerName = version === '1.2' ? SCORM.get('cmi.core.student_name') : SCORM.get('cmi.learner_name'); 62 | const completionStatus = SCORM.status('get'); 63 | this.setState({ 64 | apiConnected: true, 65 | learnerName: learnerName, 66 | completionStatus: completionStatus, 67 | scormVersion: version 68 | }, () => { 69 | this.getSuspendData(); 70 | }); 71 | } else { 72 | // could not create the SCORM API connection 73 | if (this.props.debug) console.error("ScormProvider init error: could not create the SCORM API connection"); 74 | } 75 | } 76 | 77 | closeScormAPIConnection() { 78 | if (!this.state.apiConnected) return; 79 | 80 | this.setSuspendData(); 81 | SCORM.status('set', this.state.completionStatus); 82 | SCORM.save(); 83 | const success = SCORM.quit(); 84 | if (success) { 85 | this.setState({ 86 | apiConnected: false, 87 | learnerName: '', 88 | completionStatus: 'unknown', 89 | suspendData: {}, 90 | scormVersion: '' 91 | }); 92 | } else { 93 | // could not close the SCORM API connection 94 | if (this.props.debug) console.error("ScormProvider error: could not close the API connection"); 95 | } 96 | } 97 | 98 | getSuspendData() { 99 | return new Promise((resolve, reject) => { 100 | 101 | if (!this.state.apiConnected) return reject('SCORM API not connected'); 102 | 103 | const data = SCORM.get('cmi.suspend_data'); 104 | const suspendData = data && data.length > 0 ? JSON.parse(data) : {}; 105 | this.setState({ 106 | suspendData 107 | }, () => { 108 | return resolve(this.state.suspendData); 109 | }); 110 | 111 | }); 112 | } 113 | 114 | setSuspendData(key, val) { 115 | return new Promise((resolve, reject) => { 116 | 117 | if (!this.state.apiConnected) return reject('SCORM API not connected'); 118 | 119 | let currentData = {...this.state.suspendData} || {}; 120 | if (isNumOrString(key)) currentData[key] = val; 121 | const success = SCORM.set('cmi.suspend_data', JSON.stringify(currentData)); 122 | if (!success) return reject('could not set the suspend data provided'); 123 | this.setState({ 124 | suspendData: currentData 125 | }, () => { 126 | SCORM.save(); 127 | return resolve(this.state.suspendData); 128 | }); 129 | }); 130 | } 131 | 132 | clearSuspendData() { 133 | return new Promise((resolve, reject) => { 134 | 135 | if (!this.state.apiConnected) return reject('SCORM API not connected'); 136 | 137 | const success = SCORM.set('cmi.suspend_data', JSON.stringify({})); 138 | if (!success) return reject('could not clear suspend data'); 139 | this.setState({ 140 | suspendData: {} 141 | }, () => { 142 | SCORM.save(); 143 | return resolve(this.state.suspendData); 144 | }); 145 | }); 146 | } 147 | 148 | setStatus(status, deferSaveCall) { 149 | return new Promise((resolve, reject) => { 150 | 151 | if (!this.state.apiConnected) return reject('SCORM API not connected'); 152 | 153 | const validStatuses = ["passed", "completed", "failed", "incomplete", "browsed", "not attempted", "unknown"]; 154 | if (!validStatuses.includes(status)) { 155 | if (this.props.debug) console.error("ScormProvider setStatus error: could not set the status provided"); 156 | return reject('could not set the status provided'); 157 | } 158 | 159 | const success = SCORM.status("set", status); 160 | if (!success) return reject('could not set the status provided'); 161 | this.setState({ 162 | completionStatus: status 163 | }, () => { 164 | if (!deferSaveCall) SCORM.save(); 165 | return resolve(this.state.completionStatus); 166 | }); 167 | 168 | }); 169 | } 170 | 171 | setScore(scoreObj) { 172 | return new Promise((resolve, reject) => { 173 | 174 | if (!this.state.apiConnected) return reject('SCORM API not connected'); 175 | 176 | const { value, min, max, status } = scoreObj; 177 | const coreStr = this.state.scormVersion === '1.2' ? '.core' : '' 178 | const promiseArr = []; 179 | if (typeof value === 'number') promiseArr.push(this.set(`cmi${coreStr}.score.raw`, value, true)); 180 | if (typeof min === 'number') promiseArr.push(this.set(`cmi${coreStr}.score.min`, min, true)); 181 | if (typeof max === 'number') promiseArr.push(this.set(`cmi${coreStr}.score.max`, max, true)); 182 | if (typeof status === 'string') promiseArr.push(this.setStatus(status, true)); 183 | 184 | Promise.all(promiseArr) 185 | .then(values => { 186 | SCORM.save(); 187 | return resolve(values); 188 | }) 189 | .catch(err => { 190 | return reject('could not save the score object provided'); 191 | }); 192 | 193 | }); 194 | } 195 | 196 | set(param, val, deferSaveCall) { 197 | return new Promise((resolve, reject) => { 198 | 199 | if (!this.state.apiConnected) return reject('SCORM API not connected'); 200 | 201 | const success = SCORM.set(param, val); 202 | if (!success) return reject(`could not set: { ${param}: ${val} }`); 203 | if (!deferSaveCall) SCORM.save(); 204 | return resolve([param, val]); 205 | 206 | }); 207 | } 208 | 209 | get(param) { 210 | if (!this.state.apiConnected) return; 211 | return SCORM.get(param); 212 | } 213 | 214 | render() { 215 | 216 | const val = { 217 | ...this.state, 218 | getSuspendData: this.getSuspendData, 219 | setSuspendData: this.setSuspendData, 220 | clearSuspendData: this.clearSuspendData, 221 | setStatus: this.setStatus, 222 | setScore: this.setScore, 223 | set: this.set, 224 | get: this.get 225 | } 226 | 227 | return ( 228 | 229 | {this.props.children} 230 | 231 | ); 232 | } 233 | } 234 | 235 | ScormProvider.propTypes = { 236 | version: PropTypes.oneOf(['1.2', '2004']), 237 | debug: PropTypes.bool, 238 | } 239 | 240 | export default ScormProvider; -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | import withScorm from './withScorm'; 2 | import ScormProvider from './ScormProvider'; 3 | 4 | export { withScorm }; 5 | export default ScormProvider; 6 | -------------------------------------------------------------------------------- /src/lib/withScorm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ScoContext } from './ScormProvider'; 3 | 4 | function withScorm() { 5 | 6 | return function(WrappedComponent) { 7 | 8 | const WithScorm = function(props) { 9 | return ( 10 | 11 | {value => } 12 | 13 | ) 14 | } 15 | 16 | return WithScorm; 17 | } 18 | } 19 | 20 | export default withScorm; -------------------------------------------------------------------------------- /src/tests/ScormProvider_v1_2.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mockScorm, clearScorm } from './helpers'; 3 | import { shallow, mount, render } from 'enzyme'; 4 | import { waitForState, waitForProps } from 'enzyme-async-helpers'; 5 | import ScormProvider from '../lib/index'; 6 | import ContextConsumer from './__mocks__/ContextConsumer'; 7 | 8 | afterEach(() => { 9 | jest.resetModules(); 10 | }); 11 | 12 | describe('SCORM v1.2', () => { 13 | beforeEach(() => { 14 | return mockScorm(global, '1.2'); 15 | }); 16 | 17 | afterEach(() => { 18 | return clearScorm(global); 19 | }); 20 | 21 | it('renders the component', () => { 22 | const wrapper = shallow(); 23 | expect(wrapper.isEmptyRender()).toEqual(false); 24 | expect(wrapper.instance()).toBeInstanceOf(ScormProvider); 25 | wrapper.unmount(); 26 | }); 27 | 28 | it('connects to the SCORM API', async () => { 29 | const wrapper = shallow(); 30 | wrapper.update(); 31 | await waitForState(wrapper, state => state.apiConnected === true); 32 | expect(wrapper.state('apiConnected')).toEqual(true); 33 | expect(wrapper.state('scormVersion')).toEqual('1.2'); 34 | wrapper.unmount(); 35 | }); 36 | 37 | it('updates initial state on connection to the SCORM API', async () => { 38 | const wrapper = shallow(); 39 | wrapper.update(); 40 | await waitForState(wrapper, state => state.apiConnected === true); 41 | expect(wrapper.state('apiConnected')).toEqual(true); 42 | expect(wrapper.state('scormVersion')).toEqual('1.2'); 43 | expect(wrapper.state('learnerName')).toEqual('Student, Joe'); 44 | expect(wrapper.state('completionStatus')).toEqual('incomplete'); // 'unknown' and 'not attempted' statuses get set to 'incomplete' by pipwerks 45 | expect(wrapper.state('suspendData')).toMatchObject({}); 46 | wrapper.unmount(); 47 | }); 48 | 49 | it('provides a sco prop to consumers', () => { 50 | const wrapper = mount(); 51 | const consumer = wrapper.find(ContextConsumer).childAt(0); 52 | const sco = consumer.prop('sco'); 53 | expect(consumer.isEmptyRender()).toEqual(false); 54 | expect(typeof sco).toBe('object'); 55 | expect(typeof sco.apiConnected).toBe('boolean'); 56 | expect(typeof sco.scormVersion).toBe('string'); 57 | expect(typeof sco.learnerName).toBe('string'); 58 | expect(typeof sco.completionStatus).toBe('string'); 59 | expect(typeof sco.suspendData).toBe('object'); 60 | expect(typeof sco.getSuspendData).toBe('function'); 61 | expect(typeof sco.setSuspendData).toBe('function'); 62 | expect(typeof sco.clearSuspendData).toBe('function'); 63 | expect(typeof sco.setStatus).toBe('function'); 64 | expect(typeof sco.get).toBe('function'); 65 | expect(typeof sco.set).toBe('function'); 66 | wrapper.unmount(); 67 | }); 68 | 69 | it('allows consumer to set suspendData', async () => { 70 | const wrapper = mount(); 71 | let consumer = wrapper.find(ContextConsumer).childAt(0); 72 | const sco = consumer.prop('sco'); 73 | const d = await sco.setSuspendData("foo", "bar"); 74 | expect(d).toMatchObject({ foo: 'bar' }); 75 | 76 | wrapper.update(); 77 | consumer = wrapper.find(ContextConsumer).childAt(0); 78 | expect(consumer.prop('sco').suspendData.foo).toEqual("bar"); 79 | wrapper.unmount(); 80 | }); 81 | 82 | it('updates suspendData when getSuspendData method is called', async () => { 83 | const wrapper = mount(); 84 | let consumer = wrapper.find(ContextConsumer).childAt(0); 85 | let sco = consumer.prop('sco'); 86 | const d = await sco.getSuspendData(); 87 | expect(d).toMatchObject({ foo: 'bar' }); 88 | 89 | wrapper.update(); 90 | consumer = wrapper.find(ContextConsumer).childAt(0); 91 | expect(consumer.prop('sco').suspendData.foo).toEqual("bar"); 92 | sco = consumer.prop('sco'); 93 | const f = await sco.setSuspendData('baz', 'bat'); 94 | expect(f).toMatchObject({ foo: 'bar', baz: 'bat' }); 95 | 96 | wrapper.update(); 97 | consumer = wrapper.find(ContextConsumer).childAt(0); 98 | expect(consumer.prop('sco').suspendData).toMatchObject({ foo: 'bar', baz: 'bat' }); 99 | wrapper.unmount(); 100 | }); 101 | 102 | it('clears suspendData when clearSuspendData method is called', async () => { 103 | const wrapper = mount(); 104 | let consumer = wrapper.find(ContextConsumer).childAt(0); 105 | let sco = consumer.prop('sco'); 106 | expect(sco.suspendData).toMatchObject({ foo: 'bar', baz: 'bat' }); 107 | const d = await sco.clearSuspendData(); 108 | expect(d).toMatchObject({}); 109 | 110 | wrapper.update(); 111 | consumer = wrapper.find(ContextConsumer).childAt(0); 112 | expect(consumer.prop('sco').suspendData).toMatchObject({}); 113 | wrapper.unmount(); 114 | }); 115 | 116 | it('updates completion status when setStatus method is called', async () => { 117 | const wrapper = mount(); 118 | let consumer = wrapper.find(ContextConsumer).childAt(0); 119 | let sco = consumer.prop('sco'); 120 | expect(sco.completionStatus).toEqual('incomplete'); 121 | const s = await sco.setStatus('completed'); 122 | expect(s).toEqual('completed'); 123 | 124 | wrapper.update(); 125 | consumer = wrapper.find(ContextConsumer).childAt(0); 126 | expect(consumer.prop('sco').completionStatus).toEqual('completed'); 127 | wrapper.unmount(); 128 | }); 129 | 130 | it('rejects invalid status when setStatus method is called', async () => { 131 | const wrapper = mount(); 132 | let consumer = wrapper.find(ContextConsumer).childAt(0); 133 | let sco = consumer.prop('sco'); 134 | expect(sco.completionStatus).toEqual('completed'); 135 | const s = await sco.setStatus('a crazy status').catch(e => e); 136 | expect(s).toEqual('could not set the status provided'); 137 | 138 | wrapper.update(); 139 | consumer = wrapper.find(ContextConsumer).childAt(0); 140 | expect(consumer.prop('sco').completionStatus).toEqual('completed'); 141 | wrapper.unmount(); 142 | }); 143 | 144 | it('allows retrieval of any valid SCORM data model key with the get method', async () => { 145 | const wrapper = mount(); 146 | let consumer = wrapper.find(ContextConsumer).childAt(0); 147 | let sco = consumer.prop('sco'); 148 | const v = await sco.get('cmi.core.student_id'); 149 | expect(v).toEqual('000001'); 150 | wrapper.unmount(); 151 | }); 152 | 153 | it('returns an empty string if the get method param is not part of the SCORM data model', async () => { 154 | const wrapper = mount(); 155 | let consumer = wrapper.find(ContextConsumer).childAt(0); 156 | let sco = consumer.prop('sco'); 157 | const v = await sco.get('invalid_key'); 158 | expect(v).toEqual(''); 159 | wrapper.unmount(); 160 | }); 161 | 162 | it('allows valid writable SCORM data model values to be set with the set method', async () => { 163 | const wrapper = mount(); 164 | let consumer = wrapper.find(ContextConsumer).childAt(0); 165 | let sco = consumer.prop('sco'); 166 | const v = await sco.set('cmi.comments', 'this is a great lesson'); 167 | expect(v).toEqual(expect.arrayContaining(['cmi.comments', 'this is a great lesson'])); 168 | wrapper.unmount(); 169 | }); 170 | 171 | it('rejects invalid or read-only SCORM data model keys sent with the set method', async () => { 172 | const wrapper = mount(); 173 | let consumer = wrapper.find(ContextConsumer).childAt(0); 174 | let sco = consumer.prop('sco'); 175 | const v = await sco.set('cmi.core.student_name', 'I should not be able to set this read-only field').catch(e => e); 176 | expect(v).toEqual("could not set: { cmi.core.student_name: I should not be able to set this read-only field }"); 177 | wrapper.unmount(); 178 | }); 179 | 180 | it('allows score information to be sent with the setScore method', async () => { 181 | const wrapper = mount(); 182 | let consumer = wrapper.find(ContextConsumer).childAt(0); 183 | let sco = consumer.prop('sco'); 184 | // initial values should be empty 185 | let iS = sco.get('cmi.core.score.raw'); 186 | expect(iS).toEqual(''); 187 | let iMin = sco.get('cmi.core.score.min'); 188 | expect(iMin).toEqual(''); 189 | let iMax = sco.get('cmi.core.score.max'); 190 | expect(iMax).toEqual(''); 191 | 192 | // set values 193 | let vals = await sco.setScore({ value: 80, min: 0, max: 100 }); 194 | expect(vals).toContainEqual(['cmi.core.score.raw', 80]); 195 | expect(vals).toContainEqual(['cmi.core.score.min', 0]); 196 | expect(vals).toContainEqual(['cmi.core.score.max', 100]); 197 | 198 | // check they have been stored 199 | wrapper.update(); 200 | consumer = wrapper.find(ContextConsumer).childAt(0); 201 | sco = consumer.prop('sco'); 202 | // values should be set 203 | let s = sco.get('cmi.core.score.raw'); 204 | expect(Number(s)).toEqual(80); 205 | let min = sco.get('cmi.core.score.min'); 206 | expect(Number(min)).toEqual(0); 207 | let max = sco.get('cmi.core.score.max'); 208 | expect(Number(max)).toEqual(100); 209 | 210 | wrapper.unmount(); 211 | }); 212 | 213 | }); -------------------------------------------------------------------------------- /src/tests/ScormProvider_v2004.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mockScorm, clearScorm } from './helpers'; 3 | import { shallow, mount, render } from 'enzyme'; 4 | import { waitForState } from 'enzyme-async-helpers'; 5 | import ScormProvider from '../lib/index'; 6 | import ContextConsumer from './__mocks__/ContextConsumer'; 7 | 8 | afterEach(() => { 9 | jest.resetModules(); 10 | }); 11 | 12 | describe('SCORM v2004', () => { 13 | beforeEach(() => { 14 | return mockScorm(global, '2004'); 15 | }); 16 | 17 | afterEach(() => { 18 | return clearScorm(global); 19 | }); 20 | 21 | it('renders the component', () => { 22 | const wrapper = shallow(); 23 | expect(wrapper.isEmptyRender()).toEqual(false); 24 | expect(wrapper.instance()).toBeInstanceOf(ScormProvider); 25 | wrapper.unmount(); 26 | }); 27 | 28 | it('connects to the SCORM API', async () => { 29 | const wrapper = shallow(); 30 | wrapper.update(); 31 | await waitForState(wrapper, state => state.apiConnected === true); 32 | expect(wrapper.state('apiConnected')).toEqual(true); 33 | expect(wrapper.state('scormVersion')).toEqual('2004'); 34 | wrapper.unmount(); 35 | }); 36 | 37 | it('updates initial state on connection to the SCORM API', async () => { 38 | const wrapper = shallow(); 39 | wrapper.update(); 40 | await waitForState(wrapper, state => state.apiConnected === true); 41 | expect(wrapper.state('apiConnected')).toEqual(true); 42 | expect(wrapper.state('scormVersion')).toEqual('2004'); 43 | expect(wrapper.state('learnerName')).toEqual('Student, Joe'); 44 | expect(wrapper.state('completionStatus')).toEqual('incomplete'); // 'unknown' and 'not attempted' statuses get set to 'incomplete' by pipwerks 45 | expect(wrapper.state('suspendData')).toMatchObject({}); 46 | wrapper.unmount(); 47 | }); 48 | 49 | it('provides a sco prop to consumers', () => { 50 | const wrapper = mount(); 51 | const consumer = wrapper.find(ContextConsumer).childAt(0); 52 | const sco = consumer.prop('sco'); 53 | expect(consumer.isEmptyRender()).toEqual(false); 54 | expect(typeof sco).toBe('object'); 55 | expect(typeof sco.apiConnected).toBe('boolean'); 56 | expect(typeof sco.scormVersion).toBe('string'); 57 | expect(typeof sco.learnerName).toBe('string'); 58 | expect(typeof sco.completionStatus).toBe('string'); 59 | expect(typeof sco.suspendData).toBe('object'); 60 | expect(typeof sco.getSuspendData).toBe('function'); 61 | expect(typeof sco.setSuspendData).toBe('function'); 62 | expect(typeof sco.clearSuspendData).toBe('function'); 63 | expect(typeof sco.setStatus).toBe('function'); 64 | expect(typeof sco.get).toBe('function'); 65 | expect(typeof sco.set).toBe('function'); 66 | wrapper.unmount(); 67 | }); 68 | 69 | it('allows consumer to set suspendData', async () => { 70 | const wrapper = mount(); 71 | let consumer = wrapper.find(ContextConsumer).childAt(0); 72 | const sco = consumer.prop('sco'); 73 | const d = await sco.setSuspendData("foo", "bar"); 74 | expect(d).toMatchObject({ foo: 'bar' }); 75 | 76 | wrapper.update(); 77 | consumer = wrapper.find(ContextConsumer).childAt(0); 78 | expect(consumer.prop('sco').suspendData.foo).toEqual("bar"); 79 | wrapper.unmount(); 80 | }); 81 | 82 | it('updates suspendData when getSuspendData method is called', async () => { 83 | const wrapper = mount(); 84 | let consumer = wrapper.find(ContextConsumer).childAt(0); 85 | let sco = consumer.prop('sco'); 86 | const d = await sco.getSuspendData(); 87 | expect(d).toMatchObject({ foo: 'bar' }); 88 | 89 | wrapper.update(); 90 | consumer = wrapper.find(ContextConsumer).childAt(0); 91 | expect(consumer.prop('sco').suspendData.foo).toEqual("bar"); 92 | sco = consumer.prop('sco'); 93 | const f = await sco.setSuspendData('baz', 'bat'); 94 | expect(f).toMatchObject({ foo: 'bar', baz: 'bat' }); 95 | 96 | wrapper.update(); 97 | consumer = wrapper.find(ContextConsumer).childAt(0); 98 | expect(consumer.prop('sco').suspendData).toMatchObject({ foo: 'bar', baz: 'bat' }); 99 | wrapper.unmount(); 100 | }); 101 | 102 | it('clears suspendData when clearSuspendData method is called', async () => { 103 | const wrapper = mount(); 104 | let consumer = wrapper.find(ContextConsumer).childAt(0); 105 | let sco = consumer.prop('sco'); 106 | expect(sco.suspendData).toMatchObject({ foo: 'bar', baz: 'bat' }); 107 | const d = await sco.clearSuspendData(); 108 | expect(d).toMatchObject({}); 109 | 110 | wrapper.update(); 111 | consumer = wrapper.find(ContextConsumer).childAt(0); 112 | expect(consumer.prop('sco').suspendData).toMatchObject({}); 113 | wrapper.unmount(); 114 | }); 115 | 116 | it('updates completion status when setStatus method is called', async () => { 117 | const wrapper = mount(); 118 | let consumer = wrapper.find(ContextConsumer).childAt(0); 119 | let sco = consumer.prop('sco'); 120 | expect(sco.completionStatus).toEqual('incomplete'); 121 | const s = await sco.setStatus('completed'); 122 | expect(s).toEqual('completed'); 123 | 124 | wrapper.update(); 125 | consumer = wrapper.find(ContextConsumer).childAt(0); 126 | expect(consumer.prop('sco').completionStatus).toEqual('completed'); 127 | wrapper.unmount(); 128 | }); 129 | 130 | it('rejects invalid status when setStatus method is called', async () => { 131 | const wrapper = mount(); 132 | let consumer = wrapper.find(ContextConsumer).childAt(0); 133 | let sco = consumer.prop('sco'); 134 | expect(sco.completionStatus).toEqual('completed'); 135 | const s = await sco.setStatus('a crazy status').catch(e => e); 136 | expect(s).toEqual('could not set the status provided'); 137 | 138 | wrapper.update(); 139 | consumer = wrapper.find(ContextConsumer).childAt(0); 140 | expect(consumer.prop('sco').completionStatus).toEqual('completed'); 141 | wrapper.unmount(); 142 | }); 143 | 144 | it('allows retrieval of any valid SCORM data model key with the get method', async () => { 145 | const wrapper = mount(); 146 | let consumer = wrapper.find(ContextConsumer).childAt(0); 147 | let sco = consumer.prop('sco'); 148 | const v = await sco.get('cmi.learner_id'); 149 | expect(v).toEqual('000001'); 150 | wrapper.unmount(); 151 | }); 152 | 153 | it('returns an empty string if the get method param is not part of the SCORM data model', async () => { 154 | const wrapper = mount(); 155 | let consumer = wrapper.find(ContextConsumer).childAt(0); 156 | let sco = consumer.prop('sco'); 157 | const v = await sco.get('invalid_key'); 158 | expect(v).toEqual(''); 159 | wrapper.unmount(); 160 | }); 161 | 162 | it('allows valid writable SCORM data model values to be set with the set method', async () => { 163 | const wrapper = mount(); 164 | let consumer = wrapper.find(ContextConsumer).childAt(0); 165 | let sco = consumer.prop('sco'); 166 | const v = await sco.set('cmi.learner_preference.language', 'en-US'); 167 | expect(v).toEqual(expect.arrayContaining(['cmi.learner_preference.language', 'en-US'])); 168 | wrapper.unmount(); 169 | }); 170 | 171 | it('rejects invalid or read-only SCORM data model keys sent with the set method', async () => { 172 | const wrapper = mount(); 173 | let consumer = wrapper.find(ContextConsumer).childAt(0); 174 | let sco = consumer.prop('sco'); 175 | const v = await sco.set('cmi.learner_name', 'I should not be able to set this read-only field').catch(e => e); 176 | expect(v).toEqual("could not set: { cmi.learner_name: I should not be able to set this read-only field }"); 177 | wrapper.unmount(); 178 | }); 179 | 180 | it('allows score information to be sent with the setScore method', async () => { 181 | const wrapper = mount(); 182 | let consumer = wrapper.find(ContextConsumer).childAt(0); 183 | let sco = consumer.prop('sco'); 184 | // initial values should be empty 185 | let iS = sco.get('cmi.score.raw'); 186 | expect(iS).toEqual(''); 187 | let iMin = sco.get('cmi.score.min'); 188 | expect(iMin).toEqual(''); 189 | let iMax = sco.get('cmi.score.max'); 190 | expect(iMax).toEqual(''); 191 | 192 | // set values 193 | let vals = await sco.setScore({ value: 80, min: 0, max: 100 }); 194 | expect(vals).toContainEqual(['cmi.score.raw', 80]); 195 | expect(vals).toContainEqual(['cmi.score.min', 0]); 196 | expect(vals).toContainEqual(['cmi.score.max', 100]); 197 | 198 | // check they have been stored 199 | wrapper.update(); 200 | consumer = wrapper.find(ContextConsumer).childAt(0); 201 | sco = consumer.prop('sco'); 202 | // values should be set 203 | let s = sco.get('cmi.score.raw'); 204 | expect(Number(s)).toEqual(80); 205 | let min = sco.get('cmi.score.min'); 206 | expect(Number(min)).toEqual(0); 207 | let max = sco.get('cmi.score.max'); 208 | expect(Number(max)).toEqual(100); 209 | 210 | wrapper.unmount(); 211 | }); 212 | 213 | }); -------------------------------------------------------------------------------- /src/tests/__mocks__/ContextConsumer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withScorm } from '../../lib/index'; 4 | 5 | const ContextConsumer = props => { 6 | return ( 7 |
8 | {JSON.stringify(props.sco)} 9 |
10 | ); 11 | }; 12 | 13 | ContextConsumer.propTypes = { 14 | 15 | }; 16 | 17 | export default withScorm()(ContextConsumer); -------------------------------------------------------------------------------- /src/tests/helpers.js: -------------------------------------------------------------------------------- 1 | export const mockScorm = (global, version, data) => { 2 | switch (version) { 3 | case '1.2': 4 | setupSCORM_1(global, data); 5 | break; 6 | case '2004': 7 | setupSCORM_2004(global, data); 8 | break; 9 | default: 10 | throw new Error('mockScorm requires SCORM version'); 11 | } 12 | } 13 | 14 | export const clearScorm = (global) => { 15 | global.API_1484_11 = null; 16 | global.API = null; 17 | } 18 | 19 | function setupSCORM_1(global, d = {}) { 20 | global.API = (function(){ 21 | var data = { 22 | "cmi.core.student_id": "000001", 23 | "cmi.core.student_name": "Student, Joe", 24 | "cmi.core.lesson_location": "", 25 | "cmi.core.lesson_status": "not attempted", 26 | "cmi.suspend_data": "", 27 | ...d 28 | }; 29 | return { 30 | LMSInitialize: function() { 31 | return "true"; 32 | }, 33 | LMSCommit: function() { 34 | return "true"; 35 | }, 36 | LMSFinish: function() { 37 | return "true"; 38 | }, 39 | LMSGetValue: function(model) { 40 | return data[model] || ""; 41 | }, 42 | LMSSetValue: function(model, value) { 43 | // for testing invalid entries, use this read-only model (all others will return true) 44 | if (model === 'cmi.core.student_name') return "false"; 45 | 46 | data[model] = value; 47 | return "true"; 48 | }, 49 | LMSGetLastError: function() { 50 | return "0"; 51 | }, 52 | LMSGetErrorString: function(errorCode) { 53 | return "No error"; 54 | }, 55 | LMSGetDiagnostic: function(errorCode) { 56 | return "No error"; 57 | } 58 | }; 59 | })(); 60 | } 61 | 62 | function setupSCORM_2004(global, d = {}) { 63 | global.API_1484_11 = (function(){ 64 | var data = { 65 | "cmi.learner_id": "000001", 66 | "cmi.learner_name": "Student, Joe", 67 | "cmi.location": "", 68 | "cmi.completion_status": "not attempted", 69 | "cmi.suspend_data": "", 70 | ...d 71 | }; 72 | return { 73 | Initialize: function() { 74 | return "true"; 75 | }, 76 | Commit: function() { 77 | return "true"; 78 | }, 79 | Terminate: function() { 80 | return "true"; 81 | }, 82 | GetValue: function(model) { 83 | return data[model] || ""; 84 | }, 85 | SetValue: function(model, value) { 86 | // for testing invalid entries, use this read-only model (all others will return true) 87 | if (model === 'cmi.learner_name') return "false"; 88 | 89 | data[model] = value; 90 | return "true"; 91 | }, 92 | GetLastError: function() { 93 | return "0"; 94 | }, 95 | GetErrorString: function(errorCode) { 96 | return "No error"; 97 | }, 98 | GetDiagnostic: function(errorCode) { 99 | return "No error"; 100 | } 101 | }; 102 | })(); 103 | } -------------------------------------------------------------------------------- /src/tests/setupTests.js: -------------------------------------------------------------------------------- 1 | // setup file 2 | import { configure } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | 5 | configure({ adapter: new Adapter() }); -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | 4 | module.exports = { 5 | entry: path.join(__dirname, "src/docs"), 6 | output: { 7 | path: path.join(__dirname, "docs"), 8 | filename: "bundle.js" 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.(js|jsx)$/, 14 | use: "babel-loader", 15 | exclude: /node_modules/ 16 | }, 17 | { 18 | test: /\.css$/, 19 | use: ["style-loader", "css-loader"] 20 | }, 21 | { 22 | test: /\.(png|jpg|gif)$/, 23 | use: [ 24 | { 25 | loader: 'file-loader', 26 | options: { 27 | name: '[name].[ext]', 28 | outputPath: 'images/' 29 | } 30 | } 31 | ] 32 | } 33 | ] 34 | }, 35 | plugins: [ 36 | new HtmlWebpackPlugin({ 37 | template: path.join(__dirname, "src/docs/index.html") 38 | }) 39 | ], 40 | resolve: { 41 | extensions: [".js", ".jsx"] 42 | }, 43 | devServer: { 44 | contentBase: path.join(__dirname, "docs"), 45 | port: 8000, 46 | stats: "minimal" 47 | } 48 | }; 49 | --------------------------------------------------------------------------------