diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..86c445f --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "react"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..019c587 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build +dist +node_modules +worknotes diff --git a/.h5pignore b/.h5pignore new file mode 100644 index 0000000..eaa2e7e --- /dev/null +++ b/.h5pignore @@ -0,0 +1,18 @@ +build +node_modules +reports +tests +dev +src +.idea +.git +.babelrc +.gitmodules +.h5pignore +.travis.yml +karma.conf.js +webpack.config.js +README.md +CONTRIBUTING.md +package.json +worknotes diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a414cc0 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: node_js +node_js: + - "stable" + +cache: + directories: + - node_modules + +# Google Chrome +# +# https://github.com/travis-ci/travis-ci/issues/272#issuecomment-14402117 +# http://stackoverflow.com/questions/19255976/how-to-make-travis-execute-angular-tests-on-chrome-please-set-env-variable-chr +# +before_install: + - export CHROME_BIN=chromium-browser + - export DISPLAY=:99.0 + - sh -e /etc/init.d/xvfb start diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f16d333 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +Please check out existing repo issues, and the issues in this document. +Create new issues in the repository if it doesn't exist or collaborate with +others if it already exists. +Create a pull request with test coverage for the issue :) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f447cdc --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# h5p-survey + +[![Build Status](https://travis-ci.org/h5p/h5p-survey.svg?branch=master)](https://travis-ci.org/h5p/h5p-survey) + +A H5P library for creating interactive surveys. + +## Getting started + +Grab all the modules and build the project: +```javascript +npm start +``` + +Run tests: +```javscript +npm test +``` + +Set up development server with test data: +```javascript +npm run dev +``` diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..569d653 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,86 @@ +// Karma configuration +// Generated on Fri Aug 05 2016 15:01:10 GMT+0200 (Vest-Europa (sommertid)) +var path = require('path'); + +module.exports = function(config) { + config.set({ + + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: '', + + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['jasmine'], + + + // list of files / patterns to load in the browser + files: [ + 'tests/**/*.js' + ], + + + // list of files to exclude + exclude: [ + ], + + + // preprocess matching files before serving them to the browser + // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor + preprocessors: { + 'tests/**/*.js': ['webpack'] + }, + + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ['progress'], + + webpack: { + module: { + loaders: [ + { + test: /\.js$/, + include: [ + path.resolve(__dirname, "tests"), + path.resolve(__dirname, "src/scripts") + ], + loader: 'babel' + } + ] + }, + }, + + + // web server port + port: 9876, + + + // enable / disable colors in the output (reporters and logs) + colors: true, + + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + browsers: ['Chrome', 'Firefox'], + + + // Continuous Integration mode + // if true, Karma captures browsers, runs the tests and exits + singleRun: true, + + // Concurrency level + // how many browser should be started simultaneous + concurrency: Infinity + }) +}; diff --git a/library.json b/library.json new file mode 100644 index 0000000..d307744 --- /dev/null +++ b/library.json @@ -0,0 +1,14 @@ +{ + "title": "Simple Multi Choice", + "description": "Create a simple multiple choice", + "majorVersion": 1, + "minorVersion": 0, + "patchVersion": 0, + "runnable": 1, + "author": "thomasmars", + "license": "MIT", + "machineName": "H5P.SimpleMultiChoice", + "preloadedJs": [ + {"path": "dist/dist.js"} + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1b1d9a0 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "simpleMultipleChoice", + "version": "1.0.0", + "description": "Create lightweight multiple choice for use inside content types", + "scripts": { + "test": "karma start", + "testdev": "karma start --autoWatch --no-single-run", + "build": "webpack --progress -p", + "dev": "webpack -d && webpack-dev-server -d --inline --hot --open" + }, + "keywords": [ + "lightweight", + "multiple choice", + "h5p", + "library" + ], + "author": "thomasmars", + "license": "MIT", + "devDependencies": { + "babel-cli": "^6.6.5", + "babel-loader": "^6.2.4", + "babel-preset-es2015": "^6.6.0", + "babel-preset-react": "^6.11.1", + "coveralls": "^2.11.9", + "css-loader": "^0.23.1", + "exports-loader": "^0.6.3", + "expose-loader": "^0.7.1", + "file-loader": "^0.9.0", + "h5p-view": "^1.0.0", + "imports-loader": "^0.6.5", + "jasmine-core": "^2.4.1", + "json-loader": "^0.5.4", + "karma": "^1.1.2", + "karma-chrome-launcher": "^1.0.1", + "karma-firefox-launcher": "^1.0.0", + "karma-jasmine": "^1.0.2", + "karma-webpack": "^1.7.0", + "react": "^15.3.0", + "react-dom": "^15.3.0", + "style-loader": "^0.13.1", + "webpack": "^1.12.14" + } +} diff --git a/semantics.json b/semantics.json new file mode 100644 index 0000000..70adf2d --- /dev/null +++ b/semantics.json @@ -0,0 +1,34 @@ +[ + { + "name": "question", + "label": "Question", + "type": "text" + }, + { + "name": "inputType", + "label": "Multiple choice type", + "type": "select", + "options": [ + { + "label": "Checkbox", + "value": "checkbox" + }, + { + "label": "Radiobox", + "value": "radio" + } + ] + }, + { + "name": "alternatives", + "label": "Answer alternatives", + "type": "list", + "entity": "Alternative", + "min": 2, + "field": { + "name": "alternative", + "label": "Alternative", + "type": "text" + } + } +] diff --git a/src/content/devCheck.json b/src/content/devCheck.json new file mode 100644 index 0000000..db79c6e --- /dev/null +++ b/src/content/devCheck.json @@ -0,0 +1,9 @@ +{ + "question": "What do you like the most ?", + "inputType": "checkbox", + "alternatives": [ + "Fish", + "Turtles", + "Icecream" + ] +} diff --git a/src/content/devRadio.json b/src/content/devRadio.json new file mode 100644 index 0000000..4bb4d2e --- /dev/null +++ b/src/content/devRadio.json @@ -0,0 +1,9 @@ +{ + "question": "What do you like the most ?", + "inputType": "radio", + "alternatives": [ + "Fish", + "Turtles", + "Icecream" + ] +} diff --git a/src/entries/dev.js b/src/entries/dev.js new file mode 100644 index 0000000..b86b297 --- /dev/null +++ b/src/entries/dev.js @@ -0,0 +1,8 @@ +import 'expose?H5P!exports?H5P!h5p-view'; +import SimpleMultiChoice from '../scripts/simple-multiple-choice'; + +var paramsCheck = require('../content/devCheck.json'); +var paramsRadio = require('../content/devRadio.json'); + +new SimpleMultiChoice(paramsCheck).attach(H5P.jQuery('
').appendTo(H5P.jQuery('body'))); +new SimpleMultiChoice(paramsRadio).attach(H5P.jQuery('
').appendTo(H5P.jQuery('body'))); diff --git a/src/entries/dist.js b/src/entries/dist.js new file mode 100644 index 0000000..2e1f2ad --- /dev/null +++ b/src/entries/dist.js @@ -0,0 +1,2 @@ +// Load library +H5P.SimpleMultiChoice = require('../scripts/simple-multiple-choice').default; diff --git a/src/scripts/simple-multiple-choice.js b/src/scripts/simple-multiple-choice.js new file mode 100644 index 0000000..175bcef --- /dev/null +++ b/src/scripts/simple-multiple-choice.js @@ -0,0 +1,108 @@ +let instanceId = 0; + +export default class SimpleMultiChoice extends H5P.EventDispatcher { + + /** + * Constructor for survey + * @param {Object} params + * @param {string} params.question Question text + * @param {string} params.inputType Checkbox or radio + * @param {Array} params.alternatives Array of strings with answers alternatives + * @param {number} contentId + */ + constructor(params, contentId = null) { + super(); + this.params = params; + + // Provide a unique identifier for each multi choice + this.uniqueName = 'h5p-simple-multiple-choice-' + instanceId; + instanceId += 1; + + // Keep track of the state + this.state = this.params.alternatives.map((alt, i) => { + return { + id: i, + text: alt, + checked: false + } + }); + } + + /** + * Attach library to wrapper + * @param {jQuery} $wrapper + */ + attach($wrapper) { + const element = document.createElement('div'); + element.className = 'h5p-simple-multiple-choice'; + const question = this.createQuestion(); + question.className = 'h5p-simple-multiple-choice-question'; + element.appendChild(question); + + const altList = this.createAlternativesList(this.params.alternatives); + element.appendChild(altList); + + $wrapper.get(0).appendChild(element); + } + + /** + * Create html for multiple choice + * @return {HTMLElement} html for multiple choice + */ + createQuestion() { + const question = document.createElement('div'); + question.textContent = this.params.question; + return question; + } + + /** + * Handle input changed, trigger event for listeners + * @param {number} inputIndex Index of input element that changed + */ + handleInputChange(inputIndex) { + this.state = this.state.map((alt, j) => { + let checked = j === inputIndex; + if (this.params.inputType !== 'radio') { + checked = j === inputIndex ? !alt.checked : alt.checked; + } + + // Immutable state + return Object.assign({}, alt, { + checked: checked + }); + }); + + this.trigger('changed', this.state); + } + + /** + * Create alternatives for multiple choice + * @param {Array.} alternatives Answer alternatives + * @return {HTMLElement} html for alternatives list items + */ + createAlternativesList(alternatives) { + const altList = document.createElement('ul'); + altList.className = 'h5p-simple-multiple-choice-alternatives'; + alternatives.forEach((alt, i) => { + + // Elements + const listItem = document.createElement('li'); + const label = document.createElement('label'); + const input = document.createElement('input'); + + // Input attributes + input.type = this.params.inputType; + input.name = this.uniqueName; + + // Label attributes + label.addEventListener('change', this.handleInputChange.bind(this, i)); + label.appendChild(input); + label.innerHTML += alt; + + listItem.appendChild(label); + altList.appendChild(listItem); + }); + + return altList; + } +} diff --git a/tests/simple-multiple-choice.js b/tests/simple-multiple-choice.js new file mode 100644 index 0000000..8595ffe --- /dev/null +++ b/tests/simple-multiple-choice.js @@ -0,0 +1,194 @@ +import 'expose?H5P!exports?H5P!h5p-view'; +import SimpleMultipleChoice from '../src/scripts/simple-multiple-choice'; + +describe('Simple Multiple Choice', () => { + const params = { + question: 'What do you like the most ?', + inputType: 'checkbox', + alternatives: [ + 'Fish', + 'Turtles', + 'Icecream' + ] + }; + + const $body = H5P.jQuery('body'); + + beforeEach(() => { + spyOn(H5P, 'newRunnable'); + }); + + describe('General', () => { + const simpleMultiChoice = new SimpleMultipleChoice(params); + simpleMultiChoice.attach($body); + const surveyElement = $body.get(0).querySelectorAll('.h5p-simple-multiple-choice'); + + + // Check that attach is called + it('should attach to the $wrapper', () => { + expect(surveyElement[0].parentNode).toBe($body.get(0)); + }); + + // Check that all survey elements are called with newRunnable + it('should display question', () => { + const question = document.querySelector('.h5p-simple-multiple-choice-question'); + expect(question.textContent).toBe(params.question); + }); + + it('should create 3 alternatives', () => { + const alternatives = document.querySelector('.h5p-simple-multiple-choice-alternatives'); + expect(alternatives.children.length).toEqual(3); + }); + + it('should trigger immutable states', (done) => { + const alternatives = document.querySelector('.h5p-simple-multiple-choice-alternatives'); + const inputs = alternatives.querySelectorAll('input'); + let changed = 0; + let prevState = null; + + simpleMultiChoice.on('changed', (state) => { + if (changed > 0) { + simpleMultiChoice.off('changed'); + } + else { + prevState = state; + } + + //Wait for DOM + setTimeout(() => { + if (changed > 0) { + console.log("prevState", prevState.data); + console.log("state", state.data); + expect(state.data == prevState.data).toBeFalsy(); + setTimeout(() => { + done(); + }, 100); + } + else { + changed += 1; + inputs[2].click(); + } + }, 100); + }); + inputs[1].click(); + }); + }); + + describe('Checkboxes', () => { + const simpleCheckboxMultiChoice = new SimpleMultipleChoice(params); + simpleCheckboxMultiChoice.attach($body); + const alternatives = document.querySelectorAll('.h5p-simple-multiple-choice-alternatives')[1]; + const inputs = alternatives.querySelectorAll('input'); + + it ('input field should be a checkbox', () => { + expect(inputs[0].type).toBe('checkbox'); + }); + + it('should trigger state change when checked', (done) => { + simpleCheckboxMultiChoice.on('changed', (state) => { + simpleCheckboxMultiChoice.off('changed'); + + //Wait for DOM + setTimeout(() => { + expect(state).toBeDefined(); + done(); + }, 100); + }); + inputs[0].click(); + }); + + it('should check when checked', (done) => { + simpleCheckboxMultiChoice.on('changed', (state) => { + simpleCheckboxMultiChoice.off('changed'); + + //Wait for DOM + setTimeout(() => { + expect(state.data[0].checked).toBeTruthy(); + expect(state.data[1].checked).toBeTruthy(); + expect(state.data[2].checked).toBeFalsy(); + done(); + }, 100); + }); + inputs[1].click(); + }); + + it('should uncheck when unchecked', (done) => { + simpleCheckboxMultiChoice.on('changed', (state) => { + simpleCheckboxMultiChoice.off('changed'); + + //Wait for DOM + setTimeout(() => { + expect(state.data[0].checked).toBeTruthy(); + expect(state.data[1].checked).toBeFalsy(); + expect(state.data[2].checked).toBeFalsy(); + done(); + }, 100); + }); + inputs[1].click(); + }); + }); + + describe('Radiobuttons', () => { + + const radioParams = Object.assign({}, params, { + inputType: 'radio' + }); + + const simpleRadiobuttonMultiChoice = new SimpleMultipleChoice(radioParams); + simpleRadiobuttonMultiChoice.attach($body); + + const alternatives = document.querySelectorAll('.h5p-simple-multiple-choice-alternatives'); + const inputs = alternatives[2].querySelectorAll('input'); + + it('should check when checked', (done) => { + simpleRadiobuttonMultiChoice.on('changed', (state) => { + simpleRadiobuttonMultiChoice.off('changed'); + + //Wait for DOM + setTimeout(() => { + expect(state.data[0].checked).toBeTruthy(); + done(); + }, 100); + }); + inputs[0].click(); + }); + + it('should not uncheck when checked again', () => { + inputs[0].click(); + // Will not fire 'changed' event. + expect(inputs[0].checked).toBeTruthy(); + }); + + it('should uncheck old and check new when checking a new checkbox', (done) => { + simpleRadiobuttonMultiChoice.on('changed', (state) => { + simpleRadiobuttonMultiChoice.off('changed'); + + //Wait for DOM + setTimeout(() => { + expect(state.data[0].checked).toBeFalsy(); + expect(state.data[1].checked).toBeTruthy(); + expect(state.data[2].checked).toBeFalsy(); + done(); + }, 100); + }); + inputs[1].click(); + }) + }); + + describe('Multiple instances', () => { + const alternatives = document.querySelectorAll('.h5p-simple-multiple-choice-alternatives'); + const checkboxInputs = alternatives[1].querySelectorAll('input'); + const radioInputs = alternatives[2].querySelectorAll('input'); + + it('should have different names', () => { + expect(checkboxInputs[0].name).toBeDefined(); + expect(radioInputs[0].name).toBeDefined(); + expect(checkboxInputs[0].name).not.toBe(radioInputs[0].name); + }); + + it('same instance inputs should have same name', () => { + expect(checkboxInputs[0].name).toBe(checkboxInputs[1].name); + expect(radioInputs[0].name).toBe(radioInputs[1].name); + }) + }) +}); diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..8eb32b2 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,49 @@ +var webpack = require('webpack'); +var path = require('path'); + +module.exports = { + entry: { + '../dist/dist': "./src/entries/dist.js", + 'dev': "./src/entries/dev.js" + }, + output: { + path: path.join(__dirname, '/build'), + filename: "[name].js", + sourceMapFileName: "[file].map" + }, + module: { + loaders: [ + { + test: /\.js$/, + include: [ + path.resolve(__dirname, "src/scripts"), + path.resolve(__dirname, "src/entries") + ], + loader: 'babel' + }, + { + test: /\.css$/, + include: path.resolve(__dirname, "src/scripts"), + loader: "style!css?sourceMap&modules" + }, + { + test: /\.json$/, + include: path.resolve(__dirname, "src/content"), + loader: 'json' + } + ] + }, + plugins: [ + new webpack.DefinePlugin({ + 'process.env': { + 'NODE_ENV': JSON.stringify('production') + } + }) + ], + devtool: 'source-map', + devServer: { + port: 8050, + contentBase: "./build", + quiet: true, + } +};