diff --git a/.gitignore b/.gitignore index 6b3abec7f5..741b7e2c50 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ /coverage /coverage-selenium.json /docs* - +archive.tar.gz +/*/dist *.cjsx.js - /phantomjsdriver.log diff --git a/.travis.yml b/.travis.yml index a3d4583e6b..9f42d14200 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,13 @@ # `sudo:false` for faster builds. sudo: false language: node_js -node_js: - - "0.12.10" +env: + - OX_PROJECT=tutor + - OX_PROJECT=coach + - OX_PROJECT=exercises + - OX_PROJECT=shared +before_install: + - "npm install -g npm@^3" script: - npm run ci after_failure: diff --git a/README.md b/README.md index f158afb22a..5b255141fe 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![dependency status][dependency-image]][dependency-url] [![dev dependency status][dev-dependency-image]][dev-dependency-url] -The JavaScript client for openstax Tutor. +The Front-end code for Openstax Tutor related projects ## Install @@ -14,16 +14,16 @@ The JavaScript client for openstax Tutor. - If you don’t have `git` installed you can install homebrew and then `brew install git` 1. `cd tutor-js` move into the checked out directory 1. `npm install` -1. `npm start` +1. `npm run serve ` *(where to use the mock data in `/api` ## Development -- `npm start` starts up a local development webserver which rebuilds files when changed -- `npm test` runs unit tests +- `npm run serve ` starts up a local development webserver which rebuilds files when changed +- `npm test` runs unit tests for all projects - `npm run coverage` generates a code coverage report -- `gulp prod` builds minified files for production +- `npm run build archive` builds minified files for production Use `PORT=8000 npm start` to change the default webserver port. diff --git a/bin/build b/bin/build new file mode 100755 index 0000000000..607f980616 --- /dev/null +++ b/bin/build @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +bin/checkinputs "$@" + +export OX_PROJECT=$1 +echo Building: $OX_PROJECT +export NODE_ENV=production + +[ -d $OX_PROJECT/dist ] && rm -r $OX_PROJECT/dist + +webpack --progress --config webpack.config.js + +if [ $2 == "archive" ]; then + cd $OX_PROJECT + # credit/blame to: http://stackoverflow.com/questions/8201729/rename-files-to-md5-sum-extension-bash + for F in dist/*.min.*; do + mv $F `md5sum $F | perl -MFile::Basename -ne '($m, $f) = split(/\s+/,$_); $f=basename($f); $f =~ m/(.*?)\.(.*)/; print "dist/$1-$m.$2"'` + done + tar -czf ../archive.tar.gz dist/* +fi diff --git a/bin/checkinputs b/bin/checkinputs new file mode 100755 index 0000000000..8ceb9afedf --- /dev/null +++ b/bin/checkinputs @@ -0,0 +1,7 @@ +#!/bin/bash + +if [ $# -eq 0 ]; then + echo "No project to build was given. usage:" + echo "$0 " + exit 1 +fi \ No newline at end of file diff --git a/bin/serve b/bin/serve new file mode 100755 index 0000000000..e56d872e8a --- /dev/null +++ b/bin/serve @@ -0,0 +1,10 @@ +#!/bin/bash + +set -e + +bin/checkinputs "$@" + +export OX_PROJECT=$1 +echo Serving: $OX_PROJECT + +webpack-dev-server --progress --config webpack.config.js diff --git a/bin/tdd b/bin/tdd new file mode 100755 index 0000000000..3d114f155c --- /dev/null +++ b/bin/tdd @@ -0,0 +1,23 @@ +#!/bin/bash + +set -e + +on_termination() { + echo killing webpack $WEBPACK_PID + kill -TERM $WEBPACK_PID 2>/dev/null +} + +bin/checkinputs "$@" + +trap on_termination SIGTERM SIGINT + +export OX_PROJECT=$1 +echo TDD: $OX_PROJECT + + +webpack-dev-server --config webpack.config.js & +WEBPACK_PID=$! +echo webpack started pid: $WEBPACK_PID + + +karma start test/karma.config.js --auto-watch --no-single-run diff --git a/bin/test b/bin/test new file mode 100755 index 0000000000..1bcb2c8f32 --- /dev/null +++ b/bin/test @@ -0,0 +1,10 @@ +#!/bin/bash + +set -e + +bin/checkinputs "$@" + +export OX_PROJECT=$1 +echo Test: $OX_PROJECT + +karma start test/karma.config.js --single-run diff --git a/bin/test-ci b/bin/test-ci new file mode 100755 index 0000000000..e6fc9e5a72 --- /dev/null +++ b/bin/test-ci @@ -0,0 +1,8 @@ +#!/bin/bash + +set -ev + +# travis should be setting the OX_PROJECT variable +# export OX_PROJECT='tutor' + +karma start test/karma.config.js --single-run diff --git a/coach/.gitignore b/coach/.gitignore new file mode 100644 index 0000000000..f7f6419eb5 --- /dev/null +++ b/coach/.gitignore @@ -0,0 +1,6 @@ +/node_modules +*.orig +/.tmp +/dist +/assets +/test-failed-*.png diff --git a/coach/.travis.yml b/coach/.travis.yml new file mode 100644 index 0000000000..27ce7a9f0b --- /dev/null +++ b/coach/.travis.yml @@ -0,0 +1,7 @@ +# `sudo:false` for faster builds. +sudo: false +language: node_js +node_js: + - "0.10" +after_failure: + - npm list diff --git a/coach/Gulpfile.coffee b/coach/Gulpfile.coffee new file mode 100644 index 0000000000..50f4001cd1 --- /dev/null +++ b/coach/Gulpfile.coffee @@ -0,0 +1,132 @@ +_ = require 'underscore' +coffeelint = require 'gulp-coffeelint' +del = require 'del' +env = require 'gulp-env' +gulp = require 'gulp' +gutil = require 'gulp-util' +gzip = require 'gulp-gzip' +karma = require 'karma' +rev = require 'gulp-rev' +tar = require 'gulp-tar' +watch = require 'gulp-watch' +webpack = require 'webpack' +webpackServer = require 'webpack-dev-server' +WPExtractText = require 'extract-text-webpack-plugin' + +getWebpackConfig = require './webpack.config' + +TestRunner = require './test/config/test-runner' + +KARMA_DEV_CONFIG = + configFile: __dirname + '/test/karma-dev.config.coffee' + singleRun: false + +KARMA_CONFIG = + configFile: __dirname + '/test/config/karma.config.coffee' + singleRun: true + +KARMA_COVERAGE_CONFIG = + configFile: __dirname + '/test/config/karma-coverage.config.coffee' + singleRun: true + +DIST_DIR = './dist' + +# ----------------------------------------------------------------------- +# Build Javascript and Styles using webpack +# ----------------------------------------------------------------------- +gulp.task '_cleanDist', (done) -> + del(['./dist/*'], done) + +gulpWebpack = (name) -> + env(vars:{ NODE_ENV: 'production' }) + config = getWebpackConfig(name, process.env.NODE_ENV is 'production') + webpack(config, (err, stats) -> + throw new gutil.PluginError("webpack", err) if err + gutil.log("[webpack]", stats.toString({ + # output options + })) + ) + +gulp.task '_buildMain', _.partial(gulpWebpack, 'main') +gulp.task '_buildMainMin', _.partial(gulpWebpack, 'main.min') +gulp.task '_buildFull', _.partial(gulpWebpack, 'fullBuild') +gulp.task '_buildFullMin', _.partial(gulpWebpack, 'fullBuild.min') +gulp.task '_buildDemo', _.partial(gulpWebpack, 'demo') + +gulp.task 'build', ['_cleanDist', '_buildMain', '_buildMainMin', '_buildFull', '_buildFullMin'] + +gulp.task '_tagRev', ['build'], -> + gulp.src("#{DIST_DIR}/*.min.*") + .pipe(rev()) + .pipe(gulp.dest(DIST_DIR)) + .pipe(rev.manifest()) + .pipe(gulp.dest(DIST_DIR)) + +# ----------------------------------------------------------------------- +# Production +# ----------------------------------------------------------------------- + +gulp.task '_archive', ['_tagRev'], -> + gulp.src(["#{DIST_DIR}/*"], base: DIST_DIR) + .pipe(tar('archive.tar')) + .pipe(gzip()) + .pipe(gulp.dest(DIST_DIR)) + +# ----------------------------------------------------------------------- +# Development +# ----------------------------------------------------------------------- +# +gulp.task '_karma', -> + server = new karma.Server(KARMA_DEV_CONFIG) + server.start() + +# TODO will rewrite this to fit new config +gulp.task '_webserver', -> + env(vars:{ NODE_ENV: 'development' }) + config = getWebpackConfig('devServer', process.env.NODE_ENV is 'production') + server = new webpackServer(webpack(config), config.devServer) + server.listen(config.devServer.port, '0.0.0.0', (err) -> + throw new gutil.PluginError("webpack-dev-server", err) if err + ) + +# ----------------------------------------------------------------------- +# Public Tasks +# ----------------------------------------------------------------------- +# +# External tasks called by various people (devs, testers, Travis, production) +# +# TODO: Add this to webpack +gulp.task 'lint', -> + gulp.src(['./src/**/*.{cjsx,coffee}', './*.coffee', './test/**/*.{cjsx,coffee}']) + .pipe(coffeelint()) + # Run through both reporters so lint failures are visible and Travis will error + .pipe(coffeelint.reporter()) + .pipe(coffeelint.reporter('fail')) + +gulp.task 'prod', ['_archive'] + +gulp.task 'serve', ['_webserver'] + +gulp.task 'test', ['lint'], (done) -> + server = new karma.Server(KARMA_CONFIG) + server.start() + +gulp.task 'coverage', -> + server = new karma.Server(KARMA_COVERAGE_CONFIG) + server.start() + +# clean out the dist directory before running since otherwise stale files might be served from there. +# The _webserver task builds and serves from memory with a fallback to files in dist +gulp.task 'dev', ['_cleanDist', '_webserver'] + +gulp.task 'testrunner', -> + runner = new TestRunner() + watch('{src,test}/**/*', (change) -> + runner.onFileChange(change) unless change.unlink + ) + +gulp.task 'tdd', ['_cleanDist', '_webserver', 'testrunner'] + +gulp.task 'demo', ['_buildDemo'] + +gulp.task 'release', ['build', 'demo'] diff --git a/coach/README.md b/coach/README.md new file mode 100644 index 0000000000..e1ff52fb99 --- /dev/null +++ b/coach/README.md @@ -0,0 +1,23 @@ +See it [here](https://openstax.github.io/concept-coach/). + +# To run locally: + +you need to have [tutor-server](https://github.com/openstax/tutor-server) running locally. Then, + +1. Clone this repo +1. `npm install` +1. `DEV_PORT=3004 gulp tdd` + * Replace with whatever port you'd like +1. In `tutor-server`'s `config/secrets.yml`, add `http://localhost:3004` to the list of `cc-origins` + * Starts around line 20. +1. Restart the `tutor-server` +1. Go to `http://localhost:3004` and the Launch button for Concept Coach should be there! + + +# To build for release and deployment: + +1. Run `npm run release` + * This will switch to gh-pages branch, merge master into it, and then build +1. Commit files and push to github gh-pages branch +1. Tag and make release in github +1. Copy sha hash of tag into webview's `bower.json` diff --git a/coach/api/cc/dashboard/GET.json b/coach/api/cc/dashboard/GET.json new file mode 100644 index 0000000000..02c2c74543 --- /dev/null +++ b/coach/api/cc/dashboard/GET.json @@ -0,0 +1,107 @@ +{ + "tasks": [ + { + "id": "403", + "title": "Concept Coach", + "opens_at": "2016-01-12T19:59:13.835Z", + "last_worked_at": "2016-01-12T20:10:06.043Z", + "type": "concept_coach", + "complete": true + } + ], + "role": { + "id": "238", + "type": "student" + }, + "course": { + "name": "Concept Coach", + "teachers": [ + { + "id": "2", + "role_id": "43", + "first_name": "Charles", + "last_name": "Morris" + }, + { + "id": "6", + "role_id": "171", + "first_name": "Molly", + "last_name": "Bloom" + }, + { + "id": "7", + "role_id": "172", + "first_name": "Lionel", + "last_name": "Hayes" + } + ] + }, + "chapters": [ + { + "id": "22", + "title": "The Chemical Foundation of Life", + "chapter_section": [ + 2 + ], + "pages": [ + { + "id": "106", + "title": "Sample module 2", + "uuid": "7636a3bf-eb80-4898-8b2c-e81c1711b99f", + "version": "2", + "chapter_section": [ + 2, + 1 + ], + "last_worked_at": "2016-01-12T20:10:06.043Z", + "exercises": [ + { + "id": "1632", + "is_completed": true, + "is_correct": false + }, + { + "id": "1634", + "is_completed": true, + "is_correct": true + }, + { + "id": "1626", + "is_completed": true, + "is_correct": true + } + ] + }, + { + "id": "107", + "title": "Sample module 3", + "uuid": "7636a3bf-eb80-4898-8b2c-e81c1711b99f", + "version": "2", + "chapter_section": [ + 3, + 1 + ], + "last_worked_at": "2016-01-12T20:10:06.043Z", + "exercises": [ + { + "id": "1832", + "is_completed": true, + "is_correct": false + }, + { + "id": "1834", + "is_completed": true, + "is_correct": true + }, + { + "id": "1826", + "is_completed": true, + "is_correct": true + } + ] + } + + ] + } + ] +} diff --git a/coach/api/cc/tasks/C_UUID/m_uuid/GET.json b/coach/api/cc/tasks/C_UUID/m_uuid/GET.json new file mode 100644 index 0000000000..593a517d26 --- /dev/null +++ b/coach/api/cc/tasks/C_UUID/m_uuid/GET.json @@ -0,0 +1,96 @@ +{ + "id": "265", + "type": "concept_coach", + "title": "Dummy task title", + "description": "Dummy task description", + "opens_at": "2013-01-31T20:48:48.287Z", + "is_shared": false, + "steps": [{ + "id": "4571", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": false, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-1@1", + "content": { + "uid": "-1@1", + "tags": [], + "stimulus_html": "This is fake exercise -1. ", + "questions": [{ + "id": "-1", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 10 N.", + "answers": [{ + "id": "-2", + "content_html": "10 N" + }, { + "id": "-3", + "content_html": "1 N" + }] + }] + } + }, { + "id": "4572", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": false, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-2@1", + "content": { + "uid": "-2@1", + "tags": [], + "stimulus_html": "This is fake exercise -2. ", + "questions": [{ + "id": "-4", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 11 N.", + "answers": [{ + "id": "-5", + "content_html": "10 N" + }, { + "id": "-6", + "content_html": "1 N" + }] + }] + } + }, { + "id": "4573", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": false, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-3@1", + "content": { + "uid": "-3@1", + "tags": [], + "stimulus_html": "This is fake exercise -3. ", + "questions": [{ + "id": "-7", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 12 N.", + "answers": [{ + "id": "-8", + "content_html": "10 N" + }, { + "id": "-9", + "content_html": "1 N" + }] + }] + } + }] +} diff --git a/coach/api/enrollment_changes/1/approve/PUT.json b/coach/api/enrollment_changes/1/approve/PUT.json new file mode 100644 index 0000000000..25c72e74fd --- /dev/null +++ b/coach/api/enrollment_changes/1/approve/PUT.json @@ -0,0 +1,13 @@ +{ + "id":"1", + "to": { + "course": { + "id":"3","name":"Physics I" + }, + "period":{ + "id":"6","name":"1st" + } + }, + "status":"processed", + "requires_enrollee_approval":true +} diff --git a/coach/api/enrollment_changes/POST.json b/coach/api/enrollment_changes/POST.json new file mode 100644 index 0000000000..6f2a7f8ecb --- /dev/null +++ b/coach/api/enrollment_changes/POST.json @@ -0,0 +1,17 @@ +{ + "id":"1", + "to": { + "course": { + "id":"3","name":"Physics I" + "teachers": [ + {"name": "Charles Morris", "first_name": "Charles", "last_name": "Morris"}, + {"name": "", "first_name": "William", "last_name": "Blake"} + ] + }, + "period":{ + "id":"6","name":"1st" + } + }, + "status":"pending", + "requires_enrollee_approval":true +} diff --git a/api/notifications.json b/coach/api/notifications/GET.json similarity index 100% rename from api/notifications.json rename to coach/api/notifications/GET.json diff --git a/coach/api/steps/4571/GET.json b/coach/api/steps/4571/GET.json new file mode 100644 index 0000000000..b8cfe0b02e --- /dev/null +++ b/coach/api/steps/4571/GET.json @@ -0,0 +1,30 @@ +{ + "id": "4571", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": false, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-1@1", + "content": { + "uid": "-1@1", + "tags": [], + "stimulus_html": "This is fake exercise -1. ", + "questions": [{ + "id": "-1", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 10 N.", + "answers": [{ + "id": "-2", + "content_html": "10 N" + }, { + "id": "-3", + "content_html": "1 N" + }] + }] + } +} \ No newline at end of file diff --git a/coach/api/steps/4571/PATCH.json b/coach/api/steps/4571/PATCH.json new file mode 100644 index 0000000000..b8cfe0b02e --- /dev/null +++ b/coach/api/steps/4571/PATCH.json @@ -0,0 +1,30 @@ +{ + "id": "4571", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": false, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-1@1", + "content": { + "uid": "-1@1", + "tags": [], + "stimulus_html": "This is fake exercise -1. ", + "questions": [{ + "id": "-1", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 10 N.", + "answers": [{ + "id": "-2", + "content_html": "10 N" + }, { + "id": "-3", + "content_html": "1 N" + }] + }] + } +} \ No newline at end of file diff --git a/coach/api/steps/4571/completed/PUT.json b/coach/api/steps/4571/completed/PUT.json new file mode 100644 index 0000000000..c4ad62b69f --- /dev/null +++ b/coach/api/steps/4571/completed/PUT.json @@ -0,0 +1,33 @@ +{ + "id": "4571", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": true, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-1@1", + "content": { + "uid": "-1@1", + "tags": [], + "stimulus_html": "This is fake exercise -1. ", + "questions": [{ + "id": "-1", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 10 N.", + "answers": [{ + "id": "-2", + "content_html": "10 N" + }, { + "id": "-3", + "content_html": "1 N" + }] + }] + }, + "feedback_html": "The original hypothesis is incorrect because when the coffee maker was plugged in it worked. Therefore, it is incorrect to hypothesize that there is something wrong with the outlet. Alternative hypothesis includes that the toaster wasn’t turned on.", + "correct_answer_id": "-3", + "is_correct": false +} \ No newline at end of file diff --git a/coach/api/steps/4572/GET.json b/coach/api/steps/4572/GET.json new file mode 100644 index 0000000000..d70da4104a --- /dev/null +++ b/coach/api/steps/4572/GET.json @@ -0,0 +1,30 @@ +{ + "id": "4572", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": false, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-2@1", + "content": { + "uid": "-2@1", + "tags": [], + "stimulus_html": "This is fake exercise -2. ", + "questions": [{ + "id": "-4", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 10 N.", + "answers": [{ + "id": "-5", + "content_html": "10 N" + }, { + "id": "-6", + "content_html": "1 N" + }] + }] + } +} \ No newline at end of file diff --git a/coach/api/steps/4572/PATCH.json b/coach/api/steps/4572/PATCH.json new file mode 100644 index 0000000000..d70da4104a --- /dev/null +++ b/coach/api/steps/4572/PATCH.json @@ -0,0 +1,30 @@ +{ + "id": "4572", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": false, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-2@1", + "content": { + "uid": "-2@1", + "tags": [], + "stimulus_html": "This is fake exercise -2. ", + "questions": [{ + "id": "-4", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 10 N.", + "answers": [{ + "id": "-5", + "content_html": "10 N" + }, { + "id": "-6", + "content_html": "1 N" + }] + }] + } +} \ No newline at end of file diff --git a/coach/api/steps/4572/completed/PUT.json b/coach/api/steps/4572/completed/PUT.json new file mode 100644 index 0000000000..bec94ffd48 --- /dev/null +++ b/coach/api/steps/4572/completed/PUT.json @@ -0,0 +1,33 @@ +{ + "id": "4572", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": false, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-2@1", + "content": { + "uid": "-2@1", + "tags": [], + "stimulus_html": "This is fake exercise -2. ", + "questions": [{ + "id": "-4", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 10 N.", + "answers": [{ + "id": "-5", + "content_html": "10 N" + }, { + "id": "-6", + "content_html": "1 N" + }] + }] + }, + "feedback_html": "The original hypothesis is incorrect because when the coffee maker was plugged in it worked. Therefore, it is incorrect to hypothesize that there is something wrong with the outlet. Alternative hypothesis includes that the toaster wasn’t turned on.", + "correct_answer_id": "-5", + "is_correct": true +} \ No newline at end of file diff --git a/coach/api/steps/4573/GET.json b/coach/api/steps/4573/GET.json new file mode 100644 index 0000000000..61bfd5548b --- /dev/null +++ b/coach/api/steps/4573/GET.json @@ -0,0 +1,30 @@ +{ + "id": "4573", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": false, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-3@1", + "content": { + "uid": "-3@1", + "tags": [], + "stimulus_html": "This is fake exercise -3. ", + "questions": [{ + "id": "-7", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 10 N.", + "answers": [{ + "id": "-8", + "content_html": "10 N" + }, { + "id": "-9", + "content_html": "1 N" + }] + }] + } +} \ No newline at end of file diff --git a/coach/api/steps/4573/PATCH.json b/coach/api/steps/4573/PATCH.json new file mode 100644 index 0000000000..61bfd5548b --- /dev/null +++ b/coach/api/steps/4573/PATCH.json @@ -0,0 +1,30 @@ +{ + "id": "4573", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": false, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-3@1", + "content": { + "uid": "-3@1", + "tags": [], + "stimulus_html": "This is fake exercise -3. ", + "questions": [{ + "id": "-7", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 10 N.", + "answers": [{ + "id": "-8", + "content_html": "10 N" + }, { + "id": "-9", + "content_html": "1 N" + }] + }] + } +} \ No newline at end of file diff --git a/coach/api/steps/4573/completed/PUT.json b/coach/api/steps/4573/completed/PUT.json new file mode 100644 index 0000000000..2323a7f5ae --- /dev/null +++ b/coach/api/steps/4573/completed/PUT.json @@ -0,0 +1,33 @@ +{ + "id": "4573", + "task_id": "265", + "type": "exercise", + "group": "default", + "is_completed": false, + "related_content": [], + "labels": [], + "content_url": "https://exercises-dev.openstax.org/exercises/-3@1", + "content": { + "uid": "-3@1", + "tags": [], + "stimulus_html": "This is fake exercise -3. ", + "questions": [{ + "id": "-7", + "formats": [ + "multiple-choice", + "free-response" + ], + "stem_html": "Select 10 N.", + "answers": [{ + "id": "-8", + "content_html": "10 N" + }, { + "id": "-9", + "content_html": "1 N" + }] + }] + }, + "feedback_html": "The original hypothesis is incorrect because when the coffee maker was plugged in it worked. Therefore, it is incorrect to hypothesize that there is something wrong with the outlet. Alternative hypothesis includes that the toaster wasn’t turned on.", + "correct_answer_id": "-9", + "is_correct": false +} \ No newline at end of file diff --git a/coach/auth/status/GET.json b/coach/auth/status/GET.json new file mode 100644 index 0000000000..6517839c56 --- /dev/null +++ b/coach/auth/status/GET.json @@ -0,0 +1,46 @@ +{ + "access_token": "", + "user": { + "name": "Studious Student", + "is_admin": false, + "is_customer_service": false, + "is_content_analyst": false, + "profile_url": "http://localhost:2999/profile" + }, + "endpoints":{ + "login":"http://localhost:3001/accounts/login", + "iframe_login":"http://localhost:3001/auth/iframe", + "accounts_iframe":"http://localhost:2999/remote/iframe" + }, + "courses": [ + { + "id": "1", + "name": "Biology I", + "catalog_offering_identifier": "biology", + "ecosystem_id": "1", + "ecosystem_book_uuid": "d52e93f4-8653-4273-86da-3850001c0786", + "students": [ + { + "last_name": "Morris", + "name": "Charles Morris", + "period_id": "7", + "role_id": "85", + "student_identifier":"1324" + } + ], + "roles": [ + { + "id": "11", + "type": "student" + } + ], + "periods": [ + { + "id": "1", + "name": "1st", + "enrollment_code": "mere regime" + } + ] + } + ] +} diff --git a/coach/coffeelint.json b/coach/coffeelint.json new file mode 100644 index 0000000000..461c0b7c80 --- /dev/null +++ b/coach/coffeelint.json @@ -0,0 +1,49 @@ +{ + "coffeelint": { + "transforms": [ + "coffee-react-transform" + ] + }, + "indentation": { + "level": "error", + "value": 2 + }, + "line_endings": { + "value": "unix", + "level": "error" + }, + "arrow_spacing": { + "level": "error" + }, + "no_empty_functions": { + "level": "error" + }, + "no_empty_param_list": { + "level": "error" + }, + "no_interpolation_in_single_quotes": { + "level": "error" + }, + "no_throwing_strings": { + "level": "error" + }, + "no_unnecessary_double_quotes": { + "level": "ignore" + }, + "no_unnecessary_fat_arrows": { + "level": "error" + }, + "prefer_english_operator": { + "level": "error" + }, + "space_operators": { + "level": "error" + }, + "spacing_after_comma": { + "level": "error" + }, + "max_line_length": { + "value": 135, + "level": "error" + } +} diff --git a/coach/configs/base.coffee b/coach/configs/base.coffee new file mode 100644 index 0000000000..b91476ace9 --- /dev/null +++ b/coach/configs/base.coffee @@ -0,0 +1,2 @@ +module.exports = + devPort: '3005' diff --git a/coach/configs/webpack.base.coffee b/coach/configs/webpack.base.coffee new file mode 100644 index 0000000000..eb34babda5 --- /dev/null +++ b/coach/configs/webpack.base.coffee @@ -0,0 +1,9 @@ +module.exports = + output: + libraryTarget: 'umd' + library: 'OpenStaxConceptCoach' + umdNamedDefine: true + plugins: [ + # Pass the BASE_URL along + new webpack.EnvironmentPlugin( 'BASE_URL' ) + ] diff --git a/coach/configs/webpack.debug.coffee b/coach/configs/webpack.debug.coffee new file mode 100644 index 0000000000..bf19c6005e --- /dev/null +++ b/coach/configs/webpack.debug.coffee @@ -0,0 +1,9 @@ +_ = require 'lodash' + +# similar custom configs as production +productionConfig = require './webpack.production' + +debugConfig = _.cloneDeep(productionConfig) +debugConfig.output.filename = 'main.js' + +module.exports = debugConfig diff --git a/coach/configs/webpack.development.coffee b/coach/configs/webpack.development.coffee new file mode 100644 index 0000000000..53cf422b65 --- /dev/null +++ b/coach/configs/webpack.development.coffee @@ -0,0 +1,7 @@ +module.exports = + entry: + demo: [ + 'demo' + 'resources/styles/main.less' + 'resources/styles/demo.less' + ] \ No newline at end of file diff --git a/coach/configs/webpack.production.coffee b/coach/configs/webpack.production.coffee new file mode 100644 index 0000000000..adb5edede0 --- /dev/null +++ b/coach/configs/webpack.production.coffee @@ -0,0 +1,20 @@ +webpack = require 'webpack' +webpackUMDExternal = require 'webpack-umd-external' + +module.exports = + entry: + main: 'index' + output: + filename: 'main.min.js' + plugins: [ + new webpack.ProvidePlugin({ + React: 'react/addons' + _: 'underscore' + BS: 'react-bootstrap' + $: 'jquery' + }) + ] + externals: webpackUMDExternal( + underscore: '_' + jquery: '$' + ) \ No newline at end of file diff --git a/coach/demo-mathjax-config.coffee b/coach/demo-mathjax-config.coffee new file mode 100644 index 0000000000..0476a630cf --- /dev/null +++ b/coach/demo-mathjax-config.coffee @@ -0,0 +1,81 @@ +$ = require 'jquery' + +# ripped from webview +MATHJAX_CONFIG = + jax: [ + 'input/MathML', + 'input/TeX', + 'input/AsciiMath', + 'output/NativeMML', + 'output/HTML-CSS' + ], + extensions: [ + 'asciimath2jax.js', + 'tex2jax.js', + 'mml2jax.js', + 'MathMenu.js', + 'MathZoom.js' + ], + tex2jax: { + inlineMath: [ + ['[TEX_START]', '[TEX_END]'], + ['\\(', '\\)'] + ] + }, + TeX: { + extensions: [ + 'AMSmath.js', + 'AMSsymbols.js', + 'noErrors.js', + 'noUndefined.js' + ], + noErrors: { + disabled: true + } + }, + AsciiMath: { + noErrors: { + disabled: true + } + } + +typesetMath = (node) -> + # straight up copy of webview's mathjax fn + $mathElements = $(node).find('[data-math]:not(.math-rendered)') + + $mathElements.each (iter, element) -> + + $element = $(element) + formula = $element.data('math') + + mathTex = "[TEX_START]#{formula}[TEX_END]" + $element.text(mathTex) + + # Moved adding to MathJax queue here. Means the queue gets pushed onto more (once per math element), + # but what it trys to parse for matching math is WAY less than the whole page. + MathJax.Hub.Queue(['Typeset', MathJax.Hub], $element[0]) + MathJax.Hub.Queue( -> + $element[0].classList.add('math-rendered') + ) + + +startMathJax = -> + + configuredCallback = -> + window.MathJax.Hub.Configured() + + if window.MathJax?.Hub + window.MathJax.Hub.Config(MATHJAX_CONFIG) + # Does not seem to work when passed to Config + window.MathJax.Hub.processSectionDelay = 0 + configuredCallback() + else + # If the MathJax.js file has not loaded yet: + # Call MathJax.Configured once MathJax loads and + # loads this config JSON since the CDN URL + # says to `delayStartupUntil=configured` + MATHJAX_CONFIG.AuthorInit = configuredCallback + + window.MathJax = MATHJAX_CONFIG + +module.exports = {typesetMath, startMathJax} diff --git a/coach/demo.cjsx b/coach/demo.cjsx new file mode 100644 index 0000000000..5806c2b5e5 --- /dev/null +++ b/coach/demo.cjsx @@ -0,0 +1,115 @@ +_ = require 'underscore' +ConceptCoachAPI = require './src/concept-coach' + +api = require './src/api' +AUTOSHOW = false +{startMathJax, typesetMath} = require './demo-mathjax-config' + +SETTINGS = + STUBS: + API_BASE_URL: '' + COLLECTION_UUID: 'C_UUID' + MODULE_UUID: 'm_uuid' + CNX_URL: '' + LOCAL: + API_BASE_URL: 'http://localhost:3001' + COLLECTION_UUID: 'f10533ca-f803-490d-b935-88899941197f' + MODULE_UUID: '7636a3bf-eb80-4898-8b2c-e81c1711b99f' + CNX_URL: 'http://localhost:8000' + SERVER: + API_BASE_URL: 'https://tutor-dev.openstax.org' + COLLECTION_UUID: 'f10533ca-f803-490d-b935-88899941197f' + MODULE_UUID: '7636a3bf-eb80-4898-8b2c-e81c1711b99f' + CNX_URL: 'https://dev.cnx.org' + +settings = SETTINGS.LOCAL + +loadApp = -> + unless document.readyState is 'interactive' + return false + + startMathJax() + + mainDiv = document.getElementById('react-root-container') + buttonA = document.getElementById('launcher') + buttonB = document.getElementById('launcher-other-course') + buttonC = document.getElementById('launcher-intro') + buttonMATHS = document.getElementById('launcher-maths') + + demoSettings = + collectionUUID: settings.COLLECTION_UUID + moduleUUID: settings.MODULE_UUID + cnxUrl: settings.CNX_URL + processHtmlAndMath: typesetMath # from demo + getNextPage: -> + nextChapter: '2.2' + nextTitle: 'Sample module 3' + nextModuleUUID: 'the-next-page-uuid' + filterClick: (clickEvent) -> + console.info('external click or focus') + return false + + initialModel = _.clone(demoSettings) + initialModel.mounter = mainDiv + + conceptCoachDemo = new ConceptCoachAPI(settings.API_BASE_URL) + conceptCoachDemo.setOptions(initialModel) + + conceptCoachDemo.on 'open', conceptCoachDemo.handleOpened + conceptCoachDemo.on 'ui.close', conceptCoachDemo.handleClosed + + show = -> + conceptCoachDemo.open(demoSettings) + true + + showOtherCourse = -> + otherCourseSettings = + collectionUUID: 'FAKE_COLLECTION' + moduleUUID: 'FAKE_MODULE' + cnxUrl: settings.CNX_URL + + conceptCoachDemo.open(otherCourseSettings) + true + + showIntro = -> + introSettings = _.extend({}, demoSettings, moduleUUID: 'e98bdaec-4060-4b43-ac70-681555a30e22') + + conceptCoachDemo.open(introSettings) + true + + showMaths = -> + introSettings = _.extend({}, demoSettings, + moduleUUID: '4bba6a1c-a0e6-45c0-988c-0d5c23425670', + collectionUUID: '27275f49-f212-4506-b3b1-a4d5e3598b99' + ) + + conceptCoachDemo.open(introSettings) + true + + window.conceptCoachDemo = conceptCoachDemo + conceptCoachDemo.initialize(buttonA) + buttonB.addEventListener 'click', showOtherCourse + buttonC.addEventListener 'click', showIntro + buttonMATHS.addEventListener 'click', showMaths + + conceptCoachDemo.on 'ui.launching', show + + # Hook in to writing view updates to history api + conceptCoachDemo.on 'view.update', (eventData) -> + if eventData.route isnt location.pathname + history.pushState(eventData.state, null, eventData.route) + true + + # listen to back/forward and broadcasting to coach navigation + window.addEventListener 'popstate', (eventData) -> + conceptCoachDemo.updateToRoute(location.pathname) + true + + # open to the expected view right away if view in url + conceptCoachDemo.openByRoute(demoSettings, location.pathname) if location.pathname? + + if AUTOSHOW + setTimeout( show, 300) + true + +loadApp() or document.addEventListener('readystatechange', loadApp) diff --git a/coach/full-build.coffee b/coach/full-build.coffee new file mode 100644 index 0000000000..81a03eea24 --- /dev/null +++ b/coach/full-build.coffee @@ -0,0 +1,3 @@ +ConceptCoachAPI = require './src/concept-coach' + +module.exports = ConceptCoachAPI diff --git a/coach/index.cjsx b/coach/index.cjsx new file mode 100644 index 0000000000..626cfeae49 --- /dev/null +++ b/coach/index.cjsx @@ -0,0 +1,4 @@ +{ConceptCoach} = require './src/concept-coach/base' +ConceptCoachAPI = require './src/concept-coach' + +module.exports = {ConceptCoach, ConceptCoachAPI} diff --git a/coach/index.html b/coach/index.html new file mode 100644 index 0000000000..e3f9386efe --- /dev/null +++ b/coach/index.html @@ -0,0 +1,53 @@ + + + + + + + + + + +
+
+
+
+
+
+
+ +

+ + If you are signed in and registered for another course, this will show you what courses you have available to go to. + +

+
+
+
+
+
+
+ +

+ + Example of no exercises for module + +

+
+
+
+
+
+
+ +

+ + Example of MATHs + +

+
+
+
+
+ + \ No newline at end of file diff --git a/coach/package.json b/coach/package.json new file mode 100644 index 0000000000..5aedb8604c --- /dev/null +++ b/coach/package.json @@ -0,0 +1,104 @@ +{ + "name": "openstax-concept-coach", + "version": "0.0.0", + "description": "OpenStax Concept Coach", + "main": "dist/main.min.js", + "scripts": { + "test": "gulp test && gulp prod", + "coverage": "gulp coverage", + "release": "./release.sh", + "deployment": "gulp prod", + "test-integration": "mocha -R spec ./test-integration/", + "start": "gulp dev" + }, + "repository": { + "type": "git", + "url": "https://github.com/openstax/concept-coach.git" + }, + "author": "Philip Schatz ", + "license": "AGPL-3.0", + "bugs": { + "url": "https://github.com/openstax/concept-coach/issues" + }, + "homepage": "https://github.com/openstax/concept-coach", + "dependencies": { + "bootstrap": "3.3.5", + "classnames": "2.1.5", + "dateformat": "1.0.12", + "eventemitter2": "0.4.14", + "font-awesome": "4.4.0", + "interpolate": "0.1.0", + "jquery": "2.1.4", + "keymaster": "1.6.2", + "lodash": "3.10.1", + "openstax-react-components": "openstax/react-components#d-20160715.a", + "underscore": "1.8.3" + }, + "devDependencies": { + "blanket": "1.1.7", + "chai": "2.1.2", + "chai-as-promised": "5.1.0", + "chromedriver": "2.19.0", + "cjsxify": "0.2.6", + "coffee-jsx-loader": "0.1.3", + "coffee-loader": "0.7.2", + "coffee-react-transform": "3.3.0", + "coffee-script": "1.9.3", + "coffeelint": "1.13.0", + "coffeelint-loader": "0.1.1", + "css-loader": "0.22.0", + "del": "1.1.1", + "es6-promise": "^3.0.2", + "extract-text-webpack-plugin": "0.8.2", + "file-exists": "0.1.1", + "file-loader": "0.8.4", + "fixed-data-table": "0.4.1", + "gulp": "3.9.0", + "gulp-coffeelint": "0.4.0", + "gulp-env": "0.2.0", + "gulp-gzip": "1.0.0", + "gulp-rev": "3.0.1", + "gulp-tar": "1.4.0", + "gulp-util": "3.0.6", + "gulp-watch": "4.3.5", + "istanbul-instrumenter-loader": "0.1.3", + "json-loader": "0.5.3", + "karma": "0.13.19", + "karma-chai": "0.1.0", + "karma-chai-sinon": "0.1.5", + "karma-chrome-launcher": "0.2.1", + "karma-coverage": "0.5.2", + "karma-mocha": "0.1.10", + "karma-mocha-reporter": "1.0.4", + "karma-nyan-reporter": "0.2.2", + "karma-phantomjs-launcher": "0.2.1", + "karma-phantomjs-shim": "1.1.1", + "karma-sourcemap-loader": "0.3.6", + "karma-webpack": "1.7.0", + "less": "2.5.3", + "less-loader": "2.2.1", + "lodash": "3.10.1", + "mocha": "2.2.5", + "moment": "2.10.6", + "phantomjs": "1.9.18", + "react": "0.13.1", + "react-bootstrap": "0.23.0", + "react-hot-loader": "1.3.0", + "react-scroll-components": "0.2.2", + "selenium-webdriver": "2.47.0", + "sinon": "1.17.1", + "sinon-chai": "2.8.0", + "style-loader": "0.12.4", + "url-loader": "0.5.6", + "webpack": "1.12.2", + "webpack-dev-server": "1.12.0", + "webpack-module-hot-accept": "1.0.3", + "webpack-umd-external": "1.0.2", + "when": "3.7.3" + }, + "config": { + "blanket": { + "pattern": "src" + } + } +} diff --git a/coach/release.sh b/coach/release.sh new file mode 100755 index 0000000000..8aabfa4c82 --- /dev/null +++ b/coach/release.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +git checkout gh-pages + +git pull origin gh-pages + +git merge master -m 'merge master in prep for release' + +npm install + +gulp release + +echo "Done with build, git commit and/or tag and push to complete" diff --git a/coach/resources/images/bg-cc-launcher.png b/coach/resources/images/bg-cc-launcher.png new file mode 100644 index 0000000000..91e15cf3dc Binary files /dev/null and b/coach/resources/images/bg-cc-launcher.png differ diff --git a/coach/resources/styles/components/concept-coach/base.less b/coach/resources/styles/components/concept-coach/base.less new file mode 100644 index 0000000000..045e538516 --- /dev/null +++ b/coach/resources/styles/components/concept-coach/base.less @@ -0,0 +1,42 @@ +.concept-coach-view { + margin: 80px auto; + width: 100%; + max-width: 840px; + + a:not(.btn) { + color: inherit; + border-bottom: 1px solid @openstax-info; + padding-bottom: 3px; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: none; + color: @openstax-info; + } + } + + &:not(.concept-coach-view-task) { + background: @openstax-white; + padding: @concept-coach-base-x-padding @concept-coach-base-y-padding; + } + + // TODO move out to common + .task-step { + .task-step-group { + font-size: 1.6rem; + color: @openstax-neutral; + padding-top: 2rem; + padding-bottom: 0; + .opacity(0.5); + } + .task-step-group-label { + display: inline-block; + margin-left: 4px; + } + } + + .question-feedback { + z-index: 1; + } +} diff --git a/coach/resources/styles/components/concept-coach/breadcrumbs.less b/coach/resources/styles/components/concept-coach/breadcrumbs.less new file mode 100644 index 0000000000..32e4ed1703 --- /dev/null +++ b/coach/resources/styles/components/concept-coach/breadcrumbs.less @@ -0,0 +1,5 @@ +@import '~openstax-react-components/resources/styles/components/breadcrumbs/coach'; + +.concept-coach-task { + .concept-coach-breadcrumbs(); +} \ No newline at end of file diff --git a/coach/resources/styles/components/concept-coach/index.less b/coach/resources/styles/components/concept-coach/index.less new file mode 100644 index 0000000000..fbc2bb57e6 --- /dev/null +++ b/coach/resources/styles/components/concept-coach/index.less @@ -0,0 +1,16 @@ +@import './base'; +@import './modal'; +@import './breadcrumbs'; +@import './laptop-and-mug'; +@import './launcher'; + +.concept-coach { + overflow-x: hidden; + overflow-y: auto; + max-height: 100%; + text-align: left; + + .loading { + min-height: 600px; + } +} diff --git a/coach/resources/styles/components/concept-coach/laptop-and-mug.less b/coach/resources/styles/components/concept-coach/laptop-and-mug.less new file mode 100644 index 0000000000..41605f47b8 --- /dev/null +++ b/coach/resources/styles/components/concept-coach/laptop-and-mug.less @@ -0,0 +1,22 @@ +.keyframes(launcher-steam; { + 0% { opacity: 1; .translate(0; 10px); } + 100% { opacity: 0; .translate(0; -40px); } + }); + +.laptop-and-mug { + + .launcher-coffee-steam { + path { + &:nth-child(1){ + .animation-duration(3s); + } + &:nth-child(2){ + .animation-duration(4s); + } + .animation(launcher-steam ease-in); + .animation-iteration-count(infinite); + } + } + + +} diff --git a/coach/resources/styles/components/concept-coach/launcher.less b/coach/resources/styles/components/concept-coach/launcher.less new file mode 100644 index 0000000000..edba017035 --- /dev/null +++ b/coach/resources/styles/components/concept-coach/launcher.less @@ -0,0 +1,323 @@ +@launcher-screen: #F1F1F1; +@launcher-screen-shine: #F5F5F5; + +@launcher-text-question-dark: #222E65; +@launcher-text-question-light: #7A7A99; +@launcher-text-dark: #E5E5E5; +@launcher-text-light: #EFEFEF; +@launcher-text-selected-dark: #9A9A9B; +@launcher-text-selected-light: #CFCECE; + +@launcher-letter-dark: #CFCECE; +@launcher-circle-text: #9A9A9B; + +.launcher-concept() { + .launcher-circle { + circle { + fill: @openstax-white; + } + .launcher-letter { + fill: @launcher-circle-text; + + &-hole { fill: @openstax-white; } + &-dark { fill: @launcher-letter-dark; } + } + } + .launcher-text { + polygon { + &:nth-child(odd) { fill: @launcher-text-dark; } + &:nth-child(even) { fill: @launcher-text-light; } + } + rect { fill: @launcher-text-light; } + } +} + +.launcher-concept(launching) { + .launcher-circle { + circle { + fill: @openstax-white; + stroke: @launcher-screen; + } + .launcher-letter { + fill: @launcher-circle-text; + + &-dark { fill: @launcher-circle-text; } + } + } + .launcher-text { + polygon { + &:nth-child(odd) { fill: @launcher-text-dark; } + &:nth-child(even) { fill: @launcher-text-dark; } + } + rect { fill: @launcher-text-dark; } + } +} + +.launcher-select-keyframes(@default-fill, @select-fill){ + 0% { fill: @default-fill; } + 5% { fill: @select-fill; } + 20% { fill: @select-fill; } + 25% { fill: @default-fill; } + 100% { fill: @default-fill; } +} + +.keyframes(launcher-circle-select; { + .launcher-select-keyframes(@openstax-white, @openstax-info); +}); + +.keyframes(launcher-letter-select; { + .launcher-select-keyframes(@launcher-circle-text, @openstax-white); +}); + +.keyframes(launcher-letter-dark-select; { + .launcher-select-keyframes(@launcher-letter-dark, @openstax-white); +}); + +.keyframes(launcher-text-odd-select; { + .launcher-select-keyframes(@launcher-text-dark, @launcher-text-selected-dark); +}); + +.keyframes(launcher-text-even-select; { + .launcher-select-keyframes(@launcher-text-light, @launcher-text-selected-light); +}); + +.launcher-animate-concept(@name, @delay, @duration, @timing-function: ease-in){ + .animation(~'@{name} @{duration} @{timing-function}'); + .animation-name(@name); + .animation-duration(@duration); + .animation-delay(@delay); + .animation-timing-function(@timing-function); + .animation-iteration-count(infinite); +} + +.launcher-concept(selected, @delay: 0s, @duration: 8s) { + .launcher-circle { + circle { + .launcher-animate-concept(launcher-circle-select, @delay, @duration); + } + .launcher-letter { + .launcher-animate-concept(launcher-letter-select, @delay, @duration); + + &-hole { .launcher-animate-concept(launcher-circle-select, @delay, @duration); } + &-dark { .launcher-animate-concept(launcher-letter-dark-select, @delay, @duration); } + } + } + .launcher-text { + polygon { + &:nth-child(odd) { .launcher-animate-concept(launcher-text-odd-select, @delay, @duration); } + &:nth-child(even) { .launcher-animate-concept(launcher-text-even-select, @delay, @duration); } + } + rect { + .launcher-animate-concept(launcher-text-even-select, @delay, @duration); + } + } +} + +.keyframes(launcher-answer-enter; { + 0% { opacity: 0; .translate(-200px; 0); } + 20% { opacity: 0; } + 60% { opacity: 1; .translate(30px; 0); } + 80% { .translate(-10px; 0); } + 100% { .translate(0; 0); } +}); + + +.keyframes(launcher-mug-leave; { + 0% { opacity: 1; .translate(0; 0); } + 20% { opacity: 1; .translate(-20px; 0); } + 40% { opacity: 0; .translate(600px; 0); } + 100% { opacity: 0; .translate(600px; 0); } +}); + +.launcher-concepts-animate() { + &.launcher-question { .animation-duration(100ms); } + &.launcher-a { .animation-duration(200ms); } + &.launcher-b { .animation-duration(300ms); } + &.launcher-c { .animation-duration(400ms); } + &.launcher-d { .animation-duration(500ms); } +}; + +.launcher-base() { + position: relative; + width: 100%; + height: 388px; + text-align: center; + + .laptop-and-mug { + width: 100%; + position: absolute; + left: 0; + } + + .btn { + position: relative; + top: 5%; + } + + .launcher-answer { + .launcher-concept(); + } + .launcher-desk { + .transition(all 200ms linear); + } + .launcher-concept-coach { + .transform-origin(50% 25%); + .transition(all 400ms ease-in); + } + .btn, + .launcher-concept-coach-shine-bottom, + .launcher-question .launcher-text polygon, + .launcher-section-label { + .transition(opacity 200ms linear); + } + .launcher-concept-row { + .transition(opacity 800ms linear); + } + .launcher-background, + .launcher-concept-coach-screen { + .transition(fill 200ms linear); + } +} + +.launcher-animate() { + + .launcher-a { .launcher-concept(selected, 2600ms); } + .launcher-b { .launcher-concept(selected, 4600ms); } + .launcher-c { .launcher-concept(selected, 600ms); } + .launcher-d { .launcher-concept(selected, 6600ms); } + +} + +.launcher-animate(active) { + .launcher-concept-row { + .launcher-concepts-animate(); + .animation(launcher-answer-enter ease-in); + } + + .btn { + .box-shadow(0 1px 6px rgba(0, 0, 0, 0.5)); + } +} + +.launcher-animate(launching) { + .btn, + .launcher-concept-coach-shine-bottom, + .launcher-question .launcher-text polygon, + .launcher-section-label, + .launcher-concept-row { + opacity: 0; + } + + .launcher-desk { + .translate(0, 100%); + .transform-origin(50% 100%); + } + .launcher-concept-coach { + .transform(~'translate(-62px, 30px) scale(3.97)'); + } + .launcher-answer { + .launcher-concept(launching); + } + .launcher-coffee { + .animation(launcher-mug-leave 300ms ease-in); + .animation-fill-mode(both); + } + .launcher-concept-coach-screen { + fill: @openstax-white; + } + .launcher-background { + fill: @openstax-neutral-lighter; + } + .launcher-laptop { + opacity: 0; + } +} + +.launcher-animate(closing) { + .launcher-concept-row { + .translate(0; 0); + } + .launcher-concept-coach { + .scale(1); + } + .launcher-coffee { + .translate(0; 0); + } +} + +.concept-coach-launcher-wrapper { + position: relative; + min-height: 388px; + height: 100%; + + button { + background-color: #F37440; + border: 1px solid transparent; + font-weight: bold; + padding: 20px; + span { display: block; } + .warn { + font-size: 90%; + font-weight: 300; + } + &:active, + &:focus, + &:hover { + background-color: darken(#F37440, 10%); + border-color: darken(#F37440, 15%); + } + } + + .launcher-background { + background: #9dd4db url('/resources/images/bg-cc-launcher.png') repeat left top; + color: white; + height: 388px; + text-shadow: 2px 2px 3px rgba(150, 150, 150, 0.75); + width: 100%; + + > h1 { + color: white; + font-size: 3rem; + font-weight: bold; + padding-top: 30px; + text-shadow: 2px 2px 3px rgba(150, 150, 150, 0.8); + } + } +} + +.concept-coach-launcher { + .launcher-base(); + + &:not(.launching):not(.closing){ + cursor: pointer; + .launcher-animate(); + + &:hover, + &:focus { + .launcher-animate(active); + } + } + + &.launching { + position: fixed; + z-index: 99; + left: 0; + top: 0; + + .launcher-animate(launching); + } + + &:not(.launching) { + .launcher-animate(closing); + } +} + +@media (min-width: @screen-xs-min) { + .concept-coach-launcher { + .btn { + position: absolute; + top: 40%; + left: 8%; + } + } +} diff --git a/coach/resources/styles/components/concept-coach/modal-opened.less b/coach/resources/styles/components/concept-coach/modal-opened.less new file mode 100644 index 0000000000..3a0d4888b8 --- /dev/null +++ b/coach/resources/styles/components/concept-coach/modal-opened.less @@ -0,0 +1,8 @@ +body.concept-coach-view.pinned-view.pinned-on { + overflow: hidden !important; +} + +body.cc-opened { + background: @openstax-neutral-light; + overflow: hidden !important; +} diff --git a/coach/resources/styles/components/concept-coach/modal.less b/coach/resources/styles/components/concept-coach/modal.less new file mode 100644 index 0000000000..4fc64ed3d7 --- /dev/null +++ b/coach/resources/styles/components/concept-coach/modal.less @@ -0,0 +1,16 @@ +.concept-coach-modal { + width: 100%; + height: 100%; + background: @openstax-neutral-lighter; + position: fixed; + left: 0; + top: 0; + z-index: 100; + opacity: 0; + overflow: auto; + .transition(opacity 200ms ease-in-out); + + &.loaded { + opacity: 1; + } +} diff --git a/coach/resources/styles/components/course/confirm-join.less b/coach/resources/styles/components/course/confirm-join.less new file mode 100644 index 0000000000..a924d07f2c --- /dev/null +++ b/coach/resources/styles/components/course/confirm-join.less @@ -0,0 +1,49 @@ +.confirm-join { + @content-width: 400px; + text-align: center; + .label { font-size: 1.6rem; } + .controls { + width: @content-width; + margin: auto; + .form-control { + display: inline; + width: calc(~"100% - 150px") + } + .btn.continue { + margin-left: 1rem; + } + .or, .skip { + display: block; + display: block; + text-align: center; + } + .or { + font-size: 14px; + margin: 20px auto; + } + .skip { + text-decoration: initial; + border-bottom: 0; + font-size: 16px; + color: @link-color;; + } + } + + .help-text { + color: @openstax-neutral; + font-size: 11px; + width: @content-width; + margin: 30px auto 0 auto; + text-align: center; + } + + .title { + font-size: 24px; + border-bottom: 2px solid grey; + padding: 20px 50px; // extend border past sides of text + margin: 20px auto; + border-bottom: 2px solid @openstax-neutral-light; + .join { font-size: 26px; } + } + +} diff --git a/coach/resources/styles/components/course/enrollment-code.less b/coach/resources/styles/components/course/enrollment-code.less new file mode 100644 index 0000000000..9d4155b1d0 --- /dev/null +++ b/coach/resources/styles/components/course/enrollment-code.less @@ -0,0 +1,7 @@ +.enrollment-code { + .btn.enroll { + background-color: @openstax-secondary-light; + color: white; + font-weight: lighter; + } +} diff --git a/coach/resources/styles/components/course/index.less b/coach/resources/styles/components/course/index.less new file mode 100644 index 0000000000..f25276e995 --- /dev/null +++ b/coach/resources/styles/components/course/index.less @@ -0,0 +1,5 @@ +@import './item'; +@import './student_id'; +@import './registration'; +@import './enrollment-code'; +@import './confirm-join'; diff --git a/coach/resources/styles/components/course/item.less b/coach/resources/styles/components/course/item.less new file mode 100644 index 0000000000..1bb65c421d --- /dev/null +++ b/coach/resources/styles/components/course/item.less @@ -0,0 +1,3 @@ +.concept-coach-course-item { + cursor: pointer; +} diff --git a/coach/resources/styles/components/course/registration.less b/coach/resources/styles/components/course/registration.less new file mode 100644 index 0000000000..42dccb766d --- /dev/null +++ b/coach/resources/styles/components/course/registration.less @@ -0,0 +1,84 @@ +@import '~openstax-react-components/resources/styles/mixins/flexbox'; + +.concept-coach-view-registration { + overflow: hidden; + position: relative; +} + +.enroll-or-login { + min-height: 280px; + + .flex-display(); + .flex-direction(column); + .align-items(center); + + .laptop-and-mug { + position: absolute; + top: -50px; + left: -450px; + + width: 800px; + .launcher-coffee-steam path { + fill: @openstax-neutral-light; + } + } + + .body { + margin-left: 300px; + text-align: center; + } + + .title { + margin-top: 5rem; + margin-bottom: 3rem; + } + .code-required { + font-weight: 700; + color: @openstax-neutral; + } + .login-gateway { + .flex-display(); + .align-items(center); + .justify-content(center); + font-size: 2.5rem; + margin-bottom: 3rem; + + &.is-open { + .message{ text-align: center; } + a { display: inline-block; } + } + + &.is-closed { + cursor: pointer; + } + + &.sign-up { + &.is-closed { + margin: 50px auto 10px auto; + display: inline-block; + font-weight: 300; + } + &.is-open { + width: 400px; + height: 140px; + } + } + + &.login { + font-size: 1.6rem; + display: inline; + &.is-closed { + color: @link-color; + + &:hover, + &:focus { + color: @openstax-info; + } + } + &.is-open { + display: block; + } + } + } + +} diff --git a/coach/resources/styles/components/course/student_id.less b/coach/resources/styles/components/course/student_id.less new file mode 100644 index 0000000000..6f25acb680 --- /dev/null +++ b/coach/resources/styles/components/course/student_id.less @@ -0,0 +1,26 @@ +.request-student-id { + + .panels { + width: 60%; + @media (max-width: @screen-sm-min){ width: 95%; } + margin: 0 auto; + display: flex; + align-items: flex-end; + .form-group { margin: 0; } + .field { height: 80px; } + .cancel { + margin-left: 20px; + padding-left: 20px; + border-left: 1px solid @openstax-neutral-light; + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: center; + a { + border-bottom: 0; + color: @link-color; + &:hover{ color: darken(@link-color, 20%); } + } + } + } +} diff --git a/coach/resources/styles/components/dashboard/index.less b/coach/resources/styles/components/dashboard/index.less new file mode 100644 index 0000000000..d29046a94f --- /dev/null +++ b/coach/resources/styles/components/dashboard/index.less @@ -0,0 +1,4 @@ +.concept-coach-courses-listing { + list-style: none; + padding-left: 0; +} \ No newline at end of file diff --git a/coach/resources/styles/components/exercise/index.less b/coach/resources/styles/components/exercise/index.less new file mode 100644 index 0000000000..4260adb985 --- /dev/null +++ b/coach/resources/styles/components/exercise/index.less @@ -0,0 +1,33 @@ +.exercise-wrapper[data-step-number] { + &::before { + content: attr(data-step-number) ")"; + position: absolute; + z-index: 2; + left: @concept-coach-base-y-padding - 27px; + top: @concept-coach-base-x-padding; + + .exercise-typography(); + } +} + +.exercise-wrapper { + .task-help-links { + position: absolute; + bottom: 1rem; + } + + .card-footer { + margin-bottom: 2rem; + } +} + +.exercise-identifier-link { + position: absolute; + right: @concept-coach-base-x-padding; + bottom: @concept-coach-base-x-padding; + color: @openstax-neutral-dark; + font-size: 1.2rem; + line-height: 1.2rem; + // override CC global 1px blue border + a:not(.btn) { border-bottom: 0px; } +} diff --git a/coach/resources/styles/components/navigation/index.less b/coach/resources/styles/components/navigation/index.less new file mode 100644 index 0000000000..8b1eacbf80 --- /dev/null +++ b/coach/resources/styles/components/navigation/index.less @@ -0,0 +1,36 @@ +.concept-coach { + + .navbar-brand { + > span { + display: inline-block; + } + .navbar-logo { + text-transform: uppercase; + } + .concept-coach-course-name { + margin-left: 2rem; + font-style: italic; + color: @openstax-neutral-light; + font-weight: 300; + } + } + .navbar-default { + background: @openstax-white; + } + .navbar-right { + .close { + float: none; + } + } + .concept-coach-dashboard-nav { + .btn { + margin-top: -(@padding-base-vertical + 1); + margin-bottom: -(@padding-base-vertical + 1); + } + } + .navbar .dropdown-menu > li > a { + text-decoration: none; + } + + +} diff --git a/coach/resources/styles/components/progress/exercise.less b/coach/resources/styles/components/progress/exercise.less new file mode 100644 index 0000000000..9af5b1267b --- /dev/null +++ b/coach/resources/styles/components/progress/exercise.less @@ -0,0 +1,46 @@ +@progress-size: 3rem; +@progress-margin: 0.5rem; +@progress-content-size: .7 * @progress-size; + +.concept-coach-progress-exercise { + display: inline-block; + width: @progress-size; + height: @progress-size; + border-radius: 50%; + line-height: @progress-size; + position: relative; + background: @openstax-neutral-light; + text-align: center; + font-size: @progress-content-size; + + &:not(:last-child) { + margin-right: @progress-margin; + } + + &::before { + content: '--'; + display: block; + width: 100%; + color: @openstax-neutral; + } + + &.is-completed { + background: @openstax-incorrect-color; + + &::before { + .fa-icon(); + color: @openstax-white; + content: @fa-var-close; + } + } + + &.is-completed.is-correct { + background: @openstax-correct-color; + + &::before { + .fa-icon(); + color: @openstax-white; + content: @fa-var-check; + } + } +} diff --git a/coach/resources/styles/components/progress/index.less b/coach/resources/styles/components/progress/index.less new file mode 100644 index 0000000000..5c961d57da --- /dev/null +++ b/coach/resources/styles/components/progress/index.less @@ -0,0 +1,42 @@ +@import './exercise'; +@import './page'; + +.chapter-section-prefix { + font-weight: 400; + + &[data-section] { + &::before { + content: attr(data-section); + padding-right: 2rem; + } + } +} + +.concept-coach-progress-pages { + list-style: none; + padding-left: 0; + display: table; + width: 100%; +} + +.concept-coach-progress-section { + h1 { + font-weight: 100; + border-bottom: 1px solid @openstax-neutral-light; + } +} + +.concept-coach-progress-chapter { + h3 { + line-height: 1.5em; + border-bottom: 1px solid @openstax-neutral-light; + margin-bottom: 0 !important; + font-weight: 100; + } + + &.current { + h3 { + color: @openstax-info; + } + } +} \ No newline at end of file diff --git a/coach/resources/styles/components/progress/page.less b/coach/resources/styles/components/progress/page.less new file mode 100644 index 0000000000..e70fe92ff1 --- /dev/null +++ b/coach/resources/styles/components/progress/page.less @@ -0,0 +1,45 @@ +.concept-coach-progress-page { + display: table-row; + width: 100%; + cursor: pointer; + + .concept-coach-progress-page-title, + .concept-coach-progress-page-last-worked, + .concept-coach-progress-exercises { + display: table-cell; + vertical-align: middle; + border-bottom: 1px solid @openstax-neutral-lighter; + padding: 0.75rem 0; + } + + .concept-coach-progress-page-title .chapter-section-prefix { + width: inherit; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + font-weight: 600; + + &::before { + font-weight: 200; + } + } + + .concept-coach-progress-page-last-worked { + text-align: center; + } + + &:hover { + color: @openstax-info; + + h4 { + color: @openstax-info !important; + } + + .concept-coach-progress-page-title, + .concept-coach-progress-page-last-worked, + .concept-coach-progress-exercises { + border-color: @openstax-info; + } + } + +} \ No newline at end of file diff --git a/coach/resources/styles/components/reactive/index.less b/coach/resources/styles/components/reactive/index.less new file mode 100644 index 0000000000..31ef2a37d7 --- /dev/null +++ b/coach/resources/styles/components/reactive/index.less @@ -0,0 +1,8 @@ +.reactive { + .openstax-subtle-load(will-load; 'Loading...'; 0.5s); + + &-loading.is-empty { + min-height: 5rem; + .openstax-subtle-load(loading; 0.9); + } +} \ No newline at end of file diff --git a/coach/resources/styles/components/task/index.less b/coach/resources/styles/components/task/index.less new file mode 100644 index 0000000000..bad02707ce --- /dev/null +++ b/coach/resources/styles/components/task/index.less @@ -0,0 +1,65 @@ +.concept-coach-view-task { + .card-body { + background: @openstax-white; + padding: @concept-coach-base-x-padding @concept-coach-base-y-padding; + margin-bottom: 2rem; + } +} + +.concept-coach-task-review { + .exercise-card { + margin-bottom: 4rem; + } + + .concept-coach-task-review-controls { + .btn { + width: 50%; + i.fa { + margin-left: 0.5rem; + margin-right: 0.5rem; + } + } + } + + .coach-coach-review-completed { + text-align: center; + + p { + margin-top: 1rem; + font-size: 2rem; + } + } +} + +.concept-coach-title { + margin-bottom: 1.5rem; + + .chapter-section-prefix { + display: inline-block; + margin-right: 1rem; + } + + &.back-to-book { + font-size: 2.4rem; + + span { + margin-left: 0.5rem; + } + } + + &.has-title { + .concept-coach-title-link { + border-left: 1px solid @openstax-neutral; + padding: 0 1rem; + } + } + + a { + cursor: pointer; + + &:hover, + &:focus { + color: @openstax-info; + } + } +} \ No newline at end of file diff --git a/coach/resources/styles/components/user/index.less b/coach/resources/styles/components/user/index.less new file mode 100644 index 0000000000..5b6c23a31a --- /dev/null +++ b/coach/resources/styles/components/user/index.less @@ -0,0 +1,15 @@ +.accounts-iframe { + + .heading { + display: flex; + flex-direction: row; + justify-content: space-between; + border-bottom: 1px solid @openstax-neutral; + } + + .accounts-iframe { + &.logout { + overflow: hidden; + } + } +} diff --git a/coach/resources/styles/demo.less b/coach/resources/styles/demo.less new file mode 100644 index 0000000000..5f5144c6c0 --- /dev/null +++ b/coach/resources/styles/demo.less @@ -0,0 +1,4 @@ +@import '~openstax-react-components/resources/styles/main'; +@import "@{bootstrap-path}/jumbotron"; +@import "@{bootstrap-path}/navs"; +@import "@{bootstrap-path}/navbar"; diff --git a/coach/resources/styles/main.less b/coach/resources/styles/main.less new file mode 100644 index 0000000000..f09aed51fa --- /dev/null +++ b/coach/resources/styles/main.less @@ -0,0 +1,25 @@ +@import '~openstax-react-components/resources/styles/no-conflict'; +@import './components/concept-coach/modal-opened'; +// @import '~animate.less/source/bounceInLeft.less'; + +// All styles are included inside the wrapper selector +// This ensures CC specific styles do not alter +// the appearance of the website that's embedding CC +.concept-coach-wrapper { + width: 100%; + height: 100%; + + @import "@{bootstrap-path}/variables"; + + @import './components/concept-coach/index'; + @import './components/navigation/index'; + @import './components/task/index'; + @import './components/exercise/index'; + @import './components/progress/index'; + @import './components/reactive/index'; + @import './components/course/index'; + @import './components/user/index'; + + @import './mixins'; + @import './variables'; +} diff --git a/coach/resources/styles/mixins.less b/coach/resources/styles/mixins.less new file mode 100644 index 0000000000..39e8f9b44a --- /dev/null +++ b/coach/resources/styles/mixins.less @@ -0,0 +1,40 @@ +.openstax-subtle-load(will-load; @loading-text: 'Loading...'; @trans-time: 0.25s; @bg-fade: 90%; @bg-base: @openstax-white;) { + position: relative; + + &::after { + position: absolute; + width: 100%; + height: 100%; + content: @loading-text; + background: fade(@bg-base, @bg-fade); + top: 0; + text-align: center; + font-size: 3rem; + opacity: 0; + display: none; + + .transition(opacity @trans-time ease-in-out); + } +} + +.openstax-subtle-load(loading, @opacity: 1) { + &::after { + .flex-display(); + .align-items(center); + justify-content: center; + opacity: @opacity; + } +} + +.keyframes(@name; @arguments) { + @-moz-keyframes @name { @arguments(); } + @-webkit-keyframes @name { @arguments(); } + @keyframes @name { @arguments(); } +} + +.transform(@transforms) { + -webkit-transform: @transforms; + -ms-transform: @transforms; // IE9 only + -o-transform: @transforms; + transform: @transforms; +} diff --git a/coach/resources/styles/variables.less b/coach/resources/styles/variables.less new file mode 100644 index 0000000000..b30d311c67 --- /dev/null +++ b/coach/resources/styles/variables.less @@ -0,0 +1,6 @@ +@openstax-bio-primary: @green; // green +@openstax-bio-secondary: @openstax-neutral-dark; // dark gray +@openstax-physics-primary: @dark-blue; // blue +@openstax-physics-secondary: @green; // green +@concept-coach-base-x-padding: 30px; +@concept-coach-base-y-padding: 80px; diff --git a/coach/src/api/index.coffee b/coach/src/api/index.coffee new file mode 100644 index 0000000000..d63e6697e3 --- /dev/null +++ b/coach/src/api/index.coffee @@ -0,0 +1,22 @@ +EventEmitter2 = require 'eventemitter2' + +{loader, isPending} = require './loader' +settings = require './settings' + +channel = new EventEmitter2 wildcard: true + +# HACK - WORKAROUND +# MediaBodyView.prototype.initializeConceptCoach calls this multiple times +# (triggered by back-button and most perhaps search) +IS_INITIALIZED = false + +initialize = (baseUrl) -> + settings.baseUrl ?= baseUrl + loader(channel, settings) unless IS_INITIALIZED + IS_INITIALIZED = true + +destroy = -> + channel.removeAllListeners() + IS_INITIALIZED = false + +module.exports = {loader, isPending, settings, initialize, destroy, channel} diff --git a/coach/src/api/loader.coffee b/coach/src/api/loader.coffee new file mode 100644 index 0000000000..5ff08f9e32 --- /dev/null +++ b/coach/src/api/loader.coffee @@ -0,0 +1,96 @@ +_ = require 'underscore' +deepMerge = require 'lodash.merge' +$ = require 'jquery' +interpolate = require 'interpolate' + +METHODS_WITH_DATA = ['PUT', 'PATCH', 'POST'] +LOADING = {} +API_ACCESS_TOKEN = false + +defaultFail = (response) -> + console.info(response) unless window.__karma__ + +getAjaxSettingsByEnv = (isLocal, baseUrl, setting, eventData) -> + + {data, change} = eventData + apiSetting = _.pick(setting, 'url', 'method') + apiSetting.dataType = 'json' + apiSetting.contentType = 'application/json;charset=UTF-8' + + if _.includes(METHODS_WITH_DATA, apiSetting.method) + apiSetting.data = JSON.stringify(change or data) + + if isLocal + apiSetting.url = "#{interpolate(apiSetting.url, data)}/#{apiSetting.method}.json" + apiSetting.method = 'GET' + else + if setting.useCredentials + apiSetting.xhrFields = + withCredentials: true + else if API_ACCESS_TOKEN + apiSetting.headers = + Authorization: "Bearer #{API_ACCESS_TOKEN}" + apiSetting.url = "#{baseUrl}/#{interpolate(apiSetting.url, data)}" + + apiSetting + +getResponseDataByEnv = (isLocal, requestEvent, data) -> + if isLocal + datasToMerge = [{}, {data, query: requestEvent.query}] + if requestEvent.change? + datasToMerge.push(data: requestEvent.change) + else + datasToMerge = [{}, requestEvent, {data}] + deepMerge.apply {}, datasToMerge + + +handleAPIEvent = (apiEventChannel, baseUrl, setting, requestEvent = {}) -> + + isLocal = window.__karma__ or setting.loadLocally + # simulate server delay + delay = if isLocal then 20 else 0 + + apiSetting = getAjaxSettingsByEnv(isLocal, baseUrl, setting, requestEvent) + if apiSetting.method is 'GET' + return if LOADING[apiSetting.url] + LOADING[apiSetting.url] = true + + _.delay -> + $.ajax(apiSetting) + .done((responseData) -> + delete LOADING[apiSetting.url] + try + completedEvent = interpolate(setting.completedEvent, requestEvent.data) + completedData = getResponseDataByEnv(isLocal, requestEvent, responseData) + apiEventChannel.emit(completedEvent, completedData) + catch error + apiEventChannel.emit('error', {apiSetting, response: responseData, failedData: completedData, exception: error}) + ).fail((response) -> + delete LOADING[apiSetting.url] + + {responseJSON} = response + + failedData = getResponseDataByEnv(isLocal, requestEvent, responseJSON) + if _.isString(setting.failedEvent) + failedEvent = interpolate(setting.failedEvent, requestEvent.data) + apiEventChannel.emit(failedEvent, failedData) + + defaultFail(response) + apiEventChannel.emit('error', {response, apiSetting, failedData}) + ).always((response) -> + apiEventChannel.emit('completed') + ) + , delay + +isPending = -> + not _.isEmpty(LOADING) + +loader = (apiEventChannel, settings) -> + apiEventChannel.on 'set.access_token', (token) -> + API_ACCESS_TOKEN = token + + _.each settings.endpoints, (setting, eventName) -> + apiEventChannel.on eventName, _.partial(handleAPIEvent, apiEventChannel, setting.baseUrl or settings.baseUrl, setting) + + +module.exports = {loader, isPending} diff --git a/coach/src/api/settings.coffee b/coach/src/api/settings.coffee new file mode 100644 index 0000000000..247b07a78b --- /dev/null +++ b/coach/src/api/settings.coffee @@ -0,0 +1,66 @@ +settings = + + endpoints: + 'exercise.*.send.save': + url: 'api/steps/{id}' + method: 'PATCH' + completedEvent: 'exercise.{id}.receive.save' + + 'exercise.*.send.complete': + url: 'api/steps/{id}/completed' + method: 'PUT' + completedEvent: 'exercise.{id}.receive.complete' + + 'exercise.*.send.fetch': + url: 'api/steps/{id}' + method: 'GET' + completedEvent: 'exercise.{id}.receive.fetch' + + 'task.*.send.fetch': + url: 'api/tasks/{id}' + method: 'GET' + completedEvent: 'task.{id}.receive.fetch' + failedEvent: 'task.{id}.receive.failure' + + 'task.*.send.fetchByModule': + url: 'api/cc/tasks/{collectionUUID}/{moduleUUID}' + method: 'GET' + completedEvent: 'task.{collectionUUID}/{moduleUUID}.receive.fetchByModule' + failedEvent: 'task.{collectionUUID}/{moduleUUID}.receive.failure' + + 'user.status.send.fetch': + url: 'auth/status' + method: 'GET' + useCredentials: true + completedEvent: 'user.status.receive.fetch' + + 'courseDashboard.*.send.fetch': + url: 'api/courses/{id}/cc/dashboard' + method: 'GET' + completedEvent: 'courseDashboard.{id}.receive.fetch' + + 'course.*.send.prevalidation': + url: 'api/enrollment_changes/prevalidate' + method: 'POST' + failedEvent: 'course.{book_uuid}.receive.prevalidation.failure' + completedEvent: 'course.{book_uuid}.receive.prevalidation.complete' + + 'course.*.send.registration': + url: 'api/enrollment_changes' + method: 'POST' + failedEvent: 'course.{book_uuid}.receive.registration.failure' + completedEvent: 'course.{book_uuid}.receive.registration.complete' + + 'course.*.send.confirmation': + url: 'api/enrollment_changes/{id}/approve' + method: 'PUT' + failedEvent: 'course.{id}.receive.confirmation.failure' + completedEvent: 'course.{id}.receive.confirmation.complete' + + 'course.*.send.studentUpdate': + url: 'api/user/courses/{id}/student' + method: 'PATCH' + failedEvent: 'course.*.send.studentUpdate.failure' + completedEvent: 'course.*.receive.studentUpdate.complete' + +module.exports = settings diff --git a/coach/src/breadcrumbs/index.cjsx b/coach/src/breadcrumbs/index.cjsx new file mode 100644 index 0000000000..2b316bcfa6 --- /dev/null +++ b/coach/src/breadcrumbs/index.cjsx @@ -0,0 +1,109 @@ +React = require 'react' +classnames = require 'classnames' +{Breadcrumb} = require 'shared' +_ = require 'underscore' + +tasks = require '../task/collection' +exercises = require '../exercise/collection' + + +BreadcrumbDynamic = React.createClass + displayName: 'BreadcrumbDynamic' + + propTypes: + goToStep: React.PropTypes.func.isRequired + step: React.PropTypes.object.isRequired + + getInitialState: -> + step: @props.step + + componentWillMount: -> + {id} = @props.step + exercises.channel.on("load.#{id}", @update) + + componentWillUnmount: -> + {id} = @props.step + exercises.channel.off("load.#{id}", @update) + + update: (eventData) -> + @setState(step: eventData.data) + + goToStep: (stepIndex) -> + @props.goToStep(stepIndex) + + render: -> + {step} = @state + crumbProps = _.omit(@props, 'step') + + + + + +Breadcrumbs = React.createClass + displayName: 'Breadcrumbs' + + propTypes: + goToStep: React.PropTypes.func.isRequired + moduleUUID: React.PropTypes.string.isRequired + collectionUUID: React.PropTypes.string.isRequired + currentStep: React.PropTypes.number + canReview: React.PropTypes.bool + + getInitialState: -> + {collectionUUID, moduleUUID} = @props + taskId = "#{collectionUUID}/#{moduleUUID}" + + task: tasks.get(taskId) + moduleInfo: tasks.getModuleInfo(taskId) + + makeCrumbEnd: (label, enabled) -> + {moduleInfo} = @state + + reviewEnd = + type: 'end' + data: + id: "#{label}" + title: moduleInfo.title + label: label + disabled: not enabled + + render: -> + {task, moduleInfo} = @state + {currentStep, canReview} = @props + return null if _.isEmpty(task.steps) + + crumbs = _.map task.steps, (crumbStep, index) -> + data: crumbStep + crumb: true + type: 'step' + + reviewEnd = @makeCrumbEnd('summary', canReview) + + crumbs.push(reviewEnd) + + breadcrumbs = _.map crumbs, (crumb, index) => + {disabled} = crumb + classes = classnames({disabled}) + crumb.key = index + + + + +
+
+ {breadcrumbs} +
+
+ +module.exports = {Breadcrumbs} diff --git a/coach/src/buttons/index.cjsx b/coach/src/buttons/index.cjsx new file mode 100644 index 0000000000..cf1d69db97 --- /dev/null +++ b/coach/src/buttons/index.cjsx @@ -0,0 +1,164 @@ +React = require 'react/addons' +BS = require 'react-bootstrap' +_ = require 'underscore' +EventEmitter2 = require 'eventemitter2' +classnames = require 'classnames' + +BookLinkBase = React.createClass + displayName: 'BookLinkBase' + propTypes: + children: React.PropTypes.node + collectionUUID: React.PropTypes.string.isRequired + moduleUUID: React.PropTypes.string + link: React.PropTypes.string + + contextTypes: + close: React.PropTypes.func + navigator: React.PropTypes.instanceOf(EventEmitter2) + + broadcastNav: (clickEvent) -> + clickEvent.preventDefault() + + {onClick} = @props + {close, navigator} = @context + + close() + navigator.emit('close.for.book', _.pick(@props, 'collectionUUID', 'moduleUUID', 'link')) + + onClick?(clickEvent) + true + + render: -> + {children} = @props + return null unless children? + + React.addons.cloneWithProps(children, onClick: @broadcastNav) + +BookLink = React.createClass + displayName: 'BookLink' + propTypes: + children: React.PropTypes.node + render: -> + {children, className} = @props + linkProps = _.omit(@props, 'children', 'className') + classes = classnames 'concept-coach-book-link', className + + + + {children} + + + +BookButton = React.createClass + displayName: 'BookButton' + propTypes: + children: React.PropTypes.node + render: -> + {children, className} = @props + linkProps = _.omit(@props, 'children', 'className') + classes = classnames 'concept-coach-book-link', className + + + + {children} + + + +ExerciseButton = React.createClass + displayName: 'ExerciseButton' + propTypes: + children: React.PropTypes.node + contextTypes: + navigator: React.PropTypes.instanceOf(EventEmitter2) + getDefaultProps: -> + children: 'Exercise' + showExercise: -> + @context.navigator.emit('show.task', {view: 'task'}) + @props.onClick?() + render: -> + {@props.children} + +ContinueToBookButton = React.createClass + displayName: 'ContinueToBookButton' + propTypes: + children: React.PropTypes.node + moduleUUID: React.PropTypes.string + contextTypes: + collectionUUID: React.PropTypes.string + getNextPage: React.PropTypes.func + + getInitialState: -> + @getNextPage() + getDefaultProps: -> + bsStyle: 'primary' + componentWillReceiveProps: (nextProps, nextContext) -> + nextPage = @getNextPage(nextProps, nextContext) + @setState(nextPage) + + getNextPage: (props, context) -> + props ?= @props + context ?= @context + + {moduleUUID} = props + {collectionUUID} = context + + fallBack = + nextChapter: 'Reading' + nextModuleUUID: moduleUUID + + context.getNextPage?({moduleUUID, collectionUUID}) or fallBack + + render: -> + props = _.omit(@props, 'children') + {nextChapter, nextModuleUUID} = @state + {collectionUUID} = @context + + continueLabel = @props.children unless _.isEmpty @props.children + continueLabel ?= "Continue to #{nextChapter}" + + + {continueLabel} + + + + +GoToBookLink = React.createClass + displayName: 'GoToBookLink' + contextTypes: + moduleUUID: React.PropTypes.string + collectionUUID: React.PropTypes.string + triggeredFrom: React.PropTypes.shape( + moduleUUID: React.PropTypes.string + collectionUUID: React.PropTypes.string + ) + + isFromOpen: -> + {triggeredFrom} = @context + viewingInfo = _.pick(@props, 'moduleUUID', 'collectionUUID') + + _.isEqual(triggeredFrom, viewingInfo) + + render: -> + linkAction = if @isFromOpen() then 'Return' else 'Go' + + {linkAction} to Reading + + + +ReturnToBookButton = React.createClass + displayName: 'ReturnToBookButton' + getDefaultProps: -> + section: 'Reading' + render: -> + {section, className} = @props + classes = classnames 'btn-plain', className + + + + Return to {section} + + +module.exports = {ExerciseButton, ContinueToBookButton, ReturnToBookButton, GoToBookLink, BookLink, BookLinkBase} diff --git a/coach/src/concept-coach/base.cjsx b/coach/src/concept-coach/base.cjsx new file mode 100644 index 0000000000..16b87da372 --- /dev/null +++ b/coach/src/concept-coach/base.cjsx @@ -0,0 +1,195 @@ +React = require 'react' + +_ = require 'underscore' +classnames = require 'classnames' +EventEmitter2 = require 'eventemitter2' + +{SmartOverflow, SpyMode} = require 'shared' + +{Task} = require '../task' +navigation = {Navigation} = require '../navigation' +CourseRegistration = require '../course/registration' +ErrorNotification = require './error-notification' +AccountsIframe = require '../user/accounts-iframe' +UpdateStudentIdentifier = require '../course/update-student-identifier' +LoginGateway = require '../user/login-gateway' +User = require '../user/model' + +{ExerciseStep} = require '../exercise' +{Dashboard} = require '../dashboard' +{Progress} = require '../progress' + +{channel} = require './model' +navigator = navigation.channel + +# TODO Move this and auth logic to user model +# These views are used with an authLevel (0, 1, 2, or 3) to determine what views the user is allowed to see. +VIEWS = ['loading', 'login', 'registration', ['task', 'progress', 'profile', 'dashboard', 'registration', 'student_id'], 'logout'] + +ConceptCoach = React.createClass + displayName: 'ConceptCoach' + + propTypes: + close: React.PropTypes.func + moduleUUID: React.PropTypes.string.isRequired + collectionUUID: React.PropTypes.string.isRequired + triggeredFrom: React.PropTypes.shape( + moduleUUID: React.PropTypes.string + collectionUUID: React.PropTypes.string + ) + + getDefaultProps: -> + defaultView: _.chain(VIEWS).last().first().value() + + getInitialState: -> + userState = User.status(@props.collectionUUID) + view = @getAllowedView(userState) + userState.view = view + userState + + childContextTypes: + moduleUUID: React.PropTypes.string + collectionUUID: React.PropTypes.string + triggeredFrom: React.PropTypes.shape( + moduleUUID: React.PropTypes.string + collectionUUID: React.PropTypes.string + ) + getNextPage: React.PropTypes.func + view: React.PropTypes.oneOf(_.flatten(VIEWS)) + cnxUrl: React.PropTypes.string + bookUrlPattern: React.PropTypes.string + close: React.PropTypes.func + navigator: React.PropTypes.instanceOf(EventEmitter2) + processHtmlAndMath: React.PropTypes.func + + getChildContext: -> + {view} = @state + {cnxUrl, close, moduleUUID, collectionUUID, getNextPage, triggeredFrom} = @props + bookUrlPattern = '{cnxUrl}/contents/{ecosystem_book_uuid}' + processHtmlAndMath = @props.processHtmlAndMath + + { + view, + cnxUrl, + close, + processHtmlAndMath, + bookUrlPattern, + navigator, + moduleUUID, + collectionUUID, + triggeredFrom, + getNextPage + } + + componentWillMount: -> + User.ensureStatusLoaded() + + componentDidMount: -> + mountData = @getMountData('mount') + channel.emit('coach.mount.success', mountData) + + User.channel.on('change', @updateUser) + navigator.on('show.*', @updateView) + + componentWillUnmount: -> + mountData = @getMountData('ummount') + channel.emit('coach.unmount.success', mountData) + + User.channel.off('change', @updateUser) + navigator.off('show.*', @updateView) + + getAllowedView: (userInfo) -> + {defaultView} = @props + if not userInfo.isLoaded + authLevel = 0 + else if userInfo.preValidate + authLevel = 2 # prevalidate the course + else if not userInfo.isLoggedIn + authLevel = 1 # login / signup + else if not userInfo.isRegistered + authLevel = 2 # complete joining the course + else + authLevel = 3 + + view = VIEWS[authLevel] + + # if there are multiple views allowed for this level + if _.isArray(view) + # and the target/defaultView is one of the views in this level + if defaultView in view + # then the iew should be the defaultView + view = defaultView + else + # else, the view should be the first of views allowed for this level + view = _.first(view) + + view + + getMountData: (action) -> + {moduleUUID, collectionUUID} = @props + {view} = @state + el = @getDOMNode() + + coach: {el, action, view, moduleUUID, collectionUUID} + + updateView: (eventData) -> + {view} = eventData + @setState({view}) if view? and view isnt @state.view + + showTasks: -> + @updateView(view: 'task') + + updateUser: -> + userState = User.status(@props.collectionUUID) + view = @getAllowedView(userState) + + # tell nav to update view if the next view isn't the current view + navigator.emit("show.#{view}", view: view) if view isnt @state.view + + @setState(userState) + + childComponent: (course) -> + {view} = @state + switch view + when 'loading' + Loading ... + when 'logout' + + when 'login' + + when 'registration' + + when 'task' + + when 'progress' + + when 'dashboard' + + when 'profile' + + when 'registration' + + when 'student_id' + + else +

bad internal state, no view is set

+ + render: -> + {isLoaded, isLoggedIn, view} = @state + course = User.getCourse(@props.collectionUUID) + + className = classnames 'concept-coach-view', "concept-coach-view-#{view}", + loading: not (isLoggedIn or isLoaded) + +
+ + + + +
+ {@childComponent(course)} +
+
+
+ +module.exports = {ConceptCoach, channel} diff --git a/coach/src/concept-coach/coach.cjsx b/coach/src/concept-coach/coach.cjsx new file mode 100644 index 0000000000..bd5f560c18 --- /dev/null +++ b/coach/src/concept-coach/coach.cjsx @@ -0,0 +1,33 @@ +React = require 'react' +_ = require 'underscore' + +{ConceptCoach, channel} = require './base' +{CCModal} = require './modal' +{Launcher} = require './launcher' + +Coach = React.createClass + displayName: 'Coach' + getDefaultProps: -> + open: false + displayLauncher: true + propTypes: + open: React.PropTypes.bool + displayLauncher: React.PropTypes.bool + filterClick: React.PropTypes.func + + render: -> + {open, displayLauncher, filterClick} = @props + coachProps = _.omit(@props, 'open') + + modal = + + if open + + launcher = if displayLauncher + +
+ {launcher} + {modal} +
+ +module.exports = {Coach, channel} diff --git a/coach/src/concept-coach/error-notification.cjsx b/coach/src/concept-coach/error-notification.cjsx new file mode 100644 index 0000000000..2094854484 --- /dev/null +++ b/coach/src/concept-coach/error-notification.cjsx @@ -0,0 +1,106 @@ +React = require 'react' +BS = require 'react-bootstrap' +_ = require 'underscore' +api = require '../api' + + +BASE_CONTACT_LINK = 'http://openstax.force.com/support?l=en_US&c=Products%3AConcept_Coach&cu=1&fs=ContactUs&q=' + +makeContactMessage = (errors, userAgent, location) -> + template = """Hello! + I ran into a problem while using Concept Coach on + #{userAgent} at #{location}. + + Here is some additional info: + #{errors.join()}.""" + +makeContactURL = (errors, windowContext) -> + userAgent = windowContext.navigator.userAgent + location = windowContext.location.href + + q = encodeURIComponent(makeContactMessage(errors, userAgent, location)) + + "#{BASE_CONTACT_LINK}#{q}" + + +ErrorNotification = React.createClass + + getInitialState: -> + errors: false, isShowingDetails: false + + componentWillMount: -> + api.channel.on 'error', @onError + + componentWillUnmount: -> + api.channel.off 'error', @onError + + onError: ({response, failedData, exception}) -> + return if failedData?.stopErrorDisplay # someone else is handling displaying the error + if exception? + errors = [exception.toString()] + else if response.status is 0 # either no response, or the response lacked CORS headers and the browser rejected it + errors = ["Unknown response received from server"] + else + errors = ["#{response.status}: #{response.statusText}"] + if _.isArray(failedData.data?.errors) # we have something from server to display + errors = errors.concat( + _.flatten _.map failedData.data.errors, (error) -> + # All 422 errors from BE *should* have a "code" property. If not, show whatever it is + if error.code then error.code else JSON.stringify(error) + ) + @setState(errors: errors) + + toggleDetails: -> + @setState(isShowingDetails: not @state.isShowingDetails) + + onHide: -> + @setState(errors: false) + + renderDetails: -> + +
    + {for error, i in @state.errors +
  • {error}
  • } +
+

+ {window.navigator.userAgent} +

+
+ + render: -> + return null unless @state.errors + + modalProps = _.pick(@props, 'container') + + + + + Error encountered + + +
+

+ An unexpected error has occured. Please + visit our support site so we can help to diagnose and correct the issue. +

+

+ When reporting the issue, it would be helpful if you could include the error details. +

+ + {if @state.isShowingDetails then "Hide" else "Show"} Details + + {@renderDetails() if @state.isShowingDetails} +
+
+ OK +
+
+ + +module.exports = ErrorNotification diff --git a/coach/src/concept-coach/index.cjsx b/coach/src/concept-coach/index.cjsx new file mode 100644 index 0000000000..1a1e2936b1 --- /dev/null +++ b/coach/src/concept-coach/index.cjsx @@ -0,0 +1,175 @@ +_ = require 'underscore' +$ = require 'jquery' +EventEmitter2 = require 'eventemitter2' +helpers = require '../helpers' + +restAPI = require '../api' +componentModel = require './model' +navigation = require '../navigation/model' +User = require '../user/model' +exercise = require '../exercise/collection' +progress = require '../progress/collection' +task = require '../task/collection' + +{Coach} = require './coach' +coachWrapped = helpers.wrapComponent(Coach) + +PROPS = ['moduleUUID', 'collectionUUID', 'cnxUrl', 'getNextPage', 'processHtmlAndMath'] +WRAPPER_CLASSNAME = 'concept-coach-wrapper' + +listenAndBroadcast = (componentAPI) -> + # Broadcast various internal events out to parent + restAPI.channel.on 'error', (response) -> + componentAPI.emit('api.error', response) + + restAPI.channel.on 'user.status.receive.fetch', (response) -> + componentAPI.emit('user.change', response) + + componentModel.channel.on 'coach.mount.success', (eventData) -> + componentModel.update(eventData.coach) + + componentAPI.emit('open', eventData) + componentAPI.emit('view.update', navigation.getDataByView(eventData.coach.view)) + + componentModel.channel.on 'coach.unmount.success', (eventData) -> + componentModel.update(eventData.coach) + + componentAPI.emit('close', eventData) + componentAPI.emit('view.update', navigation.getDataByView('close')) + + componentModel.channel.on 'close.clicked', -> + componentAPI.emit('ui.close') + + componentModel.channel.on 'launcher.clicked', -> + componentAPI.emit('ui.launching') + + navigation.channel.on 'show.*', (eventData) -> + componentAPI.emit('view.update', navigation.getDataByView(eventData.view)) + + navigation.channel.on 'close.for.book', (eventData) -> + componentAPI.emit('book.update', eventData) + + exercise.channel.on 'component.*', (eventData) -> + componentAPI.emit("exercise.component.#{eventData.status}", eventData) + +setupAPIListeners = (componentAPI) -> + navigation.channel.on "switch.*", (eventData) -> + {data, view} = eventData + componentAPI.update(data) + navigation.channel.emit("show.#{view}", {view}) + + componentAPI.on 'show.*', (eventData) -> + componentAPI.updateToView(eventData.view) + +initializeModels = (models) -> + _.each models, (model) -> + model.init?() + +stopModelChannels = (models) -> + _.each models, (model) -> + model.destroy?() or model.channel?.removeAllListeners?() + +deleteProperties = (obj) -> + for property, value of obj + delete obj[property] unless _.isFunction(obj[property]) or property is 'channel' + null + +class ConceptCoachAPI extends EventEmitter2 + constructor: (baseUrl, navOptions = {}) -> + super(wildcard: true) + + _.defaults(navOptions, {prefix: '/', base: 'concept-coach/'}) + + restAPI.init = _.partial restAPI.initialize, baseUrl + navigation.init = _.partial navigation.initialize, navOptions + + @models = [restAPI, navigation, User, exercise, progress, task, componentModel] + initializeModels(@models) + + listenAndBroadcast(@) + setupAPIListeners(@) + User.ensureStatusLoaded(true) + + destroy: -> + @close?() + @remove() + + stopModelChannels(@models) + deleteProperties(@models) + deleteProperties(componentModel) + + @removeAllListeners() + + remove: -> + coachWrapped.unmountFrom(componentModel.mounter) if @component?.isMounted() + + setOptions: (options) -> + isSame = _.isEqual(_.pick(options, PROPS), _.pick(componentModel, PROPS)) + options = _.extend({}, options, isSame: isSame) + componentModel.update(options) + + initialize: (mountNode, props = {}) -> + @remove() + props = _.clone(props) + props.defaultView ?= if componentModel.isSame then componentModel.view else 'task' + + componentModel.update( + mounter: mountNode + isSame: true + ) + + props.close = => + @component.setProps(open: false) + componentModel.channel.emit('close.clicked') + + @close = props.close + @component = coachWrapped.render(mountNode, props) + + open: (props) -> + # wait until our logout request has been received and the close + User.channel.once 'logout.received', => + @close() + + openProps = _.extend({}, props, open: true) + openProps.triggeredFrom = _.pick(props, 'moduleUUID', 'collectionUUID') + + @component.setProps(openProps) + + openByRoute: (props, route) -> + props = _.clone(props) + props.defaultView = navigation.getViewByRoute(route) + + if props.defaultView? and props.defaultView isnt 'close' + @open(props) + + updateToView: (view) -> + if @component?.isMounted() + if view is 'close' + @component.props.close() + else + navigation.channel.emit("show.#{view}", {view}) + else if componentModel.mounter? and view isnt 'close' + props = _.pick(componentModel, PROPS) + props.defaultView = view + @open(props) + + updateToRoute: (route) -> + view = navigation.getViewByRoute(route) + @updateToView(view) if view? + + update: (nextProps) -> + return unless @component? + props = _.extend({}, _.pick(nextProps, PROPS)) + @component.setProps(props) + + handleOpened: (eventData, body = document.body) -> + body.classList.add('cc-opened') + + handleClosed: (eventData, body = document.body) -> + body.classList.remove('cc-opened') + + handleError: (error) -> + channel.emit('error', error) + console.info(error) + +module.exports = ConceptCoachAPI diff --git a/coach/src/concept-coach/laptop-and-mug.cjsx b/coach/src/concept-coach/laptop-and-mug.cjsx new file mode 100644 index 0000000000..80768ea5c2 --- /dev/null +++ b/coach/src/concept-coach/laptop-and-mug.cjsx @@ -0,0 +1,111 @@ +# coffeelint: disable=max_line_length +React = require 'react' + +LaptopAndMug = React.createClass + + propTypes: + height: React.PropTypes.number.isRequired + + render: -> + {height} = @props + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +module.exports = LaptopAndMug diff --git a/coach/src/concept-coach/launcher/background-and-desk.cjsx b/coach/src/concept-coach/launcher/background-and-desk.cjsx new file mode 100644 index 0000000000..5e45919714 --- /dev/null +++ b/coach/src/concept-coach/launcher/background-and-desk.cjsx @@ -0,0 +1,12 @@ +React = require 'react' + +BackgroundAndDesk = React.createClass + displayName: 'BackgroundAndDesk' + propTypes: + height: React.PropTypes.number.isRequired + render: -> +
+

Study Smarter with OpenStax Concept Coach

+
+ +module.exports = BackgroundAndDesk diff --git a/coach/src/concept-coach/launcher/index.cjsx b/coach/src/concept-coach/launcher/index.cjsx new file mode 100644 index 0000000000..661b901c13 --- /dev/null +++ b/coach/src/concept-coach/launcher/index.cjsx @@ -0,0 +1,52 @@ +React = require 'react' +BS = require 'react-bootstrap' +_ = require 'underscore' +classnames = require 'classnames' + +BackgroundAndDesk = require './background-and-desk' +LaptopAndMug = require '../laptop-and-mug' + +{channel} = require '../model' + + +Launcher = React.createClass + displayName: 'Launcher' + propTypes: + isLaunching: React.PropTypes.bool + defaultHeight: React.PropTypes.number + getDefaultProps: -> + isLaunching: false + defaultHeight: 388 + getInitialState: -> + height: @getHeight() + componentWillReceiveProps: (nextProps) -> + @setState(height: @getHeight(nextProps)) if @props.isLaunching isnt nextProps.isLaunching + + getHeight: (props) -> + props ?= @props + {isLaunching, defaultHeight} = props + if isLaunching then window.innerHeight else defaultHeight + + launch: -> + channel.emit('launcher.clicked') + undefined # stop react from complaining about returning false from a handler + + render: -> + {isLaunching, defaultHeight} = @props + {height} = @state + + classes = classnames 'concept-coach-launcher', + launching: isLaunching + +
+
+ + + Launch Concept Coach + enrollment code needed + + +
+
+ +module.exports = {Launcher} diff --git a/coach/src/concept-coach/modal.cjsx b/coach/src/concept-coach/modal.cjsx new file mode 100644 index 0000000000..4dba5586a7 --- /dev/null +++ b/coach/src/concept-coach/modal.cjsx @@ -0,0 +1,64 @@ +React = require 'react' +classnames = require 'classnames' +_ = require 'underscore' + +{channel} = require './model' +api = require '../api' +navigation = require '../navigation/model' + +CCModal = React.createClass + displayName: 'CCModal' + getInitialState: -> + isLoaded: false + + componentDidMount: -> + mountData = modal: el: @getDOMNode() + channel.emit('modal.mount.success', mountData) + mountData.modal.el.focus() + + # only wait to set loaded if there is a pending api call + if api.isPending() + api.channel.once('completed', @setLoaded) + else + @setLoaded() + + componentWillMount: -> + document.addEventListener('click', @checkAllowed, true) + document.addEventListener('focus', @checkAllowed, true) + + navigation.channel.on('show.*', @resetScroll) + + componentWillUnmount: -> + document.removeEventListener('click', @checkAllowed, true) + document.removeEventListener('focus', @checkAllowed, true) + + navigation.channel.off('show.*', @resetScroll) + + resetScroll: -> + modal = @getDOMNode() + modal.scrollTop = 0 + + checkAllowed: (focusEvent) -> + modal = @getDOMNode() + unless modal.contains(focusEvent.target) or @props.filterClick?(focusEvent) + focusEvent.preventDefault() + focusEvent.stopImmediatePropagation() + modal.focus() + + setLoaded: -> + {isLoaded} = @state + @setState(isLoaded: true) unless isLoaded + + render: -> + {isLoaded} = @state + + classes = classnames 'concept-coach-modal', + loaded: isLoaded + +
+
+ {@props.children} +
+
+ +module.exports = {CCModal, channel} diff --git a/coach/src/concept-coach/model.coffee b/coach/src/concept-coach/model.coffee new file mode 100644 index 0000000000..108e530afe --- /dev/null +++ b/coach/src/concept-coach/model.coffee @@ -0,0 +1,10 @@ +_ = require 'underscore' +EventEmitter2 = require 'eventemitter2' + +coach = + update: (options) -> + _.extend(@, options) + + channel: new EventEmitter2 wildcard: true + +module.exports = coach \ No newline at end of file diff --git a/coach/src/course/confirm-join.cjsx b/coach/src/course/confirm-join.cjsx new file mode 100644 index 0000000000..2c3f886084 --- /dev/null +++ b/coach/src/course/confirm-join.cjsx @@ -0,0 +1,76 @@ +React = require 'react' +BS = require 'react-bootstrap' +ENTER = 'Enter' +RequestStudentId = require './request-student-id' + +Course = require './model' +ErrorList = require './error-list' +{AsyncButton} = require 'shared' + +ConfirmJoin = React.createClass + + propTypes: + course: React.PropTypes.instanceOf(Course).isRequired + optionalStudentId: React.PropTypes.bool + + startConfirmation: -> + @props.course.confirm(@getSchoolId()) + + onKeyPress: (ev) -> + @onSubmit() if ev.key is ENTER + + onCancel: (ev) -> + ev.preventDefault() + @props.course.confirm() + + onSubmit: -> + @props.course.confirm(@getSchoolId()) + + getSchoolId: -> @refs.input.getDOMNode().value + + render: -> + + +
+ +

+
You are joining
+
{@props.course.description()}
+
{@props.course.teacherNames()}
+

+ + +

Enter your school-issued ID

+
+
+ + + + + Continue + + +
+ or + + Skip this step for now* + +
+
+ * You can enter you student ID later by clicking on your name in the top right + of your dashboard and selecting "Change Student ID" from the menu. +
+
+
+ + +module.exports = ConfirmJoin diff --git a/coach/src/course/enroll-or-login.cjsx b/coach/src/course/enroll-or-login.cjsx new file mode 100644 index 0000000000..88fb6c397b --- /dev/null +++ b/coach/src/course/enroll-or-login.cjsx @@ -0,0 +1,38 @@ +React = require 'react' +classnames = require 'classnames' + +NewCourseRegistration = require './new-registration' +Course = require './model' +LoginGateway = require '../user/login-gateway' +LaptopAndMug = require '../concept-coach/laptop-and-mug' + +EnrollOrLogin = React.createClass + getInitialState: -> + isOpen: false + + toggleOpen: (isOpen) -> + @setState({isOpen}) + + render: -> + {isOpen} = @state + + signUpClasses = classnames 'sign-up', + 'btn btn-primary btn-lg': not isOpen + +
+ +
+ Sign up for Concept Coach +

+ Enrollment code required +

+

+ No enrollment code? Contact your instructor. +

+
+ Already have an account? Sign in +
+
+
+ +module.exports = EnrollOrLogin diff --git a/coach/src/course/enrollment-code-input.cjsx b/coach/src/course/enrollment-code-input.cjsx new file mode 100644 index 0000000000..ffa3c19318 --- /dev/null +++ b/coach/src/course/enrollment-code-input.cjsx @@ -0,0 +1,56 @@ +React = require 'react' +BS = require 'react-bootstrap' +ENTER = 'Enter' + +{CourseListing} = require './listing' +ErrorList = require './error-list' +Course = require './model' +{AsyncButton} = require 'shared' +User = require '../user/model' + +EnrollmentCodeInput = React.createClass + + propTypes: + title: React.PropTypes.string.isRequired + course: React.PropTypes.instanceOf(Course).isRequired + currentCourses: React.PropTypes.arrayOf(React.PropTypes.instanceOf(Course)) + + startRegistration: -> + @props.course.register(@refs.input.getValue(), User) + + onKeyPress: (ev) -> + return if @props.course.isBusy # double enter + @startRegistration() if ev.key is ENTER + + renderCurrentCourses: -> +
+

You are not registered for this course.

+

Did you mean to go to one of these?

+ +
+ + render: -> + button = + + Enroll + + +
+ {@renderCurrentCourses() if @props.currentCourses?.length} +

{@props.title}

+
+ +
+ +
+
+ +module.exports = EnrollmentCodeInput diff --git a/coach/src/course/error-list.cjsx b/coach/src/course/error-list.cjsx new file mode 100644 index 0000000000..7748ea434e --- /dev/null +++ b/coach/src/course/error-list.cjsx @@ -0,0 +1,19 @@ +React = require 'react' +Course = require './model' + +ErrorList = React.createClass + + propTypes: + course: React.PropTypes.instanceOf(Course).isRequired + + render: -> + return null unless @props.course.hasErrors() +
+
    + {for msg, i in @props.course.errorMessages() +
  • {msg}
  • } +
+
+ + +module.exports = ErrorList diff --git a/coach/src/course/item.cjsx b/coach/src/course/item.cjsx new file mode 100644 index 0000000000..fedc976b41 --- /dev/null +++ b/coach/src/course/item.cjsx @@ -0,0 +1,44 @@ +React = require 'react' +BS = require 'react-bootstrap' +EventEmitter2 = require 'eventemitter2' +interpolate = require 'interpolate' +_ = require 'underscore' + +{BookLinkBase} = require '../buttons' +navigation = require '../navigation/model' + +CourseItem = React.createClass + displayName: 'CourseItem' + + contextTypes: + cnxUrl: React.PropTypes.string + bookUrlPattern: React.PropTypes.string + + getLink: -> + {course} = @props + {cnxUrl, bookUrlPattern} = @context + {ecosystem_book_uuid} = course + bookUrlPattern ?= '' + + link = interpolate bookUrlPattern, {cnxUrl, ecosystem_book_uuid} + routeData = navigation.getDataByView('task') + + "#{link}#{routeData.route}" + + render: -> + {course} = @props + return null unless course.isRegistered() + + {ecosystem_book_uuid} = course + link = @getLink() + category = course.catalog_offering_identifier?.toLowerCase() or 'unknown' + + + + {course.description()} + + + +module.exports = {CourseItem} diff --git a/coach/src/course/listing.cjsx b/coach/src/course/listing.cjsx new file mode 100644 index 0000000000..39fb59b3eb --- /dev/null +++ b/coach/src/course/listing.cjsx @@ -0,0 +1,22 @@ +React = require 'react' +BS = require 'react-bootstrap' +_ = require 'underscore' + +{CourseItem} = require './item' + +CourseListing = React.createClass + displayName: 'CourseListing' + getDefaultProps: -> + disabled: false + render: -> + {courses} = @props + listedCourses = _.map courses, (course) -> + + + + {listedCourses} + + +module.exports = {CourseListing} diff --git a/coach/src/course/model.coffee b/coach/src/course/model.coffee new file mode 100644 index 0000000000..6f1ef9855d --- /dev/null +++ b/coach/src/course/model.coffee @@ -0,0 +1,178 @@ +# coffeelint: disable=max_line_length + +_ = require 'underscore' +React = require 'react' +EventEmitter2 = require 'eventemitter2' + +api = require '../api' + +ERROR_MAP = { + invalid_enrollment_code: 'The provided enrollment code is not valid. Please verify the enrollment code and try again.' + enrollment_code_does_not_match_book: 'The provided enrollment code matches a course but not for the current book. ' + + 'Please verify the enrollment code and try again.' + already_enrolled: 'You are already enrolled in this course. Please log in.' + multiple_roles: 'We currently do not support teacher accounts with multiple associated student enrollments.' + dropped_student: 'You have been dropped from this course. Please speak to your instructor to rejoin.' + already_processed: 'Your enrollment in this course has been processed. Please reload the page.' + already_approved: 'Your enrollment in this course has been approved. Please reload the page.' + already_rejected: 'Your request to enroll in this course has been rejected for an unknown reason. Please contact OpenStax support.' + taken: 'The provided student ID has already been used in this course. Please try again or contact your instructor.' + blank_student_identifer: 'The student ID field cannot be left blank. Please enter your student ID.' +} + + +class Course + + constructor: (attributes) -> + @channel = new EventEmitter2 + _.extend(@, attributes) + _.bindAll(@, '_onRegistered', '_onConfirmed', '_onValidated', '_onStudentUpdated') + + # complete and ready for use + isRegistered: -> @id and not (@isIncomplete() or @isPending()) + # Freshly initialized, registration code has not been entered + isIncomplete: -> not (@name or @to) + # The registration code has been validated but sign-up is not yet started + isValidated: -> @status is "validated" + # A registration has been created, but not confimed + isPending: -> @status is "pending" + + # forget in-progress registration information. Called when a join is canceled + resetToBlankState: -> + delete @to + delete @name + @channel.emit('change') + + description: -> + if @isIncomplete() # still fetching + "" + else if @isPending() # we originated from a join or move request + msg = @describeMovePart(@to) + if @from then "from #{@describeMovePart(@from)} to #{msg}" else msg + else + "#{@name} #{_.first(@periods).name}" + + describeMovePart: (part) -> + return '' unless part + "#{part.course.name} (section #{part.period.name})" + + teacherNames: -> + part = @to or @ + return '' unless part.course?.teachers + teachers = part.course.teachers + names = _.map teachers, (teacher) -> + teacher.name or "#{teacher.first_name} #{teacher.last_name}" + # convert array to sentence + if names.length > 1 + "Instructors: " + names.slice(0, names.length - 1).join(', ') + " and " + names.slice(-1) + else + "Instructor: " + _.first(names) + + getStudentIdentifier: -> + @getStudentRecord()?.student_identifier + + getStudentRecord: -> + # Currently the students listing only contains the current student + # If that is ever extended then the bootstrap data will need to include + # the current user's id so that the `students` array can be searched for it. + @students = [{}] if _.isEmpty(@students) + _.first(@students) + + hasErrors: -> + not _.isEmpty(@errors) + + errorMessages: -> + _.map @errors, (err) -> ERROR_MAP[err.code] or "An unknown error with code #{err.code} occured." + + # When a course needs to be manipluated, it's cloned + clone: -> + new Course({ecosystem_book_uuid: @ecosystem_book_uuid}) + + # The clone's attributes are persisted to the user once complete + persist: (user) -> + other = user.findOrCreateCourse(@ecosystem_book_uuid) + _.extend(other, @to?.course) + other.status = @status + other.enrollment_code = @enrollment_code + other.periods = [ @to?.period ] + user.onCourseUpdate(other) + + _onValidated: (response) -> + {data} = response + delete @isBusy + @errors = data?.errors + response.stopErrorDisplay = true if @errors + @status = 'validated' if data?.response is true + @channel.emit('change') + + # Submits pending course change for confirmation + confirm: (studentId) -> + payload = { id: @id } + payload.student_identifier = studentId if studentId + @isBusy = true + api.channel.once "course.#{@id}.receive.confirmation.*", @_onConfirmed + api.channel.emit("course.#{@id}.send.confirmation", data: payload) + @channel.emit('change') + + _onConfirmed: (response) -> + throw new Error("response is empty in onConfirmed") if _.isEmpty(response) + {data} = response + if data?.to + _.extend(@, data.to.course) + @periods = [ data.to.period ] + @errors = data?.errors + @getStudentRecord().student_identifier = response.data.student_identifier + response.stopErrorDisplay = true if @errors + delete @status unless @hasErrors() # blank status indicates good to go + delete @isBusy + @channel.emit('change') + + # Submits a course invite for registration. If user is signed in + # the registration will be saved, otherwise it will only be vaidated + register: (enrollment_code, user) -> + @enrollment_code = enrollment_code + data = {enrollment_code, book_uuid: @ecosystem_book_uuid} + @isBusy = true + if user.isLoggedIn() + api.channel.once "course.#{@ecosystem_book_uuid}.receive.registration.*", @_onRegistered + api.channel.emit("course.#{@ecosystem_book_uuid}.send.registration", {data}) + else + api.channel.once "course.#{@ecosystem_book_uuid}.receive.prevalidation.*", @_onValidated + api.channel.emit("course.#{@ecosystem_book_uuid}.send.prevalidation", {data}) + @channel.emit('change') + + _onStudentUpdated: (response) -> + _.extend(@, response.data) if response?.data + @getStudentRecord().student_identifier = response.data.student_identifier + @channel.emit('change') + + updateStudentIdentifier: ( newIdentifier ) -> + if _.isEmpty(newIdentifier) + @errors = [{code: 'blank_student_identifer'}] + @channel.emit('change') + else + @updateStudent(student_identifier: newIdentifier) + + updateStudent: (attributes) -> + data = _.extend({}, attributes, id: @id) + api.channel.once "course.#{@ecosystem_book_uuid}.receive.studentUpdate.*", @_onStudentUpdated + api.channel.emit("course.#{@ecosystem_book_uuid}.send.studentUpdate", {data}) + + _onRegistered: (response) -> + throw new Error("response is empty in onRegistered") if _.isEmpty(response) + {data} = response + console.log data, response + _.extend(@, data) if data + @errors = data?.errors + # a freshly registered course doesn't contain the is_concept_coach flag + @is_concept_coach = true + response.stopErrorDisplay = true if @errors + delete @isBusy + @channel.emit('change') + + destroy: -> + @channel.emit('destroy') + @channel.removeAllListeners() + + +module.exports = Course diff --git a/coach/src/course/modify-registration.cjsx b/coach/src/course/modify-registration.cjsx new file mode 100644 index 0000000000..2747f2199d --- /dev/null +++ b/coach/src/course/modify-registration.cjsx @@ -0,0 +1,66 @@ +React = require 'react' +_ = require 'underscore' + +EnrollmentCodeInput = require './enrollment-code-input' +RequestStudentId = require './request-student-id' + +ConfirmJoin = require './confirm-join' +User = require '../user/model' +Course = require './model' +Navigation = require '../navigation/model' + +ModifyCourseRegistration = React.createClass + + propTypes: + course: React.PropTypes.instanceOf(Course) + + # create a private copy of the course to operate on + getInitialState: -> + course = @props.course.clone() + course.channel.on('change', @onCourseChange) + {course, original: @props.course} + + componentWillUnmount: -> + @state.course.channel.off('change', @onCourseChange) + + onCourseChange: -> + if @state.course.isRegistered() + # wait 1.5 secs so our success message is briefly displayed, then call onComplete + _.delay(@onComplete, 1500) + @forceUpdate() + + showTasks: -> + Navigation.channel.emit('show.panel', view: 'task') + + onComplete: -> + @state.course.persist(User) + @showTasks() + + renderComplete: (course) -> +

+ You have successfully modified your registration to be {course.description()} +

+ + renderCurrentStep: -> + {course, original} = @state + + if course.isIncomplete() + + else if course.isPending() + + else + @renderComplete(course) + + render: -> +
+ + {@renderCurrentStep()} +
+ +module.exports = ModifyCourseRegistration diff --git a/coach/src/course/new-registration.cjsx b/coach/src/course/new-registration.cjsx new file mode 100644 index 0000000000..823b6f929a --- /dev/null +++ b/coach/src/course/new-registration.cjsx @@ -0,0 +1,89 @@ +React = require 'react' +_ = require 'underscore' + +Course = require './model' +User = require '../user/model' +ENTER = 'Enter' + +EnrollmentCodeInput = require './enrollment-code-input' +ConfirmJoin = require './confirm-join' +Navigation = require '../navigation/model' +User = require '../user/model' + +NewCourseRegistration = React.createClass + + propTypes: + collectionUUID: React.PropTypes.string.isRequired + validateOnly: React.PropTypes.bool + title: React.PropTypes.string + course: React.PropTypes.instanceOf(Course) + + getDefaultProps: -> + title: 'Register for this Concept Coach course' + + componentWillMount: -> + course = @props.course or + User.getCourse(@props.collectionUUID) or + new Course({ecosystem_book_uuid: @props.collectionUUID}) + course.channel.on('change', @onCourseChange) + @setState({course}) + + componentWillUnmount: -> + @state.course.channel.off('change', @onCourseChange) + + onComplete: -> + @state.course.persist(User) + Navigation.channel.emit('show') + + onCourseChange: -> + if @state.course.isRegistered() + # wait 1.5 secs so our success message is briefly displayed, then call onComplete + _.delay(@onComplete, 1500) + else if @state.course.isValidated() + @onComplete() + + @forceUpdate() + + renderValidated: -> +

Redirecting to login...

+ + renderComplete: (course) -> +

+ You have successfully joined {course.description()} +

+ + isTeacher: -> + User.isTeacherForCourse(@props.collectionUUID) + + renderCurrentStep: -> + {course} = @state + if course.isValidated() + @renderValidated() + else if course.isIncomplete() + title = if @isTeacher() then '' else @props.title + + else if course.isPending() + + else + @renderComplete(course) + + teacherMessage: -> +
+

+ Welcome! +

+ To see the student view of your course in Concept Coach, + enter an enrollment code from one of your sections. +

+ We suggest creating a test section for yourself so you can + separate your Concept Coach responses from those of your students. +

+
+ + render: -> +
+ {@teacherMessage() if @isTeacher()} + {@renderCurrentStep()} +
+ +module.exports = NewCourseRegistration diff --git a/coach/src/course/registration.cjsx b/coach/src/course/registration.cjsx new file mode 100644 index 0000000000..ca6f3f705e --- /dev/null +++ b/coach/src/course/registration.cjsx @@ -0,0 +1,31 @@ +React = require 'react' + +NewCourseRegistration = require './new-registration' +ModifyCourseRegistration = require './modify-registration' +EnrollOrLogin = require './enroll-or-login' + +UserStatus = require '../user/status-mixin' +Course = require './model' + +CourseRegistration = React.createClass + + propTypes: + collectionUUID: React.PropTypes.string.isRequired + + mixins: [UserStatus] + + render: -> + user = @getUser() + course = user.getCourse(@props.collectionUUID) + body = if course and course.isRegistered() + + else if user.isLoggedIn() + + else + + +
+ {body} +
+ +module.exports = CourseRegistration diff --git a/coach/src/course/request-student-id.cjsx b/coach/src/course/request-student-id.cjsx new file mode 100644 index 0000000000..a8c2ae2b54 --- /dev/null +++ b/coach/src/course/request-student-id.cjsx @@ -0,0 +1,64 @@ +React = require 'react' +BS = require 'react-bootstrap' +ENTER = 'Enter' + +User = require '../user/model' +Course = require './model' +ErrorList = require './error-list' +{AsyncButton} = require 'shared' + +RequestStudentId = React.createClass + + propTypes: + onCancel: React.PropTypes.func.isRequired + onSubmit: React.PropTypes.func.isRequired + label: React.PropTypes.oneOfType([ + React.PropTypes.string + React.PropTypes.element + ]).isRequired + saveButtonLabel: React.PropTypes.string.isRequired + title: React.PropTypes.string.isRequired + course: React.PropTypes.instanceOf(Course).isRequired + + startConfirmation: -> + @props.course.confirm(@refs.input.getValue()) + + onKeyPress: (ev) -> + @onSubmit() if ev.key is ENTER + + onCancel: (ev) -> + ev.preventDefault() + @props.onCancel() + + onSubmit: -> + @props.onSubmit(@refs.input.getValue()) + + render: -> + button = + {@props.saveButtonLabel} + +
+

+ {@props.title} +

+ +
+
+ +
+
+ Cancel +
+
+
+ +module.exports = RequestStudentId diff --git a/coach/src/course/update-student-identifier.cjsx b/coach/src/course/update-student-identifier.cjsx new file mode 100644 index 0000000000..1d7660d7c3 --- /dev/null +++ b/coach/src/course/update-student-identifier.cjsx @@ -0,0 +1,71 @@ +_ = require 'underscore' +React = require 'react' +BS = require 'react-bootstrap' +{AsyncButton} = require 'shared' +ENTER = 'Enter' + +Course = require './model' +RequestStudentId = require './request-student-id' +Navigation = require '../navigation/model' + +UpdateStudentIdentifer = React.createClass + componentWillMount: -> + course = @props.course or + User.getCourse(@props.collectionUUID) or + new Course({ecosystem_book_uuid: @props.collectionUUID}) + course.channel.on('change', @onCourseChange) + @setState({course}) + + componentWillUnmount: -> + @state.course.channel.off('change', @onCourseChange) + + onCourseChange: -> + if @props.course.student_identifier + @setState(requestSuccess: true) + delete @props.course.student_identifier + # wait 1.5 secs so our success message is briefly displayed, then call onComplete + _.delay(@onCancel, 1500) + @forceUpdate() + + propTypes: + course: React.PropTypes.instanceOf(Course).isRequired + + startConfirmation: -> + @props.course.confirm(@refs.input.getValue()) + + onKeyPress: (ev) -> + @startConfirmation() if ev.key is ENTER + + onConfirmKeyPress: (ev) -> + @startConfirmation() if ev.key is ENTER + + cancelConfirmation: -> + @props.course.resetToBlankState() + + onSubmit: (studentId) -> + @props.course.updateStudentIdentifier(studentId) + + onCancel: -> + Navigation.channel.emit('show.task', view: 'task') + + renderComplete: -> +

+ You have successfully updated your student identifier. +

+ + render: -> + return @renderComplete() if @state.requestSuccess + + + + + +module.exports = UpdateStudentIdentifer diff --git a/coach/src/dashboard/index.cjsx b/coach/src/dashboard/index.cjsx new file mode 100644 index 0000000000..55d0877e2a --- /dev/null +++ b/coach/src/dashboard/index.cjsx @@ -0,0 +1,36 @@ +React = require 'react' +_ = require 'underscore' +classnames = require 'classnames' + +{Reactive} = require '../reactive' +{CourseListing} = require '../course/listing' +User = require '../user/model' + +apiChannelName = 'user' + +DashboardBase = React.createClass + displayName: 'DashboardBase' + getDefaultProps: -> + item: {} + render: -> + {item, status, cnxUrl} = @props + +
+

Enrolled Courses

+ +
+ +Dashboard = React.createClass + displayName: 'Dashboard' + render: -> + + + + +module.exports = {Dashboard, DashboardBase} diff --git a/coach/src/exercise/collection.coffee b/coach/src/exercise/collection.coffee new file mode 100644 index 0000000000..e90dadf139 --- /dev/null +++ b/coach/src/exercise/collection.coffee @@ -0,0 +1,90 @@ +EventEmitter2 = require 'eventemitter2' +api = require '../api' +steps = {} +freeResponseCache = {} + +_ = require 'underscore' + +user = require '../user/model' + +channel = new EventEmitter2 wildcard: true + +STEP_TYPES = + 'free-response': ['free_response'] + 'multiple-choice': ['answer_id', 'is_completed'] + + +getStepsByTaskId = (taskId) -> + _.where steps, {task_id: taskId} + +quickLoad = (stepId, data) -> + steps[stepId] = data + channel.emit("quickLoad.#{stepId}", {data}) + +cacheFreeResponse = (stepId, freeResponse) -> + freeResponseCache[stepId] = freeResponse + +uncachedFreeResponse = (stepId) -> + delete freeResponseCache[stepId] if freeResponseCache[stepId]? + +load = (stepId, data) -> + steps[stepId] = data + channel.emit("load.#{stepId}", {data}) + +update = (eventData) -> + {data} = eventData + load(data.id, data) + +fetch = (stepId) -> + eventData = {data: {id: stepId}, status: 'loading'} + + channel.emit("fetch.#{stepId}", eventData) + api.channel.emit("exercise.#{stepId}.send.fetch", eventData) + +getCurrentPanel = (stepId) -> + panel = 'review' + + step = steps[stepId] + question = step?.content?.questions?[0] + return panel unless question? + + {formats} = question + + _.find STEP_TYPES, (stepChecks, format) -> + return false unless format in formats + isStepCompleted = _.reduce stepChecks, (isOtherCompleted, currentCheck) -> + step[currentCheck]? and step[currentCheck] and isOtherCompleted + , true + + unless isStepCompleted + panel = format + true + + panel + +get = (stepId) -> + uncachedFreeResponse(stepId) if steps[stepId].free_response? + steps[stepId].cachedFreeResponse = freeResponseCache[stepId] + steps[stepId] + +getAllParts = (stepId) -> + step = steps[stepId] + {task_id, content_url} = step + + stepsForTask = getStepsByTaskId(task_id) + + parts = _.filter stepsForTask, (part) -> + part.is_in_multipart and part.content_url is content_url + + parts = [step] if _.isEmpty(parts) + + _.map parts, (part) -> + get(part.id) + +init = -> + user.channel.on 'logout.received', -> + steps = {} + + api.channel.on("exercise.*.receive.*", update) + +module.exports = {fetch, getCurrentPanel, get, getAllParts, init, channel, quickLoad, cacheFreeResponse} diff --git a/coach/src/exercise/index.cjsx b/coach/src/exercise/index.cjsx new file mode 100644 index 0000000000..743c19ef2e --- /dev/null +++ b/coach/src/exercise/index.cjsx @@ -0,0 +1,113 @@ +React = require 'react' +_ = require 'underscore' +{Exercise, ChapterSectionMixin} = require 'shared' + +{channel, getCurrentPanel} = exercises = require './collection' +tasks = require '../task/collection' +api = require '../api' +{Reactive} = require '../reactive' +apiChannelName = 'exercise' + +ExerciseBase = React.createClass + displayName: 'ExerciseBase' + getInitialState: -> + @getStepState() + + mixins: [ ChapterSectionMixin ] + + getStepState: (props) -> + props ?= @props + {item} = props + + step: _.last(item) + parts: item + + componentWillReceiveProps: (nextProps) -> + nextState = @getStepState(nextProps) + + @setState(nextState) + + componentDidUpdate: (prevProps, prevState) -> + {status} = @props + {step} = @state + + channel.emit("component.#{status}", status: status, step: step) + + contextTypes: + processHtmlAndMath: React.PropTypes.func + + renderHelpLink: (sections) -> + if not sections?.length then return + + section = _.first(sections) +
+ Comes from {@sectionFormat(section.chapter_section)} - {section.title} +
+ + render: -> + {step} = @state + {taskId} = @props + return null if _.isEmpty(step) + + exerciseProps = + project: 'concept-coach' + taskId: step.task_id + parts: [step] + getCurrentPanel: getCurrentPanel + canReview: true + freeResponseValue: step.cachedFreeResponse + helpLink: @renderHelpLink(step.related_content) + + setAnswerId: (id, answerId) -> + step.answer_id = answerId + eventData = change: step, data: step, status: 'saving' + + channel.emit("change.#{step.id}", eventData) + api.channel.emit("exercise.#{step.id}.send.save", eventData) + + setFreeResponseAnswer: (id, freeResponse) -> + step.free_response = freeResponse + eventData = change: step, data: step, status: 'saving' + channel.emit("change.#{step.id}", eventData) + api.channel.emit("exercise.#{step.id}.send.save", eventData) + + onFreeResponseChange: (id, freeResponse) -> + exercises.cacheFreeResponse(id, freeResponse) + + onContinue: -> + step.is_completed = true + eventData = change: step, data: step, status: 'loading' + + channel.emit("change.#{step.id}", eventData) + api.channel.emit("exercise.#{step.id}.send.complete", eventData) + + onStepCompleted: -> + channel.emit("completed.#{step.id}") + + onNextStep: -> + channel.emit("leave.#{step.id}") + + if taskId? + wrapperProps = + 'data-step-number': tasks.getStepIndex(taskId, step.id) + 1 + + htmlAndMathProps = _.pick(@context, 'processHtmlAndMath') + +
+ +
+ +ExerciseStep = React.createClass + displayName: 'ExerciseStep' + render: -> + {id} = @props + + + + + +module.exports = {ExerciseStep, channel} diff --git a/coach/src/helpers/index.coffee b/coach/src/helpers/index.coffee new file mode 100644 index 0000000000..924989a14f --- /dev/null +++ b/coach/src/helpers/index.coffee @@ -0,0 +1,11 @@ +React = require 'react' +_ = require 'underscore' + +helpers = + + wrapComponent: (component) -> + render: (DOMNode, props = {}) -> + React.render React.createElement(component, props), DOMNode + unmountFrom: React.unmountComponentAtNode + +module.exports = helpers diff --git a/coach/src/navigation/course-name.cjsx b/coach/src/navigation/course-name.cjsx new file mode 100644 index 0000000000..9974b7631c --- /dev/null +++ b/coach/src/navigation/course-name.cjsx @@ -0,0 +1,19 @@ +React = require 'react' +_ = require 'underscore' + +classnames = require 'classnames' + +CourseNameBase = React.createClass + displayName: 'CourseNameBase' + getDefaultProps: -> + course: {} + render: -> + {course, className} = @props + + classes = classnames 'concept-coach-course-name', className + + + {course.description?()} + + +module.exports = {CourseNameBase} diff --git a/coach/src/navigation/index.cjsx b/coach/src/navigation/index.cjsx new file mode 100644 index 0000000000..19a9cdf7d8 --- /dev/null +++ b/coach/src/navigation/index.cjsx @@ -0,0 +1,73 @@ +React = require 'react' +BS = require 'react-bootstrap' +{CloseButton} = require 'shared' +{CourseNameBase} = require './course-name' + +Course = require '../course/model' +user = require '../user/model' +{channel} = require './model' +api = require '../api' +UserMenu = require '../user/menu' +{NotificationsBar} = require 'shared' + +Navigation = React.createClass + displayName: 'Navigation' + + contextTypes: + close: React.PropTypes.func + view: React.PropTypes.string + + propTypes: + course: React.PropTypes.instanceOf(Course) + + componentWillMount: -> + user.ensureStatusLoaded() + user.channel.on('change', @update) + + componentWillUnmount: -> + user.channel.off('change', @update) + + update: -> + @forceUpdate() if @isMounted() + + close: -> + @context.close?() + + handleSelect: (selectedKey) -> + channel.emit("show.#{selectedKey}", view: selectedKey) if selectedKey? + + render: -> + {course} = @props + {view} = @context + + brand = [ + Concept Coach + + ] + + courseItems = [ + + My Progress + + ] if course?.isRegistered() + + + + + + {courseItems} + + Close + + + + + + +module.exports = {Navigation, channel} diff --git a/coach/src/navigation/loader.coffee b/coach/src/navigation/loader.coffee new file mode 100644 index 0000000000..f794c1d269 --- /dev/null +++ b/coach/src/navigation/loader.coffee @@ -0,0 +1,14 @@ +_ = require 'underscore' +interpolate = require 'interpolate' + +makeViewSettings = (viewOptions, routePattern, view) -> + viewOptions = _.extend({}, viewOptions, {view}) + route = interpolate(routePattern, viewOptions) + + {state: {view}, route} + +loader = (model, viewSettings) -> + viewOptions = _.pick(model, 'prefix', 'base') + model.views = _.mapObject viewSettings, _.partial(makeViewSettings, viewOptions) + +module.exports = {loader} diff --git a/coach/src/navigation/model.coffee b/coach/src/navigation/model.coffee new file mode 100644 index 0000000000..a21f6a7128 --- /dev/null +++ b/coach/src/navigation/model.coffee @@ -0,0 +1,27 @@ +_ = require 'underscore' +EventEmitter2 = require 'eventemitter2' + +settings = require './settings' +{loader} = require './loader' + +navigation = {} +channel = new EventEmitter2 wildcard: true + +initialize = (options) -> + _.extend(navigation, options) + + loader(navigation, settings.views) + +getDataByView = (view) -> + navigation.views[view] + +getViewByRoute = (route) -> + navData = _.findWhere navigation.views, {route} + view = navData?.state?.view + + if view? + view = 'task' if view is 'default' + + view + +module.exports = {channel, initialize, getDataByView, getViewByRoute} diff --git a/coach/src/navigation/settings.coffee b/coach/src/navigation/settings.coffee new file mode 100644 index 0000000000..8d339cea29 --- /dev/null +++ b/coach/src/navigation/settings.coffee @@ -0,0 +1,18 @@ +DEFAULT_PATTERN = '{prefix}{base}{view}' + +settings = + views: + profile: DEFAULT_PATTERN + prevalidate: DEFAULT_PATTERN + dashboard: DEFAULT_PATTERN + task: DEFAULT_PATTERN + registration: DEFAULT_PATTERN + progress: DEFAULT_PATTERN + loading: DEFAULT_PATTERN + login: DEFAULT_PATTERN + student_id: DEFAULT_PATTERN + logout: DEFAULT_PATTERN + default: '{prefix}{base}' + close: '{prefix}' + +module.exports = settings diff --git a/coach/src/progress/chapter.cjsx b/coach/src/progress/chapter.cjsx new file mode 100644 index 0000000000..78111f9221 --- /dev/null +++ b/coach/src/progress/chapter.cjsx @@ -0,0 +1,49 @@ +React = require 'react' +_ = require 'underscore' +classnames = require 'classnames' + +{ChapterSectionMixin} = require 'shared' +{PageProgress} = require './page' + +ChapterProgress = React.createClass + displayName: 'ChapterProgress' + propTypes: + maxLength: React.PropTypes.number + className: React.PropTypes.string + chapter: React.PropTypes.shape( + chapter_section: React.PropTypes.array + pages: React.PropTypes.arrayOf(React.PropTypes.object) + ) + + getDefaultProps: -> + chapter: {} + mixins: [ChapterSectionMixin] + render: -> + {chapter, className, maxLength} = @props + return null unless chapter.pages?.length > 0 + + classes = classnames 'concept-coach-progress-chapter', className + section = @sectionFormat(chapter.chapter_section) + + sectionProps = + className: 'chapter-section-prefix' + sectionProps['data-section'] = section if section? + + pages = _.map chapter.pages, (page) -> + + + title =

+ {chapter.title} +

if chapter.title? + +
+ {title} +
    + {pages} +
+
+ +module.exports = {ChapterProgress} diff --git a/coach/src/progress/collection.coffee b/coach/src/progress/collection.coffee new file mode 100644 index 0000000000..d27ca032dd --- /dev/null +++ b/coach/src/progress/collection.coffee @@ -0,0 +1,46 @@ +EventEmitter2 = require 'eventemitter2' +_ = require 'underscore' +api = require '../api' + +local = {} +channel = new EventEmitter2 wildcard: true + +apiChannelName = 'courseDashboard' + +load = (id, data) -> + local[id] = data + channel.emit("load.#{id}", {data}) + +update = (eventData) -> + {data} = eventData + load(data.id, data) + +fetch = (id) -> + eventData = {data: {id: id}, status: 'loading'} + + channel.emit("fetch.#{id}", eventData) + api.channel.emit("#{apiChannelName}.#{id}.send.fetch", eventData) + +get = (id) -> + local[id] + +getFilteredChapters = (id, uuids = []) -> + progresses = get(id) + return unless progresses? + {chapters} = progresses + + _.chain(chapters) + .map((chapter) -> + chapter.pages = _.reject(chapter.pages, (page) -> + _.indexOf(uuids, page.uuid) > -1 + ) + return null if _.isEmpty chapter.pages + chapter + ) + .compact() + .value() + +init = -> + api.channel.on("#{apiChannelName}.*.receive.*", update) + +module.exports = {fetch, get, getFilteredChapters, init, channel} diff --git a/coach/src/progress/current.cjsx b/coach/src/progress/current.cjsx new file mode 100644 index 0000000000..6e274c5fa6 --- /dev/null +++ b/coach/src/progress/current.cjsx @@ -0,0 +1,59 @@ +React = require 'react' +_ = require 'underscore' +classnames = require 'classnames' + +{channel} = tasks = require '../task/collection' +{Reactive} = require '../reactive' +apiChannelName = 'task' + +{ChapterProgress} = require './chapter' + +CurrentProgressBase = React.createClass + displayName: 'CurrentProgressBase' + render: -> + {item, taskId, maxLength, moduleUUID} = @props + task = item + return null unless task?.steps? + + page = tasks.getAsPage(taskId) + + chapter = + pages: [page] + + + +CurrentProgress = React.createClass + displayName: 'CurrentProgress' + + contextTypes: + moduleUUID: React.PropTypes.string + collectionUUID: React.PropTypes.string + + filter: (props, eventData) -> + toCompare = ['collectionUUID', 'moduleUUID'] + + setProps = _.pick(props, toCompare) + receivedData = _.pick(eventData.data, toCompare) + + _.isEqual(setProps, receivedData) + + render: -> + {collectionUUID, moduleUUID} = @context + taskId = "#{collectionUUID}/#{moduleUUID}" + + + + + +module.exports = {CurrentProgress} diff --git a/coach/src/progress/exercise.cjsx b/coach/src/progress/exercise.cjsx new file mode 100644 index 0000000000..7e19dd92a5 --- /dev/null +++ b/coach/src/progress/exercise.cjsx @@ -0,0 +1,23 @@ +React = require 'react' +classnames = require 'classnames' + +ExerciseProgress = React.createClass + displayName: 'ExerciseProgress' + propTypes: + className: React.PropTypes.string + exercise: React.PropTypes.shape( + is_completed: React.PropTypes.bool + is_correct: React.PropTypes.bool + ) + getDefaultProps: -> + exercise: {} + render: -> + {exercise, className} = @props + + classes = classnames 'concept-coach-progress-exercise', className, + 'is-completed': exercise.is_completed + 'is-correct': exercise.is_correct + +
+ +module.exports = {ExerciseProgress} diff --git a/coach/src/progress/index.cjsx b/coach/src/progress/index.cjsx new file mode 100644 index 0000000000..e27a15aef3 --- /dev/null +++ b/coach/src/progress/index.cjsx @@ -0,0 +1,73 @@ +React = require 'react' +_ = require 'underscore' +classnames = require 'classnames' + +{ChapterSectionMixin} = require 'shared' +{Reactive} = require '../reactive' +{ExerciseButton} = require '../buttons' +{SectionProgress} = require './section' +{ChapterProgress} = require './chapter' +{CurrentProgress} = require './current' +{channel} = progresses = require './collection' +tasks = require '../task/collection' + +apiChannelName = 'courseDashboard' + +ProgressBase = React.createClass + displayName: 'ProgressBase' + getDefaultProps: -> + item: {} + contextTypes: + moduleUUID: React.PropTypes.string + collectionUUID: React.PropTypes.string + render: -> + {item, className, status} = @props + {moduleUUID, collectionUUID} = @context + + chapters = item + classes = classnames 'concept-coach-student-dashboard', className + + currentTask = tasks.get("#{collectionUUID}/#{moduleUUID}") + maxExercises = _.chain(chapters) + .pluck('pages') + .flatten() + .pluck('exercises') + .max((exercises) -> + exercises.length + ) + .value() + + maxLength = Math.max(maxExercises?.length or 0, currentTask?.steps?.length or 0) + + progress = _.map chapters, (chapter) -> + + +
+ + + + + {progress} + +
+ +Progress = React.createClass + displayName: 'Progress' + contextTypes: + moduleUUID: React.PropTypes.string + render: -> + {id} = @props + {moduleUUID} = @context + + + + + +module.exports = {Progress, ProgressBase, channel} diff --git a/coach/src/progress/page.cjsx b/coach/src/progress/page.cjsx new file mode 100644 index 0000000000..71830f0da8 --- /dev/null +++ b/coach/src/progress/page.cjsx @@ -0,0 +1,59 @@ +React = require 'react' +_ = require 'underscore' +dateFormat = require 'dateformat' +classnames = require 'classnames' +EventEmitter2 = require 'eventemitter2' + +{ChapterSectionMixin, ResizeListenerMixin} = require 'shared' +{ExerciseProgress} = require './exercise' + +PageProgress = React.createClass + displayName: 'PageProgress' + getDefaultProps: -> + page: {} + dateFormatString: 'mmm. d' + progressWidth: 30 + progressMargin: 5 + dateBuffer: 100 + + contextTypes: + navigator: React.PropTypes.instanceOf(EventEmitter2) + + mixins: [ChapterSectionMixin, ResizeListenerMixin] + switchModule: (data) -> + @context.navigator.emit('switch.module', {data, view: 'task'}) + render: -> + {page, dateFormatString, dateBuffer, maxLength, progressWidth, progressMargin, className} = @props + {componentEl} = @state + + exercisesProgressWidth = maxLength * progressWidth + (maxLength - 1) * progressMargin + titleWidth = componentEl.width - exercisesProgressWidth - dateBuffer + + classes = classnames 'concept-coach-progress-page', className + section = @sectionFormat(page.chapter_section) + pageLastWorked = dateFormat(new Date(page.last_worked_at), dateFormatString) if page.last_worked_at? + + sectionProps = + className: 'chapter-section-prefix' + sectionProps['data-section'] = section if section? + + exercises = _.map page.exercises, (exercise) -> + + +
  • +

    +
    + {page.title} +
    +

    + {pageLastWorked} +
    + {exercises} +
    +
  • + +module.exports = {PageProgress} diff --git a/coach/src/progress/section.cjsx b/coach/src/progress/section.cjsx new file mode 100644 index 0000000000..3fafbe9379 --- /dev/null +++ b/coach/src/progress/section.cjsx @@ -0,0 +1,22 @@ +React = require 'react' +classnames = require 'classnames' +_ = require 'underscore' + +SectionProgress = React.createClass + displayName: 'SectionProgress' + getDefaultProps: -> + progress: null + title: 'Progress' + render: -> + {progress, title, children, className} = @props + progress ?= children + return null if _.isEmpty(progress) + + classes = classnames 'concept-coach-progress-section', className + +
    +

    {title}

    + {progress} +
    + +module.exports = {SectionProgress} diff --git a/coach/src/reactive/index.cjsx b/coach/src/reactive/index.cjsx new file mode 100644 index 0000000000..2f8baa579f --- /dev/null +++ b/coach/src/reactive/index.cjsx @@ -0,0 +1,117 @@ +React = require 'react/addons' +classnames = require 'classnames' +api = require '../api' +_ = require 'underscore' + +interpolate = require 'interpolate' + +Reactive = React.createClass + displayName: 'Reactive' + + propTypes: + children: React.PropTypes.node.isRequired + store: React.PropTypes.object.isRequired + topic: React.PropTypes.string.isRequired + apiChannelPattern: React.PropTypes.string + channelUpdatePattern: React.PropTypes.string + apiChannelName: React.PropTypes.string + fetcher: React.PropTypes.func + filter: React.PropTypes.func + getStatusMessage: React.PropTypes.func + getter: React.PropTypes.func + + getDefaultProps: -> + apiChannelPattern: '{apiChannelName}.{topic}.send.*' + channelUpdatePattern: 'load.*' + + getInitialState: -> + {channelUpdatePattern, apiChannelPattern} = @props + + state = @getState() + state.status = 'loading' + + state.storeChannelUpdate = interpolate(channelUpdatePattern, @props) + state.apiChannelSend = interpolate(apiChannelPattern, @props) + + state + + fetchModel: (props) -> + props ?= @props + {topic, store, fetcher} = props + + if _.isFunction(fetcher) then fetcher(props) else store.fetch(topic) + + getState: (eventData = {}, props) -> + props ?= @props + {topic, store, getter} = props + {status} = eventData + status ?= 'loaded' + + errors = eventData?.data?.errors + + item: getter?(topic) or store.get?(topic) + status: status + errors: errors + + isForThisComponent: (eventData, props) -> + props ?= @props + {topic, filter} = props + + eventData.errors? or filter?(props, eventData) or eventData?.data?.id is topic or eventData?.data?.topic is topic + + update: (eventData, props) -> + props ?= @props + return unless @isForThisComponent(eventData, props) + + nextState = @getState(eventData, props) + @setState(nextState) + + setStatus: (eventData) -> + return unless @isForThisComponent(eventData) + + {status} = eventData + @setState({status}) + + componentWillMount: -> + {store} = @props + {storeChannelUpdate, apiChannelSend} = @state + + @fetchModel() + store.channel.on(storeChannelUpdate, @update) + api.channel.on(apiChannelSend, @setStatus) + + componentWillUnmount: -> + {topic, store} = @props + {storeChannelUpdate, apiChannelSend} = @state + + store.channel.off(storeChannelUpdate, @update) + api.channel.off(apiChannelSend, @setStatus) + + componentWillReceiveProps: (nextProps) -> + if nextProps.topic isnt @props.topic + stubDataForImmediateUpdate = + data: + id: nextProps.topic + status: 'cached' + + @update(stubDataForImmediateUpdate, nextProps) + @fetchModel(nextProps) + + render: -> + {status, item} = @state + {className} = @props + + classes = classnames 'reactive', "reactive-#{status}", className, + 'is-empty': _.isEmpty(item) + + propsForChildren = _.pick(@state, 'status', 'item', 'errors') + + reactiveItems = React.Children.map(@props.children, (child) -> + React.addons.cloneWithProps(child, propsForChildren) + ) + +
    + {reactiveItems} +
    + +module.exports = {Reactive} diff --git a/coach/src/task/collection.coffee b/coach/src/task/collection.coffee new file mode 100644 index 0000000000..a36e252f8a --- /dev/null +++ b/coach/src/task/collection.coffee @@ -0,0 +1,118 @@ +EventEmitter2 = require 'eventemitter2' +interpolate = require 'interpolate' +_ = require 'underscore' +api = require '../api' +exercises = require '../exercise/collection' + +tasks = {} + +user = require '../user/model' + +channel = new EventEmitter2 wildcard: true + +ERRORS_TO_SILENCE = ['page_has_no_exercises'] + +getUnhandledErrors = (errors) -> + otherErrors = _.reject errors, (error) -> + _.indexOf(ERRORS_TO_SILENCE, error.code) > -1 + +handledAllErrors = (otherErrors) -> + _.isEmpty otherErrors + +checkFailure = (response) -> + if response.data?.errors + response.data.errors = getUnhandledErrors(response.data.errors) + response.stopErrorDisplay = handledAllErrors(response.data.errors) + +load = (taskId, data) -> + tasks[taskId] = data + + status = if not data or data.errors? then 'failed' else 'loaded' + + _.each data?.steps, (step) -> + exercises.quickLoad(step.id, step) + + channel.emit("load.#{taskId}", {data, status}) + +update = (eventData) -> + return unless eventData? + {data, query} = eventData + load(query, data) + +fetch = (taskId) -> + eventData = {data: {id: taskId}, status: 'loading'} + eventData.query = taskId + + channel.emit("fetch.#{taskId}", eventData) + api.channel.emit("task.#{taskId}.send.fetch", eventData) + +fetchByModule = ({collectionUUID, moduleUUID}) -> + eventData = {data: {collectionUUID, moduleUUID}, status: 'loading'} + eventData.query = "#{collectionUUID}/#{moduleUUID}" + + channel.emit("fetch.#{collectionUUID}/#{moduleUUID}", eventData) + api.channel.emit("task.#{collectionUUID}/#{moduleUUID}.send.fetchByModule", eventData) + +get = (taskId) -> + tasks[taskId] + +getCompleteSteps = (taskId) -> + _.filter(tasks[taskId]?.steps, (step) -> + step? and step.is_completed + ) + +getIncompleteSteps = (taskId) -> + _.filter(tasks[taskId]?.steps, (step) -> + step? and not step.is_completed + ) + +getFirstIncompleteIndex = (taskId) -> + _.max [_.findIndex(tasks[taskId]?.steps, {is_completed: false}), 0] + +getStepIndex = (taskId, stepId) -> + _.findIndex(tasks[taskId]?.steps, id: stepId) + +getModuleInfo = (taskId, cnxUrl = '') -> + task = tasks[taskId] + return unless task? + + moduleUrlPattern = '{cnxUrl}/contents/{collectionUUID}:{moduleUUID}' + {collectionUUID, moduleUUID} = task + + moduleInfo = _.clone(task.steps?[0].related_content?[0]) or {} + _.extend moduleInfo, _.pick(task, 'collectionUUID', 'moduleUUID') + moduleInfo.link = interpolate moduleUrlPattern, {cnxUrl, collectionUUID, moduleUUID} + + moduleInfo + +getAsPage = (taskId) -> + task = get(taskId) + {moduleUUID, steps} = task + + page = _.pick task, 'last_worked_at', 'id' + _.extend page, _.first(_.first(steps).related_content) + page.exercises = steps + page.uuid = moduleUUID + + page + +init = -> + user.channel.on 'logout.received', -> + tasks = {} + api.channel.on("task.*.receive.*", update) + api.channel.on('task.*.receive.failure', checkFailure) + +module.exports = { + init, + load, + fetch, + fetchByModule, + get, + getCompleteSteps, + getIncompleteSteps, + getFirstIncompleteIndex, + getStepIndex, + getModuleInfo, + getAsPage, + channel +} diff --git a/coach/src/task/index.cjsx b/coach/src/task/index.cjsx new file mode 100644 index 0000000000..d074f8c660 --- /dev/null +++ b/coach/src/task/index.cjsx @@ -0,0 +1,172 @@ +React = require 'react' +EventEmitter2 = require 'eventemitter2' +_ = require 'underscore' +classnames = require 'classnames' +{SpyMode} = require 'shared' + +{channel} = tasks = require './collection' +api = require '../api' +{Reactive} = require '../reactive' +apiChannelName = 'task' + +exercises = {ExerciseStep} = require '../exercise' +breadcrumbs = {Breadcrumbs} = require '../breadcrumbs' + +{TaskReview} = require './review' +{TaskTitle} = require './title' +{NoExercises} = require './no-exercises' + +TaskBase = React.createClass + displayName: 'TaskBase' + getInitialState: -> + {item} = @props + + task: item + currentStep: 0 + steps: @setupSteps(item) + + contextTypes: + close: React.PropTypes.func + navigator: React.PropTypes.instanceOf(EventEmitter2) + + setupSteps: (task) -> + steps = _.keys(task?.steps) + steps.push('summary') + steps.push('continue') + + steps + + goToStep: (stepIndex) -> + @setState(currentStep: stepIndex) if @isStepAllowed(stepIndex) + + nextStep: -> + {currentStep} = @state + @goToStep(currentStep + 1) + + goToFirstIncomplete: -> + {taskId} = @props + stepIndex = tasks.getFirstIncompleteIndex(taskId) + @goToStep(stepIndex) + + isStepAllowed: (stepIndex) -> + @isExerciseStep(stepIndex) or + (@isReviewStep(stepIndex) and @canReview()) or + (@isContinueStep(stepIndex) and @shouldContinue()) + + isExerciseStep: (stepIndex) -> + {task} = @state + stepIndex < task.steps.length + + canReview: -> + {taskId} = @props + not _.isEmpty tasks.getCompleteSteps(taskId) + + shouldContinue: -> + {taskId} = @props + _.isEmpty tasks.getIncompleteSteps(taskId) + + isReviewStep: (stepIndex) -> + {steps} = @state + steps[stepIndex] is 'summary' + + isContinueStep: (stepIndex) -> + {steps} = @state + steps[stepIndex] is 'continue' + + fetchTask: -> + tasks.fetchByModule(@props) + + componentWillMount: -> + api.channel.on('exercise.*.receive.complete', @fetchTask) + exercises.channel.on('leave.*', @nextStep) + + componentWillUnmount: -> + api.channel.off('exercise.*.receive.complete', @fetchTask) + exercises.channel.off('leave.*', @nextStep) + + componentWillReceiveProps: (nextProps) -> + nextState = + task: nextProps.item + steps: @setupSteps(nextProps.item) + + if (_.isEmpty(@props.item) and not _.isEmpty(nextProps.item)) or + (@props.taskId isnt nextProps.taskId) + stepIndex = tasks.getFirstIncompleteIndex(nextProps.taskId) + nextState.currentStep = stepIndex + + @setState(nextState) + + componentDidUpdate: -> + {currentStep, steps} = @state + {close, navigator} = @context + + step = steps[currentStep] + navigator.emit('show.task', {view: 'task', step: step}) + + close() if @isContinueStep(currentStep) + + render: -> + {task, currentStep} = @state + {taskId} = @props + return null unless task? + + breadcrumbs = + + noExercises = not task.steps? or _.isEmpty(task.steps) + + if noExercises + panel = + else if task.steps[currentStep]? + panel = + else if @isReviewStep(currentStep) + panel = + else if @isContinueStep(currentStep) + panel = null + + taskClasses = classnames 'concept-coach-task', + 'card-body': noExercises + +
    + + {breadcrumbs} + {panel} + {JSON.stringify(task.spy)} +
    + + +Task = React.createClass + displayName: 'Task' + filter: (props, eventData) -> + toCompare = ['collectionUUID', 'moduleUUID'] + + setProps = _.pick(props, toCompare) + receivedData = _.pick(eventData.data, toCompare) + + _.isEqual(setProps, receivedData) + + render: -> + {collectionUUID, moduleUUID} = @props + taskId = "#{collectionUUID}/#{moduleUUID}" + + + + + + + +module.exports = {Task, channel} diff --git a/coach/src/task/no-exercises.cjsx b/coach/src/task/no-exercises.cjsx new file mode 100644 index 0000000000..126a8a2360 --- /dev/null +++ b/coach/src/task/no-exercises.cjsx @@ -0,0 +1,10 @@ +React = require 'react' + +NoExercises = React.createClass + displayName: 'NoExercises' + render: -> +
    + Sorry, there are no exercises for this module. +
    + +module.exports = {NoExercises} diff --git a/coach/src/task/review.cjsx b/coach/src/task/review.cjsx new file mode 100644 index 0000000000..45b1ba7b9d --- /dev/null +++ b/coach/src/task/review.cjsx @@ -0,0 +1,100 @@ +React = require 'react' +BS = require 'react-bootstrap' +_ = require 'underscore' +tasks = require './collection' + +{ChapterSectionMixin} = require 'shared' +{ExerciseStep} = require '../exercise' +{ExerciseButton, ContinueToBookButton, ReturnToBookButton} = require '../buttons' + +ReviewControls = React.createClass + displayName: 'ReviewControls' + mixins: [ChapterSectionMixin] + + propTypes: + moduleUUID: React.PropTypes.string.isRequired + collectionUUID: React.PropTypes.string.isRequired + taskId: React.PropTypes.string.isRequired + + render: -> + {taskId, moduleUUID, collectionUUID} = @props + + moduleInfo = tasks.getModuleInfo(taskId) + section = @sectionFormat(moduleInfo.chapter_section) + + + + + + +TaskReview = React.createClass + displayName: 'TaskReview' + + propTypes: + moduleUUID: React.PropTypes.string.isRequired + collectionUUID: React.PropTypes.string.isRequired + + getInitialState: -> + @getSteps(@props) + + componentWillMount: -> + {collectionUUID, moduleUUID} = @props + tasks.fetchByModule({collectionUUID, moduleUUID}) + + componentWillReceiveProps: (nextProps) -> + @setState(@getSteps(nextProps)) + + getSteps: (props) -> + {taskId} = props + completeSteps: tasks.getCompleteSteps(taskId) + incompleteSteps: tasks.getIncompleteSteps(taskId) + + render: -> + {completeSteps, incompleteSteps} = @state + {status, taskId, moduleUUID, collectionUUID} = @props + + if _.isEmpty(completeSteps) + completeStepsReview =
    +

    Exercise to see Review

    + +
    + else + completeStepsReview = _.map completeSteps, (step) -> + + + if _.isEmpty(incompleteSteps) + completedMessage =
    +

    You're done.

    + +

    or review your work below.

    +
    + completedEnd =
    + +
    + +
    + {completedMessage} + {completeStepsReview} + {completedEnd} +
    + +module.exports = {TaskReview} diff --git a/coach/src/task/title.cjsx b/coach/src/task/title.cjsx new file mode 100644 index 0000000000..ab2f1adcc6 --- /dev/null +++ b/coach/src/task/title.cjsx @@ -0,0 +1,50 @@ +React = require 'react' +_ = require 'underscore' +classnames = require 'classnames' +tasks = require './collection' + +{ChapterSectionMixin} = require 'shared' +{GoToBookLink} = require '../buttons' + +TaskTitle = React.createClass + displayName: 'TaskTitle' + mixins: [ChapterSectionMixin] + + contextTypes: + close: React.PropTypes.func + moduleUUID: React.PropTypes.string + collectionUUID: React.PropTypes.string + + render: -> + {taskId, cnxUrl} = @props + {close} = @context + moduleInfo = tasks.getModuleInfo(taskId, cnxUrl) + return null unless moduleInfo + + section = @sectionFormat(moduleInfo.chapter_section) + + sectionProps = + className: 'chapter-section-prefix' + sectionProps['data-section'] = section if section? + + linkProps = _.pick(@props, 'collectionUUID', 'moduleUUID') + linkProps.role = 'button' + linkProps.link = moduleInfo.link + + if moduleInfo.title + linkProps.target = '_blank' + title =

    + {moduleInfo.title} +

    + + titleClasses = classnames 'concept-coach-title', + 'has-title': moduleInfo.title? + +
    + {title} + + + +
    + +module.exports = {TaskTitle} diff --git a/coach/src/user/accounts-iframe.cjsx b/coach/src/user/accounts-iframe.cjsx new file mode 100644 index 0000000000..7c1f8d5c7c --- /dev/null +++ b/coach/src/user/accounts-iframe.cjsx @@ -0,0 +1,88 @@ +# coffeelint: disable=no_empty_functions + +React = require 'react' +classnames = require 'classnames' + +api = require '../api' +User = require './model' + +AccountsIframe = React.createClass + + propTypes: + type: React.PropTypes.oneOf(['logout', 'profile']).isRequired + + getInitialState: -> + width: '100%', height: 400, isClosable: @props.type is "profile" + + pageLoad: (page) -> + if page is "/login" + if User.isLoggingOut # we've logged out and are re-displaying login + User._signalLogoutCompleted() + @setState(isClosable: false) + else # we're displaying a profile or settings related page + if User.isLoggedIn() + @setState(isClosable: true) + else + @setState(isClosable: false) + # somehow we're displaying the profile page but we don't know we're logged in? + if page is "/profile" + # redisplaying the login page so we can pickup the login info + @sendCommand('displayLogin', User.endpoints.iframe_login) + + # Note: we're currently not doing anything with the width because we want that to stay at 100% + pageResize: ({width, height}) -> + @setState(height: height) + + setTitle: (title) -> + @setState(title: title) + + iFrameReady: -> + @sendCommand('displayProfile') + + # called when an logout process completes + logoutComplete: (success) -> + return unless success + User._signalLogoutCompleted() + + sendCommand: (command, payload = {}) -> + msg = JSON.stringify(data: {"#{command}": payload}) + React.findDOMNode(@refs.iframe).contentWindow.postMessage(msg, '*') + + parseAndDispatchMessage: (msg) -> + return unless @isMounted() + try + json = JSON.parse(msg.data) + for method, payload of json.data + if @[method] + @[method](payload) + else + console.warn?("Received message for unsupported #{method}") + catch error + console.warn(error) + + componentWillUnmount: -> + window.removeEventListener('message', @parseAndDispatchMessage) + componentWillMount: -> + window.addEventListener('message', @parseAndDispatchMessage) + + + render: -> + # the other side of the iframe will validate our address and then only send messages to it + me = window.location.protocol + '//' + window.location.host + url = if @props.type is 'logout' then User.endpoints.logout else User.endpoints.accounts_iframe + url = "#{url}?parent=#{me}" + className = classnames( 'accounts-iframe', @props.type ) +
    +
    +

    {@state?.title}

    +
    + +
    + + + + +module.exports = AccountsIframe diff --git a/coach/src/user/login-gateway.cjsx b/coach/src/user/login-gateway.cjsx new file mode 100644 index 0000000000..104779be6d --- /dev/null +++ b/coach/src/user/login-gateway.cjsx @@ -0,0 +1,78 @@ +React = require 'react' +_ = require 'underscore' +User = require './model' +api = require '../api' +classnames = require 'classnames' + +SECOND = 1000 + +LoginGateway = React.createClass + + propTypes: + window: React.PropTypes.shape( + open: React.PropTypes.func + ) + onToggle: React.PropTypes.func + + getDefaultProps: -> + window: window + + getInitialState: -> + loginWindow: false + + openLogin: (ev) -> + ev.preventDefault() + + width = Math.min(1000, window.screen.width - 20) + height = Math.min(800, window.screen.height - 30) + options = ["toolbar=no", "location=" + (if @props.window.opera then "no" else "yes"), + "directories=no,status=no,menubar=no,scrollbars=yes,resizable=yes,copyhistory=no", + "width=" + width, "height=" + height, + "top=" + (window.screen.height - height) / 2, + "left=" + (window.screen.width - width) / 2].join() + loginWindow = @props.window.open(@urlForLogin(), 'oxlogin', options) + @setState({loginWindow}) + @props.onToggle?(loginWindow) + _.delay(@windowClosedCheck, SECOND) + + parseAndDispatchMessage: (msg) -> + return unless @isMounted() + try + data = JSON.parse(msg.data) + if data.user + api.channel.emit 'user.status.receive.fetch', data: data + @setState(loginWindow: false) # cancel checking for close + catch error + console.warn(error) + componentWillUnmount: -> + window.removeEventListener('message', @parseAndDispatchMessage) + componentWillMount: -> + window.addEventListener('message', @parseAndDispatchMessage) + + windowClosedCheck: -> + return unless @isMounted() + if @state.loginWindow and @state.loginWindow.closed + User.ensureStatusLoaded(true) + else + _.delay( @windowClosedCheck, SECOND) + + urlForLogin: -> + User.endpoints.login + '?parent=' + encodeURIComponent(window.location.href) + + renderOpenMessage: -> + + Please log in using your OpenStax account in the window. Click to reopen window. + + + render: -> + classes = classnames('login-gateway', @props.className, + 'is-open': @state.loginWindow + 'is-closed': not @state.loginWindow + ) +
    + {if @state.loginWindow then @renderOpenMessage() else @props.children} +
    + +module.exports = LoginGateway diff --git a/coach/src/user/menu.cjsx b/coach/src/user/menu.cjsx new file mode 100644 index 0000000000..b7941243f6 --- /dev/null +++ b/coach/src/user/menu.cjsx @@ -0,0 +1,84 @@ +React = require 'react' +BS = require 'react-bootstrap' +EventEmitter2 = require 'eventemitter2' +{CloseButton} = require 'shared' + +Status = require './status-mixin' + +Course = require '../course/model' + +api = require '../api' + +# TODO combine this with link in error notification +GET_HELP_LINK = 'http://openstax.force.com/support?l=en_US&c=Products%3AConcept_Coach' + +getWaitingText = (status) -> + "#{status}…" + +UserMenu = React.createClass + mixins: [Status] + + contextTypes: + close: React.PropTypes.func + navigator: React.PropTypes.instanceOf(EventEmitter2) + + propTypes: + course: React.PropTypes.instanceOf(Course) + + componentWillMount: -> + @getUser().ensureStatusLoaded() + + logoutUser: (clickEvent) -> + clickEvent.preventDefault() + @context.navigator.emit('show.logout', view: 'logout') + + showProfile: (clickEvent) -> + clickEvent.preventDefault() + @context.navigator.emit('show.profile', view: 'profile') + + updateStudentId: (clickEvent) -> + clickEvent.preventDefault() + @context.navigator.emit('show.student_id', view: 'student_id') + + update: -> + @forceUpdate() if @isMounted() + + close: (clickEvent) -> + clickEvent.preventDefault() + @context.close?() + + modifyCourse: (clickEvent) -> + clickEvent.preventDefault() + @context.navigator.emit('show.registration', view: 'registration') + + renderCourseOption: -> + if @props.course?.isRegistered() + courseChangeText = 'Change Section' + else + courseChangeText = 'Register for Section' + {courseChangeText} + + renderStudentIdOption: -> + return null unless @props.course?.isRegistered() + Change student ID + + render: -> + # The menu has no valid actions unless the useris logged in + user = @getUser() + return null unless user.isLoggedIn() + + {@renderCourseOption()} + Account Profile + {@renderStudentIdOption()} + + + Get Help + + Logout + + +module.exports = UserMenu diff --git a/coach/src/user/model.coffee b/coach/src/user/model.coffee new file mode 100644 index 0000000000..528d6b24a5 --- /dev/null +++ b/coach/src/user/model.coffee @@ -0,0 +1,106 @@ +_ = require 'underscore' +React = require 'react' +EventEmitter2 = require 'eventemitter2' +Course = require '../course/model' +api = require '../api' +{BootrapURLs, NotificationActions} = require 'shared' + +BLANK_USER = + is_admin: false + is_content_analyst: false + is_customer_service: false + name: null + profile_url: null + courses: [] + _course_data: [] + isLoaded: false + isLoggingOut: false + +User = + channel: new EventEmitter2 wildcard: true + + update: (data) -> + _.extend(this, data.user) + @_course_data = data.courses + pending = @validatedPendingCourses() + @courses = _.compact _.map data.courses, (course) -> + if course.is_concept_coach and _.detect(course.roles, (role) -> role.type is 'student') + new Course(course) + _.each pending, (course) => + @courses.push(course) + course.register(course.enrollment_code, @) + @channel.emit('change') + + validatedPendingCourses: -> + _.filter @courses, (course) -> course.isValidated() + + isTeacherForCourse: (collectionUUID) -> + course = _.findWhere @_course_data, ecosystem_book_uuid: collectionUUID + course and _.detect(course.roles, (role) -> role.type is 'teacher') + + status: (collectionUUID) -> + course = @getCourse(collectionUUID) + isLoggedIn: @isLoggedIn() + isLoaded: @isLoaded + isRegistered: !!course?.isRegistered() + preValidate: (not @isLoggedIn()) and (not course?.isValidated()) + + getCourse: (collectionUUID) -> + _.findWhere( @courses, ecosystem_book_uuid: collectionUUID ) + + registeredCourses: -> + _.filter @courses, (course) -> course.isRegistered() + + findOrCreateCourse: (collectionUUID) -> + @getCourse(collectionUUID) or ( + course = new Course(ecosystem_book_uuid: collectionUUID) + @courses.push(course) + course + ) + + ensureStatusLoaded: (force = false) -> + api.channel.emit('user.status.send.fetch') if force or not @isLoggedIn() + + isLoggedIn: -> + !!@profile_url + + onCourseUpdate: (course) -> + @channel.emit('change') + @ensureStatusLoaded(true) # re-fetch course list from server + + removeCourse: (course) -> + index = @courses.indexOf(course) + @courses.splice(index, 1) unless index is -1 + @channel.emit('change') + + _signalLogoutCompleted: -> + _.extend(this, BLANK_USER) + @isLoggingOut = true + @channel.emit('logout.received') + + init: -> + api.channel.on 'user.status.receive.*', ({data}) -> + User.isLoaded = true + + if data.access_token + api.channel.emit('set.access_token', data.access_token) + User.endpoints = data.endpoints + if data.user + BootrapURLs.update(data) + NotificationActions.startPolling() + User.update(data) + else + _.extend(this, BLANK_USER) + User.channel.emit('change') + + destroy: -> + User.channel.removeAllListeners() + _.invoke @courses, 'destroy' + @courses = [] + + +# start out as a blank user +_.extend(User, BLANK_USER) +User.endpoints = {} # this shoudn't be part of BLANK_USER so it persists between logins + +module.exports = User diff --git a/coach/src/user/status-mixin.coffee b/coach/src/user/status-mixin.coffee new file mode 100644 index 0000000000..8b81950885 --- /dev/null +++ b/coach/src/user/status-mixin.coffee @@ -0,0 +1,15 @@ +User = require './model' + +UserStatusMixin = { + + componentDidMount: -> + User.channel.on("change", @onUserChange) + componentWillUnmount: -> + User.channel.off("change", @onUserChange) + onUserChange: -> + @forceUpdate() if @isMounted() + getUser: -> + User +} + +module.exports = UserStatusMixin diff --git a/coach/test/all-tests.coffee b/coach/test/all-tests.coffee new file mode 100644 index 0000000000..9d011a8a5a --- /dev/null +++ b/coach/test/all-tests.coffee @@ -0,0 +1,2 @@ +testsContext = require.context("./", true, /\.spec\.(cjsx|coffee)$/) +testsContext.keys().forEach(testsContext) diff --git a/coach/test/api/error-notification.spec.cjsx b/coach/test/api/error-notification.spec.cjsx new file mode 100644 index 0000000000..c489e6cf93 --- /dev/null +++ b/coach/test/api/error-notification.spec.cjsx @@ -0,0 +1,36 @@ +{Testing, expect, sinon, _, React} = require 'shared/test/helpers' + +ErrorNotification = require 'concept-coach/error-notification' +api = require 'api' + +ContainedNotification = React.createClass + displayName: 'ContainedNotification' + render: -> +
    + +
    + +describe 'Error Notification', -> + + it 'does not render if there are no errors', -> + Testing.renderComponent( ContainedNotification ).then ({dom}) -> + expect(dom.querySelector('.errors')).to.be.null + + it 'renders exceptions', -> + Testing.renderComponent( ContainedNotification ).then ({dom, root}) -> + expect(dom.querySelector('.errors')).to.be.null + exception = new Error + api.channel.emit('error', {exception}) + expect(dom.querySelector('.errors')).to.not.be.null + + + it 'displays errors when button is clicked', -> + Testing.renderComponent( ContainedNotification ).then ({dom}) -> + exception = new Error("You have errors!") + api.channel.emit('error', {exception}) + + btn = dom.querySelector('.-display-errors') + expect(btn.textContent).equal('Show Details') + Testing.actions.click btn + expect(btn.textContent).equal('Hide Details') + expect(dom.querySelector('.errors-listing').textContent).equal('Error: You have errors!') diff --git a/coach/test/api/index.spec.coffee b/coach/test/api/index.spec.coffee new file mode 100644 index 0000000000..d77c22d523 --- /dev/null +++ b/coach/test/api/index.spec.coffee @@ -0,0 +1,33 @@ +{Testing, expect, sinon, _} = require 'shared/test/helpers' + +{loader, isPending} = require 'api/loader' +REAL_LOADER = loader + +describe 'API', -> + beforeEach -> + @loader = sinon.spy() + # make sure api's not already required + delete require.cache[require.resolve('api')] + # set up api/loader's exports to use our stubbed loader + require.cache[require.resolve('api/loader')].exports = {loader: @loader, isPending} + # and now require api, which will in turn require api/loader with our stub + @api = require 'api' + + afterEach -> + require.cache[require.resolve('api/loader')].exports = {loader: REAL_LOADER, isPending} + # force require to re-parse the api file now that the stub's removed + delete require.cache[require.resolve('api')] + @api.initialize('/') + + it 'only calls loader a single time', -> + @api.initialize('/') + expect(@loader.callCount).equal(1) + @api.initialize('/') + expect(@loader.callCount).equal(1) + + it 'can be re-initialized after destroy', -> + @api.initialize('/') + expect(@loader.callCount).equal(1) + @api.destroy() + @api.initialize('/') + expect(@loader.callCount).equal(2) diff --git a/coach/test/api/loader.spec.coffee b/coach/test/api/loader.spec.coffee new file mode 100644 index 0000000000..1bf8bc2108 --- /dev/null +++ b/coach/test/api/loader.spec.coffee @@ -0,0 +1,42 @@ +{Testing, expect, sinon, _} = require 'shared/test/helpers' + +$ = require 'jquery' + + +describe 'API loader', -> + beforeEach -> + @jquery = + ajax: sinon.spy -> + d = $.Deferred() + _.defer -> d.resolve({}) + d.promise() + delete require.cache[require.resolve('api')] + delete require.cache[require.resolve('api/loader')] + require.cache[require.resolve('jquery')].exports = @jquery + @api = require 'api' + @api.initialize('test/url') + + + afterEach -> + require.cache[require.resolve('jquery')].exports = $ + # force require to re-parse the api file now that the stub's removed + delete require.cache[require.resolve('api/loader')] + + it 'sets isPending', (done) -> + expect(@api.isPending()).to.be.false + @api.channel.emit('user.status.send.fetch', data: {}) + _.delay( => + expect(@api.isPending()).to.be.true + , 1) + _.delay => + expect(@api.isPending()).to.be.false + done() + , 50 # longer than loader's isLocal delay + + it 'debounces calls to the same URL', (done) -> + for i in [1..10] + @api.channel.emit('user.status.send.fetch', data: {}) + _.delay => + expect(@jquery.ajax.callCount).equal(1) + done() + , 50 diff --git a/coach/test/breadcrumbs/index.spec.coffee b/coach/test/breadcrumbs/index.spec.coffee new file mode 100644 index 0000000000..a2e6957192 --- /dev/null +++ b/coach/test/breadcrumbs/index.spec.coffee @@ -0,0 +1,23 @@ +{Testing, expect, sinon, _} = require 'shared/test/helpers' + +{Breadcrumbs} = require 'breadcrumbs' +TASK = require 'cc/tasks/C_UUID/m_uuid/GET' +tasks = require 'task/collection' + +describe 'Breadcrumbs Component', -> + beforeEach -> + @props = + goToStep: sinon.spy() + moduleUUID: 'm_uuid' + collectionUUID: 'C_UUID' + tasks.load("#{@props.collectionUUID}/#{@props.moduleUUID}", TASK) + + + it 'renders steps', -> + Testing.renderComponent( Breadcrumbs, props: @props ).then ({dom}) -> + expect(dom.querySelectorAll('.openstax-breadcrumbs-step:not(.breadcrumb-end)').length).equal(TASK.steps.length) + + it 'calls goToStep callback', -> + Testing.renderComponent( Breadcrumbs, props: @props ).then ({dom}) => + Testing.actions.click dom.querySelector('.openstax-breadcrumbs-step:first-child') + expect(@props.goToStep).to.have.been.called diff --git a/coach/test/buttons/index.coffee b/coach/test/buttons/index.coffee new file mode 100644 index 0000000000..8b3e16160f --- /dev/null +++ b/coach/test/buttons/index.coffee @@ -0,0 +1,21 @@ +{Testing, expect, sinon, _} = require 'shared/test/helpers' +React = require 'react' +{ExerciseButton} = require 'buttons' + +TestChildComponent = React.createClass + render: -> React.createElement('span', {}, 'i am a label') + +describe 'Exercise Button', -> + beforeEach -> + @props = + children: React.createElement(TestChildComponent) + onClick: sinon.spy() + + it 'renders child components', -> + Testing.renderComponent( ExerciseButton, props: @props ).then ({dom}) -> + expect(dom.textContent).equal('i am a label') + + it 'calls onClick callback', -> + Testing.renderComponent( ExerciseButton, props: @props ).then ({dom}) => + Testing.actions.click dom.querySelector('span') + expect(@props.onClick).to.have.been.called diff --git a/coach/test/concept-coach/base.spec.coffee b/coach/test/concept-coach/base.spec.coffee new file mode 100644 index 0000000000..8328b4797a --- /dev/null +++ b/coach/test/concept-coach/base.spec.coffee @@ -0,0 +1,22 @@ +{Testing, expect, sinon, _} = require 'shared/test/helpers' + +{ConceptCoach} = require 'concept-coach/base' +TASK = require 'cc/tasks/C_UUID/m_uuid/GET' +tasks = require 'task/collection' + +describe 'ConceptCoach base component', -> + beforeEach -> + @props = + close: sinon.spy() + moduleUUID: 'm_uuid' + collectionUUID: 'C_UUID' + tasks.load("#{@props.collectionUUID}/#{@props.moduleUUID}", TASK) + + it 'renders as loading by default', -> + Testing.renderComponent( ConceptCoach, props: @props ).then ({dom}) -> + expect(dom.textContent).to.contain('Loading ...') + + it 'calls close callback', -> + Testing.renderComponent( ConceptCoach, props: @props ).then ({dom}) => + Testing.actions.click(dom.querySelector('.concept-coach-dashboard-nav')) + expect(@props.close).to.have.been.called diff --git a/coach/test/concept-coach/coach.spec.coffee b/coach/test/concept-coach/coach.spec.coffee new file mode 100644 index 0000000000..b5eed6aca5 --- /dev/null +++ b/coach/test/concept-coach/coach.spec.coffee @@ -0,0 +1,22 @@ +{Testing, expect, sinon, _, ReactTestUtils} = require 'shared/test/helpers' + +{Coach} = require 'concept-coach/coach' +{CCModal} = require 'concept-coach/modal' + +describe 'Coach wrapper component', -> + beforeEach -> + @props = + open: false + displayLauncher: true + moduleUUID: 'm_uuid' + collectionUUID: 'C_UUID' + + it 'renders launch state', -> + Testing.renderComponent( Coach, props: @props ).then ({dom, element}) -> + expect(dom.textContent).to.contain('Launch Concept Coach') + expect(ReactTestUtils.scryRenderedComponentsWithType(element, CCModal)).to.be.empty + + it 'renders coach when open=true', -> + @props.open = true + Testing.renderComponent( Coach, props: @props ).then ({dom, element}) -> + expect(ReactTestUtils.scryRenderedComponentsWithType(element, CCModal)).not.to.be.empty diff --git a/coach/test/concept-coach/index.spec.coffee b/coach/test/concept-coach/index.spec.coffee new file mode 100644 index 0000000000..c50e51ee7f --- /dev/null +++ b/coach/test/concept-coach/index.spec.coffee @@ -0,0 +1,20 @@ +{Testing, expect, sinon, _} = require 'shared/test/helpers' + +API = require 'concept-coach' + +describe 'ConceptCoach API', -> + + beforeEach -> + @api = new API + + it 'renders views', -> + sinon.spy @api, 'updateToView' + @api.emit('show.view', view: 'test') + expect(@api.updateToView).to.have.been.calledWith('test') + + it 'sets only allowed props on component when updated', -> + @api.initialize document.createElement('div') + sinon.spy @api.component, 'setProps' + newProps = evilProp: true, moduleUUID: 'test' + @api.update(newProps) + expect(@api.component.setProps).to.have.been.calledWith(moduleUUID: 'test') diff --git a/coach/test/concept-coach/laptop-and-mug.spec.coffee b/coach/test/concept-coach/laptop-and-mug.spec.coffee new file mode 100644 index 0000000000..8184c9d6dd --- /dev/null +++ b/coach/test/concept-coach/laptop-and-mug.spec.coffee @@ -0,0 +1,12 @@ +{Testing, expect, sinon, _} = require 'shared/test/helpers' + +LaptopAndMug = require 'concept-coach/laptop-and-mug' + +describe 'Laptop and Mug SVG', -> + beforeEach -> + @props = {height: 42} + + it 'renders laptop and mug without errors', -> + Testing.renderComponent( LaptopAndMug, props: @props ).then ({dom}) -> + expect(dom.tagName).equal('svg') + expect(dom.getAttribute('height')).equal('42px') diff --git a/coach/test/concept-coach/launcher/background-and-desk.spec.coffee b/coach/test/concept-coach/launcher/background-and-desk.spec.coffee new file mode 100644 index 0000000000..fff7712381 --- /dev/null +++ b/coach/test/concept-coach/launcher/background-and-desk.spec.coffee @@ -0,0 +1,10 @@ +{Testing, expect, sinon, _} = require 'shared/test/helpers' + +BackgroundAndDesk = require 'concept-coach/launcher/background-and-desk' + + +describe 'Background with Desk', -> + + it 'renders background and desk without errors', -> + Testing.renderComponent( BackgroundAndDesk, props: @props ).then ({dom}) -> + expect(dom.tagName.toLowerCase()).equal('div') diff --git a/coach/test/concept-coach/launcher/index.spec.coffee b/coach/test/concept-coach/launcher/index.spec.coffee new file mode 100644 index 0000000000..d7a4776093 --- /dev/null +++ b/coach/test/concept-coach/launcher/index.spec.coffee @@ -0,0 +1,18 @@ +{Testing, expect, sinon, _} = require 'shared/test/helpers' + +{Launcher} = require 'concept-coach/launcher' +{channel} = require 'concept-coach/model' + +describe 'Launcher', -> + + it 'renders with launching status', -> + Testing.renderComponent( Launcher, props: {isLaunching: true} ).then ({dom}) -> + expect(dom.textContent).to.include('Launch Concept Coach') + expect(dom.querySelector('.concept-coach-launcher').classList.contains('launching')).to.be.true + + it 'emits launch action when clicked', -> + spy = sinon.spy() + channel.on('launcher.clicked', spy) + Testing.renderComponent( Launcher ).then ({dom}) -> + Testing.actions.click dom.querySelector('.concept-coach-launcher') + expect(spy).to.have.been.called diff --git a/coach/test/concept-coach/modal.spec.cjsx b/coach/test/concept-coach/modal.spec.cjsx new file mode 100644 index 0000000000..07ae82e5b9 --- /dev/null +++ b/coach/test/concept-coach/modal.spec.cjsx @@ -0,0 +1,52 @@ +{Testing, expect, sinon, _, React} = require 'shared/test/helpers' + +{CCModal} = require 'concept-coach/modal' +api = require 'api' + +describe 'CC Modal Component', -> + beforeEach -> + @props = + filterClick: sinon.spy (clickEvent) -> + true + children: + + + + + + + +module.exports = Search diff --git a/exercises/src/components/search/controls.cjsx b/exercises/src/components/search/controls.cjsx new file mode 100644 index 0000000000..5386ec57f6 --- /dev/null +++ b/exercises/src/components/search/controls.cjsx @@ -0,0 +1,12 @@ +React = require 'react' + + +SearchControls = React.createClass + + + # render nothing for now, maybe a header message or something later? + render: -> + return null + + +module.exports = SearchControls diff --git a/exercises/src/components/tags/blooms.cjsx b/exercises/src/components/tags/blooms.cjsx new file mode 100644 index 0000000000..d98e29381f --- /dev/null +++ b/exercises/src/components/tags/blooms.cjsx @@ -0,0 +1,24 @@ +React = require 'react' +_ = require 'underscore' + +SingleDropdown = require './single-dropdown' + +CHOICES = {} +CHOICES[i] = i for i in _.range(1, 8) + +BloomsTag = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + + render: -> + + +module.exports = BloomsTag diff --git a/exercises/src/components/tags/book-selection.cjsx b/exercises/src/components/tags/book-selection.cjsx new file mode 100644 index 0000000000..1fe259ba0b --- /dev/null +++ b/exercises/src/components/tags/book-selection.cjsx @@ -0,0 +1,41 @@ +React = require 'react' +_ = require 'underscore' + +BOOKS = + 'stax-soc' : 'Sociology' + 'stax-phys' : 'College Physics' + 'stax-k12phys' : 'Physics', + 'stax-bio' : 'Biology' + 'stax-apbio' : 'Biology for AP® Courses' + + # Temporarily removed from list until books are ready + # 'stax-cbio' : 'Concepts of Biology' + # 'stax-econ' : 'Economics' + # 'stax-macro' : 'Macro Economics' + # 'stax-micro' : 'Micro Economics' + # 'stax-anp' : 'Anatomy and Physiology' + +BookSelection = React.createClass + + propTypes: + onChange: React.PropTypes.func + selected: React.PropTypes.string + limit: React.PropTypes.array + + render: -> + books = if @props.limit + _.pick(BOOKS, @props.limit) + else + BOOKS + + + +module.exports = BookSelection diff --git a/exercises/src/components/tags/books.cjsx b/exercises/src/components/tags/books.cjsx new file mode 100644 index 0000000000..7e21087015 --- /dev/null +++ b/exercises/src/components/tags/books.cjsx @@ -0,0 +1,53 @@ +React = require 'react' + +Wrapper = require './wrapper' + +BookSelection = require './book-selection' + +BookTagSelect = React.createClass + propTypes: + book: React.PropTypes.string.isRequired + id: React.PropTypes.string.isRequired + + updateTag: (ev) -> + @props.actions.setPrefixedTag(@props.id, + prefix: 'book', tag: ev.target.value, previous: @props.book + ) + + onDelete: -> + @props.actions.setPrefixedTag(@props.id, + prefix: 'book', tag: false, previous: @props.book + ) + @props.actions.setPrefixedTag(@props.id, + prefix: "exid:#{@props.book}", tag: false, replaceOthers: true + ) + + render: -> +
    + + + + +
    + +BookTags = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + + add: -> + @props.actions.addBlankPrefixedTag(@props.id, + prefix: 'book' + ) + + render: -> + tags = @props.store.getTagsWithPrefix(@props.id, 'book') + + {for tag in tags + } + + + +module.exports = BookTags diff --git a/exercises/src/components/tags/cnx-feature.cjsx b/exercises/src/components/tags/cnx-feature.cjsx new file mode 100644 index 0000000000..e27f6eb651 --- /dev/null +++ b/exercises/src/components/tags/cnx-feature.cjsx @@ -0,0 +1,30 @@ +React = require 'react' + +MultiInput = require './multi-input' + +CnxModTag = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + + validateInput: (value) -> + 'Must match feature ID' unless value.match( + /^[\w\-]+$/i + ) + + cleanInput: (val) -> + val.replace(/[^\w\-]/g, '') + + render: -> + + +module.exports = CnxModTag diff --git a/exercises/src/components/tags/cnx-mod.cjsx b/exercises/src/components/tags/cnx-mod.cjsx new file mode 100644 index 0000000000..edf8d314b3 --- /dev/null +++ b/exercises/src/components/tags/cnx-mod.cjsx @@ -0,0 +1,30 @@ +React = require 'react' + +MultiInput = require './multi-input' + +CnxModTag = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + + validateInput: (value) -> + 'Must match CNX module ID (without version number)' unless value.match( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ) + + cleanInput: (val) -> + val.replace(/[^0-9a-f-]/g, '') + + render: -> + + +module.exports = CnxModTag diff --git a/exercises/src/components/tags/dok.cjsx b/exercises/src/components/tags/dok.cjsx new file mode 100644 index 0000000000..4d9212da13 --- /dev/null +++ b/exercises/src/components/tags/dok.cjsx @@ -0,0 +1,24 @@ +React = require 'react' +_ = require 'underscore' + +SingleDropdown = require './single-dropdown' + +CHOICES = {} +CHOICES[i] = i for i in _.range(1, 5) + +DokTag = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + + render: -> + + +module.exports = DokTag diff --git a/exercises/src/components/tags/error.cjsx b/exercises/src/components/tags/error.cjsx new file mode 100644 index 0000000000..1a14ef8e16 --- /dev/null +++ b/exercises/src/components/tags/error.cjsx @@ -0,0 +1,17 @@ +React = require 'react' +BS = require 'react-bootstrap' + +TagError = React.createClass + + propTypes: + error: React.PropTypes.string + + render: -> + return null unless @props.error + tooltip = + {@props.error} + + + + +module.exports = TagError diff --git a/exercises/src/components/tags/filter-type.cjsx b/exercises/src/components/tags/filter-type.cjsx new file mode 100644 index 0000000000..4e2ec0629d --- /dev/null +++ b/exercises/src/components/tags/filter-type.cjsx @@ -0,0 +1,39 @@ +React = require 'react' +_ = require 'underscore' + +Wrapper = require './wrapper' +Multiselect = require 'react-widgets/lib/Multiselect' + +PREFIX = 'filter-type' +TYPES = [ + {id: 'vocabulary', title: 'Vocabulary' } + {id: 'test-prep', title: 'Test Prep' } + {id: 'ap-test-prep', title: 'AP® Test Prep' } +] + +FilterTypeTag = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + + updateTag: (types) -> + tags = _.map types, (tag, v) -> + if _.isObject(tag) then tag.id else tag + @props.actions.setPrefixedTag(@props.id, prefix: PREFIX, tags: tags) + + render: -> + tags = @props.store.getTagsWithPrefix(@props.id, PREFIX) + + +
    + +
    +
    + +module.exports = FilterTypeTag diff --git a/exercises/src/components/tags/lo.cjsx b/exercises/src/components/tags/lo.cjsx new file mode 100644 index 0000000000..2e69cbcf7b --- /dev/null +++ b/exercises/src/components/tags/lo.cjsx @@ -0,0 +1,94 @@ +React = require 'react' +_ = require 'underscore' +classnames = require 'classnames' + +Error = require './error' +Wrapper = require './wrapper' + +PREFIX = 'lo' +BookSelection = require './book-selection' + +Input = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + tag: React.PropTypes.string.isRequired + + getDefaultProps: -> + inputType: 'text' + + getInitialState: -> + [book, lo] = @props.tag.split(':') + {book, lo} + + validateInput: (value) -> + 'Must match LO pattern of dd-dd-dd' unless value.match( + /^\d{1,2}-\d{1,2}-\d{1,2}$/ + ) + + componentWillReceiveProps: (nextProps) -> + [book, lo] = @props.tag.split(':') + @setState({book, lo}) + + onTextChange: (ev) -> + lo = ev.target.value.replace(/[^0-9\-]/g, '') + @setState({errorMsg: null, lo}) + + validateAndSave: (attrs = {}) -> + {lo, book} = _.defaults attrs, @state + if book and lo?.match( /^\d{1,2}-\d{1,2}-\d{1,2}$/ ) + @props.actions.setPrefixedTag(@props.id, + prefix: PREFIX, tag: "#{book}:#{lo}", previous: @props.tag + ) + else + @setState({lo, book, errorMsg: 'Must match LO pattern of book:dd-dd-dd'}) + + onTextBlur: -> @validateAndSave() + updateBook: (ev) -> + book = ev.target.value + @validateAndSave({book}) + + onDelete: -> + @props.actions.setPrefixedTag(@props.id, + prefix: PREFIX, tag: false, previous: @props.tag + ) + + render: -> + +
    + + + + + + +
    + +LoTags = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + + onAdd: -> + @props.actions.addBlankPrefixedTag(@props.id, prefix: PREFIX) + + render: -> + tags = @props.store.getTagsWithPrefix(@props.id, PREFIX) + + {for tag in tags + } + + +module.exports = LoTags diff --git a/exercises/src/components/tags/multi-input.cjsx b/exercises/src/components/tags/multi-input.cjsx new file mode 100644 index 0000000000..4d1deea909 --- /dev/null +++ b/exercises/src/components/tags/multi-input.cjsx @@ -0,0 +1,78 @@ +React = require 'react' +BS = require 'react-bootstrap' +_ = require 'underscore' +classnames = require 'classnames' + +Wrapper = require './wrapper' +Error = require './error' + +Input = React.createClass + + getDefaultProps: -> + inputType: 'text' + + propTypes: + tag: React.PropTypes.string.isRequired + + getInitialState: -> + value: @props.tag + + componentWillReceiveProps: (nextProps) -> + @setState(value: nextProps.tag) + + onChange: (ev) -> + @setState(errorMsg: null, value: @props.cleanInput(ev.target.value)) + + validateAndSave: (ev) -> + {value} = @state + error = @props.validateInput(value) + if error + @setState({errorMsg: error}) + else + @props.actions.setPrefixedTag(@props.id, + prefix: @props.prefix, tag: value, previous: @props.tag + ) + + onDelete: -> + @props.actions.setPrefixedTag(@props.id, + prefix: @props.prefix, tag: false, previous: @props.tag + ) + + render: -> +
    + + + + + +
    + +MultiInput = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + label: React.PropTypes.string.isRequired + prefix: React.PropTypes.string.isRequired + cleanInput: React.PropTypes.func.isRequired + validateInput: React.PropTypes.func.isRequired + + add: -> + @props.actions.addBlankPrefixedTag(@props.id, prefix: @props.prefix) + + render: -> + tags = @props.store.getTagsWithPrefix(@props.id, @props.prefix) + + + {for tag in tags + } + + +module.exports = MultiInput diff --git a/exercises/src/components/tags/question-type.cjsx b/exercises/src/components/tags/question-type.cjsx new file mode 100644 index 0000000000..a9fc3d2eb1 --- /dev/null +++ b/exercises/src/components/tags/question-type.cjsx @@ -0,0 +1,27 @@ +React = require 'react' + +SingleDropdown = require './single-dropdown' + +PREFIX = 'type' +TYPES = + 'conceptual-or-recall' : 'Conceptual or Recall' + 'conceptual' : 'Conceptual' + 'recall' : 'Recall' + 'practice' : 'Practice' + +QuestionTypeTag = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + + render: -> + # + +module.exports = QuestionTypeTag diff --git a/exercises/src/components/tags/requires-context.cjsx b/exercises/src/components/tags/requires-context.cjsx new file mode 100644 index 0000000000..12c59eab61 --- /dev/null +++ b/exercises/src/components/tags/requires-context.cjsx @@ -0,0 +1,28 @@ +React = require 'react' +BS = require 'react-bootstrap' +_ = require 'underscore' + +PREFIX = 'requires-context' +Wrapper = require './wrapper' + +RequiresContextTag = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + + updateTag: (ev) -> + tag = if ev.target.checked then 'true' else false # false will remove tag + @props.actions.setPrefixedTag(@props.id, prefix: PREFIX, tag: tag, replaceOthers:true) + + render: -> + tag = _.first @props.store.getTagsWithPrefix(@props.id, PREFIX) + + +
    + +
    +
    + +module.exports = RequiresContextTag diff --git a/exercises/src/components/tags/single-dropdown.cjsx b/exercises/src/components/tags/single-dropdown.cjsx new file mode 100644 index 0000000000..0eb8bf3009 --- /dev/null +++ b/exercises/src/components/tags/single-dropdown.cjsx @@ -0,0 +1,34 @@ +React = require 'react' +_ = require 'underscore' + +Wrapper = require './wrapper' + +SingleDropdown = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + label: React.PropTypes.string.isRequired + prefix: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + + + updateTag: (ev) -> + @props.actions.setPrefixedTag(@props.id, + tag: ev.target.value, prefix: @props.prefix, replaceOthers: true + ) + + render: -> + tag = _.first @props.store.getTagsWithPrefix(@props.id, @props.prefix) + +
    + +
    +
    + +module.exports = SingleDropdown diff --git a/exercises/src/components/tags/time.cjsx b/exercises/src/components/tags/time.cjsx new file mode 100644 index 0000000000..5da45fcd6b --- /dev/null +++ b/exercises/src/components/tags/time.cjsx @@ -0,0 +1,27 @@ +React = require 'react' +_ = require 'underscore' + +SingleDropdown = require './single-dropdown' + +CHOICES = { + 'short' : 'Short' + 'medium' : 'Medium' + 'long' : 'Long' +} + +TimeTag = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + store: React.PropTypes.object.isRequired + actions: React.PropTypes.object.isRequired + + render: -> + + +module.exports = TimeTag diff --git a/exercises/src/components/tags/wrapper.cjsx b/exercises/src/components/tags/wrapper.cjsx new file mode 100644 index 0000000000..c38bf6bf2f --- /dev/null +++ b/exercises/src/components/tags/wrapper.cjsx @@ -0,0 +1,30 @@ +React = require 'react' +BS = require 'react-bootstrap' +classnames = require 'classnames' + +TagWrapper = React.createClass + + propTypes: + label: React.PropTypes.string.isRequired + + render: -> + classes = classnames('tag-type', + 'has-error': @props.error + 'has-single-tag': @props.singleTag is true + ) + + +
    + {@props.label} +
    + {if @props.onAdd + } +
    +
    + + {@props.children} + +
    + + +module.exports = TagWrapper diff --git a/exercises/src/components/user-actions-menu.cjsx b/exercises/src/components/user-actions-menu.cjsx new file mode 100644 index 0000000000..3d71f6c03b --- /dev/null +++ b/exercises/src/components/user-actions-menu.cjsx @@ -0,0 +1,44 @@ +React = require 'react' +BS = require 'react-bootstrap' +_ = require 'underscore' +classnames = require 'classnames' + + +UserActionsMenu = React.createClass + propTypes: + user: React.PropTypes.object.isRequired + + componentWillMount: -> + @crsfToken = + document.querySelector('meta[name=csrf-token]')?.getAttribute('content') + + render: -> + name = @props.user?.full_name or + [@props.user?.first_name, @props.user?.last_name].join(' ') + + + +
  • + +
    + + + +
    +
    +
  • + +
    +
    + + +module.exports = UserActionsMenu diff --git a/exercises/src/components/vocabulary.cjsx b/exercises/src/components/vocabulary.cjsx new file mode 100644 index 0000000000..c1cba34bbb --- /dev/null +++ b/exercises/src/components/vocabulary.cjsx @@ -0,0 +1,87 @@ +React = require 'react' +_ = require 'underscore' +BS = require 'react-bootstrap' +classnames = require 'classnames' +Location = require 'stores/location' + +{VocabularyActions, VocabularyStore} = require 'stores/vocabulary' +Distractors = require 'components/vocabulary/distractors' +Tags = require 'components/vocabulary/tags' +ExercisePreview = require 'components/exercise/preview' +NetworkActivity = require 'components/network-activity-spinner' +RecordNotFound = require 'components/record-not-found' + +Vocabulary = React.createClass + propTypes: + id: React.PropTypes.string.isRequired + location: React.PropTypes.object + + setTerm: (ev) -> + VocabularyActions.change(@getVocabId(), term: ev.target.value) + + setDefinition: (ev) -> + VocabularyActions.change(@getVocabId(), definition: ev.target.value) + + update: -> @forceUpdate() + + componentWillMount: -> + VocabularyStore.addChangeListener(@update) + @loadIfNeeded( @getVocabId() ) + + componentWillReceiveProps: (nextProps) -> + @loadIfNeeded( ExerciseStore.get(nextProps.id)?.vocab_term_uid ) + + loadIfNeeded: (vocabId) -> + if vocabId and not ( VocabularyStore.get(vocabId) or VocabularyStore.isLoading(vocabId) ) + VocabularyActions.load(vocabId) + + componentWillUnmount: -> + VocabularyStore.removeChangeListener(@update) + + getVocabId: -> + ExerciseStore.get(@props.id)?.vocab_term_uid + + render: -> + vocabId = @getVocabId() + return null unless vocabId + + if vocabId and VocabularyStore.isLoading(vocabId) + return + + vocabTerm = VocabularyStore.getFromExerciseId(@props.id) + unless vocabTerm + return + +
    + +
    + + + + + + + + + + + + + + + + +

    Tags

    +
    +
    + + +
    + + +
    {vocabId}
    +
    + + +module.exports = Vocabulary diff --git a/exercises/src/components/vocabulary/controls.cjsx b/exercises/src/components/vocabulary/controls.cjsx new file mode 100644 index 0000000000..6324f6c584 --- /dev/null +++ b/exercises/src/components/vocabulary/controls.cjsx @@ -0,0 +1,104 @@ +React = require 'react' +BS = require 'react-bootstrap' +_ = require 'underscore' + +Location = require 'stores/location' +{VocabularyActions, VocabularyStore} = require 'stores/vocabulary' +{ExerciseActions} = require 'stores/exercise' + +AsyncButton = require 'shared/src/components/buttons/async-button.cjsx' +{SuretyGuard} = require 'shared' + +VocabularyControls = React.createClass + + propTypes: + id: React.PropTypes.string.isRequired + location: React.PropTypes.object + + update: -> @forceUpdate() + + componentWillMount: -> + VocabularyStore.addChangeListener(@update) + VocabularyStore.on('updated', @onUpdated) + + componentWillUnmount: -> + VocabularyStore.removeChangeListener(@update) + VocabularyStore.off('updated', @onUpdated) + + saveVocabulary: -> + vocabId = @getVocabId() + if VocabularyStore.isNew(vocabId) + VocabularyActions.create(vocabId, VocabularyStore.get(vocabId)) + else + VocabularyActions.save(vocabId) + + onUpdated: -> + vocab = VocabularyStore.getFromExerciseId(@props.id) + exId = _.last(vocab.exercise_uids) + {id} = @props.location.getCurrentUrlParts() + if id is exId + ExerciseActions.load(exId) + else + @props.location.visitVocab(exId) # update URL with new version + + publishVocabulary: -> + VocabularyActions.publish(@getVocabId()) + + isVocabularyDirty: -> + vocabId = @getVocabId() + vocabId and VocabularyStore.isChanged(vocabId) + + getVocabId: -> + ExerciseStore.get(@props.id)?.vocab_term_uid + + # render nothing for now, maybe a header message or something later? + render: -> + + {id} = @props + vocabTerm = VocabularyStore.getFromExerciseId(@props.id) + return null unless vocabTerm + vocabId = @getVocabId() + + guardProps = + onlyPromptIf: @isVocabularyDirty + placement: 'right' + message: "You will lose all unsaved changes" + +
    + + + + Save Draft + + + { unless VocabularyStore.isNew(vocabId) + + + Publish + + + } + +
    + +module.exports = VocabularyControls diff --git a/exercises/src/components/vocabulary/distractors.cjsx b/exercises/src/components/vocabulary/distractors.cjsx new file mode 100644 index 0000000000..89831f34f6 --- /dev/null +++ b/exercises/src/components/vocabulary/distractors.cjsx @@ -0,0 +1,66 @@ +React = require 'react' +_ = require 'underscore' +BS = require 'react-bootstrap' +classnames = require 'classnames' + +{VocabularyActions, VocabularyStore} = require 'stores/vocabulary' + +Distractor = React.createClass + getInitialState: -> + term: @props.term + + componentWillReceiveProps: (nextProps) -> + @setState(term: nextProps.term) + + save: (ev) -> + VocabularyActions.updateDistractor(@props.termId, @props.term, @state.term) + + onKeyPress: (ev) -> + if ev.key is 'Enter' + @save() + VocabularyActions.addBlankDistractor(@props.termId, @props.index + 1) + _.defer => + @getDOMNode().parentElement.querySelector("[data-index='#{@props.index+1}']")?.focus() + + + onChange: (ev) -> + @setState(term: ev.target.value) + + render: -> + + + +Distractors = React.createClass + propTypes: + termId: React.PropTypes.string.isRequired + + + onAdd: -> + VocabularyActions.addBlankDistractor(@props.termId) + + render: -> + vt = VocabularyStore.get(@props.termId) + return null unless vt + +
    +
    + +
    + +
    +
    +
    + {for distractor, i in vt.distractor_literals or [] + } +
    +
    + +module.exports = Distractors diff --git a/exercises/src/components/vocabulary/tags.cjsx b/exercises/src/components/vocabulary/tags.cjsx new file mode 100644 index 0000000000..5700828920 --- /dev/null +++ b/exercises/src/components/vocabulary/tags.cjsx @@ -0,0 +1,42 @@ +React = require 'react' +BS = require 'react-bootstrap' +_ = require 'underscore' + +{VocabularyActions, VocabularyStore} = require 'stores/vocabulary' + +Books = require 'components/tags/books' +Lo = require 'components/tags/lo' +QuestionType = require 'components/tags/question-type' +CnxMod = require 'components/tags/cnx-mod' +CnxFeature = require 'components/tags/cnx-feature' +Dok = require 'components/tags/dok' +Blooms = require 'components/tags/blooms' +Time = require 'components/tags/time' +RequiresContext = require 'components/tags/requires-context' + +ExerciseTags = React.createClass + propTypes: + vocabularyId: React.PropTypes.string.isRequired + + render: -> + tagProps = { + id: @props.vocabularyId + store: VocabularyStore + actions: VocabularyActions + } +
    + + + + + + + + + + +
    + + +module.exports = ExerciseTags diff --git a/exercises/src/index.coffee b/exercises/src/index.coffee new file mode 100644 index 0000000000..5a42d3bfaf --- /dev/null +++ b/exercises/src/index.coffee @@ -0,0 +1,35 @@ +React = require 'react' + +{AnswerActions, AnswerStore} = require './stores/answer' +{QuestionActions, QuestionStore} = require './stores/answer' +{ExerciseActions, ExerciseStore} = require './stores/exercise' + +MathJaxHelper = require 'shared/src/helpers/mathjax' +Exercise = require './components/exercise' +App = require './components/app' +api = require './api' + + +# Just for debugging +window.React = React +window.App = React.createFactory(App) +window.ExerciseActions = ExerciseActions +window.ExerciseStore = ExerciseStore +window.AnswerStore = AnswerStore +window.QuestionStore = QuestionStore +window.logout = -> ExerciseActions.changeExerciseMode(EXERCISE_MODES.VIEW) + + +loadApp = -> + + api.start() + MathJaxHelper.startMathJax() + + root = document.createElement('div') + document.body.appendChild(root) + data = JSON.parse( + document.getElementById('exercises-boostrap-data')?.innerHTML or '{}' + ) + window.React.render(window.App({data}), root) + +document.addEventListener('DOMContentLoaded', loadApp) diff --git a/exercises/src/stores/answer.coffee b/exercises/src/stores/answer.coffee new file mode 100644 index 0000000000..de4bda3fed --- /dev/null +++ b/exercises/src/stores/answer.coffee @@ -0,0 +1,37 @@ +flux = require 'flux-react' +{CrudConfig, makeSimpleStore, extendConfig} = require './helpers' + +AnswerConfig = { + updateContent: (id, content) -> + @_change(id, {content_html: content}) + + setCorrect: (id) -> + @_change(id, {correctness: "1.0"}) + + setIncorrect: (id) -> + @_change(id, {correctness: "0.0"}) + + updateFeedback: (id, feedback) -> + @_change(id, {feedback_html: feedback}) + + exports: + getContent: (id) -> @_get(id).content_html + getFeedback: (id) -> @_get(id).feedback_html + isCorrect: (id) -> @_get(id).correctness is "1.0" + + validate: (id) -> + if (not @_get(id).content_html) + return valid: false, part: 'Answer Distractor' + + return valid: true + + getTemplate: -> + content_html:"", + feedback_html:"", + correctness:"1.0" +} + +extendConfig(AnswerConfig, new CrudConfig()) +{actions, store} = makeSimpleStore(AnswerConfig) + +module.exports = {AnswerActions:actions, AnswerStore:store} diff --git a/exercises/src/stores/errors.coffee b/exercises/src/stores/errors.coffee new file mode 100644 index 0000000000..2d9c9b5919 --- /dev/null +++ b/exercises/src/stores/errors.coffee @@ -0,0 +1,31 @@ +_ = require 'underscore' +flux = require 'flux-react' + +{makeSimpleStore} = require './helpers' + +ErrorsConfig = { + + + + setServerError: (statusCode, message, requestDetails) -> + {url, opts} = requestDetails + + sparseOpts = _.pick(opts, 'method', 'data') + request = {url, opts: sparseOpts} + @_currentServerError = {statusCode, message, request} + + @emit('change') + + acknowledge: -> + delete @_currentServerError + @emit('change') + + exports: + getError: -> @_currentServerError + + + +} + +{actions, store} = makeSimpleStore(ErrorsConfig) +module.exports = {ErrorsActions:actions, ErrorsStore:store} diff --git a/exercises/src/stores/exercise.coffee b/exercises/src/stores/exercise.coffee new file mode 100644 index 0000000000..fada16afc7 --- /dev/null +++ b/exercises/src/stores/exercise.coffee @@ -0,0 +1,183 @@ +_ = require 'underscore' +flux = require 'flux-react' +{CrudConfig, makeSimpleStore, extendConfig} = require './helpers' +{QuestionActions, QuestionStore} = require './question' +TaggingMixin = require './tagging-mixin' + +cascadeLoad = (obj, exerciseId) -> + for question in obj.questions + QuestionActions.loaded(question, question.id) + obj + +EmptyFn = -> '' + +multipartCache = {} + +ExerciseConfig = { + _loaded: (obj, exerciseId) -> + cascadeLoad(obj, exerciseId) + @emit('loaded', exerciseId) + + _asyncStatusPublish: {} + + updateStimulus: (id, stimulus_html) -> @_change(id, {stimulus_html}) + + sync: (id) -> + questions = _.map @_local[id].questions, (question) -> + QuestionActions.syncAnswers(question.id) + QuestionStore.get(question.id) + @_change(id, {questions}) + + save: (id) -> + @sync(id) + @_save(id) + + published: (obj, id) -> + @emit('published', id) + @saved(obj, id) + + _saved: (obj, id) -> + cascadeLoad(obj, id) + @_asyncStatusPublish[id] = false + @emit('updated', obj.id) + + _created:(obj, id) -> + cascadeLoad(obj, obj.number) + obj.id = obj.number + @emit('updated', obj.id) + obj + + setAsVocabularyPlaceHolder: (id, newVocabId) -> + @_change(id, {vocab_term_uid: newVocabId}) + + publish: (id) -> + @_asyncStatusPublish[id] = true + @emitChange() + + deleteAttachment: EmptyFn + + attachmentDeleted: (resp, exerciseUid, attachmentId) -> + exercise = _.findWhere(@_local, {uid: exerciseUid}) + exercise.attachments = _.reject exercise.attachments, (attachment) -> attachment.id is attachmentId + @emitChange() + + addQuestionPart: (id) -> + { questions } = @_get(id) + + newId = QuestionStore.freshLocalId() + template = _.extend({}, QuestionStore.getTemplate(), {id: newId}) + QuestionActions.loaded(template, newId) + question = QuestionStore.get(newId) + + @_local[id].questions.push(question) + @sync(id) + + toggleMultiPart: (id) -> + { questions } = @_get(id) + + if (@_local[id].questions.length > 1) + multipartCache[id] = questions + newQuestions = [_.first(questions)] + else if (multipartCache[id]) + newQuestions = multipartCache[id] + multipartCache[id] = null + else + return @addQuestionPart(id) + + @_local[id].questions = newQuestions + @sync(id) + + removeQuestion: (id, questionId) -> + { questions } = @_get(id) + + newQuestions = _.filter(questions, (question) -> question.id isnt questionId) + if (newQuestions.length is 0) + @_local[id].questions = [] + @addQuestionPart(id) + else + @_local[id].questions = newQuestions + @sync(id) + + moveQuestion: (id, questionId, direction) -> + { questions } = @_get(id) + index = _.findIndex questions, (question) -> + question.id is questionId + + if (index isnt -1) + temp = questions[index] + questions[index] = questions[index + direction] + questions[index + direction] = temp + @_local[id].questions = questions + @sync(id) + + + attachmentUploaded: (uid, attachment) -> + exercise = _.findWhere(@_local, {uid}) + exercise.attachments ||= [] + exercise.attachments.push(attachment) + @emitChange() + + createBlank: (id) -> + template = @exports.getTemplate.call(@) + @loaded(template, id) + + exports: + + getQuestions: (id) -> @_get(id).questions + + isMultiPart: (id) -> @_get(id)?.questions.length > 1 + + isVocabQuestion: (id) -> @_get(id)?.is_vocab + + getVocabId: (id) -> @_get(id)?.vocab_term_uid + + getId: (id) -> @_get(id).uid + + getNumber: (id) -> @_get(id).number + + getStimulus: (id) -> @_get(id).stimulus_html + + getPublishedDate: (id) -> @_local[id].published_at + + isPublished: (id) -> !!@_get(id)?.published_at + + isPublishing: (id) -> !!@_asyncStatusPublish[id] + + isSavable: (id) -> + @exports.isChanged.call(@, id) and + @exports.validate.call(@, id).valid and + not @exports.isSaving.call(@, id) and + not @exports.isPublishing.call(@, id) + + isPublishable: (id) -> + @exports.validate.call(@, id).valid and + not @exports.isChanged.call(@, id) and + not @exports.isSaving.call(@, id) and + not @exports.isPublishing.call(@, id) and + not @_get(id)?.published_at + + + getTemplate: (id) -> + questionId = QuestionStore.freshLocalId() + + tags: [] + stimulus_html:"", + questions:[_.extend({}, QuestionStore.getTemplate(), {id: questionId})] + + validate: (id) -> + return {valid: false, part: 'exercise'} unless @_local[id] + _.reduce @_local[id].questions, (memo, question) -> + validity = QuestionStore.validate(question.id) + + valid: memo.valid and validity.valid + part: memo.part or validity.part + , valid: true + +} + +TaggingMixin.extend(ExerciseConfig) + +extendConfig(ExerciseConfig, new CrudConfig()) +{actions, store} = makeSimpleStore(ExerciseConfig) + +module.exports = {ExerciseActions:actions, ExerciseStore:store} diff --git a/exercises/src/stores/helpers.coffee b/exercises/src/stores/helpers.coffee new file mode 100644 index 0000000000..21bf2f333a --- /dev/null +++ b/exercises/src/stores/helpers.coffee @@ -0,0 +1,181 @@ +_ = require 'underscore' +flux = require 'flux-react' + +LOADING = 'loading' +LOADED = 'loaded' +FAILED = 'failed' +SAVING = 'saving' +DELETING = 'deleting' +DELETED = 'deleted' + +idCounter = 0 +CREATE_KEY = -> "_CREATING_#{idCounter++}" + +isNew = (id) -> /_CREATING_/.test(id) + +CrudConfig = -> + { + _asyncStatus: {} + _local: {} + _changed: {} + _errors: {} + _reload: {} + + # If the specific type needs to do something else to the object: + # _loaded : (obj, id) -> + # _saved : (obj, id) -> + # _reset : -> + + reset: -> + @_asyncStatus = {} + @_local = {} + @_changed = {} + @_errors = {} + @_reload = {} + + @_reset?() + @emitChange() + + FAILED: (status, msg, id) -> + @_asyncStatus[id] = FAILED + @_errors[id] = msg + unless status is 0 # indicates network failure + delete @_local[id] + @emitChange() + + load: (id) -> + # Add a shortcut for unit testing + return if @_asyncStatus[id] is LOADED and @_HACK_DO_NOT_RELOAD + @_reload[id] = false + @_asyncStatus[id] = LOADING + @emitChange() + + loaded: (obj, id) -> + # id = obj.id + @_asyncStatus[id] = LOADED + # HACK When working locally a step completion triggers a reload but the is_completed field on the TaskStep + # is discarded. so, if is_completed is set on the local object but not on the returned JSON + # Tack on a dummy correct_answer_id + if @_local[id] and obj.HACK_LOCAL_STEP_COMPLETION and @_local[id].steps + for step in @_local[id].steps + # HACK: Tack on a fake correct_answer and feedback to all completed steps that have an exercise but no correct_answer_id + if step.is_completed and step.content?.questions?[0]?.answers[0]? and not step.correct_answer_id + step.correct_answer_id = step.content.questions[0].answers[0].id + step.feedback_html = 'Some FAKE feedback' + + if obj + # If the specific type needs to do something else to the object: + @_local[id] = @_loaded?(obj, id) or obj + + @emitChange() + + save: (id, obj) -> + # Note: id could be isNew() + @_asyncStatus[id] = SAVING + @emitChange() + + saved: (result, id) -> + # id = result.id + @_asyncStatus[id] = LOADED # TODO: Maybe make this SAVED + + # If the specific type needs to do something else to the object: + obj = @_saved?(result, id) + result = obj if obj + + if result + @_local[id] = result + @_local[result.id] = result + delete @_changed[result.id] + else + console.warn('API WARN: Server did not return JSON after saving. Patching locally') + # Merge all the local changes into the new local object + @_local[id] = _.extend(@_local[id], @_changed[id]) + delete @_changed[id] + delete @_errors[id] + # If the specific type needs to do something else to the object: + @emitChange() + + create: (localId, attributes = {}) -> + throw new Error('BUG: MUST provide a local id') unless isNew(localId) + @_local[localId] = {} + @_changed[localId] = attributes + @_asyncStatus[localId] = LOADED + + created: (result, localId) -> + @_local[localId] = result # HACK: So react component can still manipulate the same object + # If the specific type needs to do something else to the object: + obj = @_created?(result, result.id, localId) + result = obj if obj + @_local[result.id] = result + @_asyncStatus[localId] = LOADED + @_asyncStatus[result.id] = LOADED + @emitChange() + + _change: (id, obj) -> + @_changed[id] ?= {} + _.extend(@_changed[id], obj) + @emitChange() + + _save: (id) -> + @_asyncStatus[id] = SAVING + + delete: (id) -> + @_asyncStatus[id] = DELETING + + deleted: (result, id) -> + @_asyncStatus[id] = DELETED + delete @_local[id] + @emitChange() + + clearChanged: (id) -> + delete @_changed[id] + + HACK_DO_NOT_RELOAD: (bool) -> @_HACK_DO_NOT_RELOAD = bool + + # Keep this here so other exports method have access to it + _get: (id) -> + val = @_local[id] + return null unless val or @_asyncStatus[id] is SAVING + # Scores stores an Array unlike most other stores + if _.isArray(val) + val + else + _.extend({}, val, @_changed[id]) + + exports: + isUnknown: (id) -> not @_asyncStatus[id] + isLoading: (id) -> @_asyncStatus[id] is LOADING + isLoaded: (id) -> @_asyncStatus[id] is LOADED + isDeleting: (id) -> @_asyncStatus[id] is DELETING + isSaving: (id) -> @_asyncStatus[id] is SAVING + isFailed: (id) -> @_asyncStatus[id] is FAILED + getAsyncStatus: (id) -> @_asyncStatus[id] + get: (id) -> @_get(id) + isChanged: (id) -> not _.isEmpty(@_changed[id]) + getChanged: (id) -> @_changed[id] or {} + freshLocalId: -> CREATE_KEY() + isNew: (id) -> isNew(id) + reload: (id) -> @_reload[id] + } + +# Helper for creating a simple store for actions +makeSimpleStore = (storeConfig) -> + + actionsConfig = _.omit storeConfig, (value, key) -> + typeof value isnt 'function' or key is 'exports' + + actionsConfig = _.keys(actionsConfig) + actions = flux.createActions(actionsConfig) + storeConfig.actions = _.values(actions) + store = flux.createStore(storeConfig) + {actions, store} + + +extendConfig = (newConfig, origConfig) -> + newConfig.exports ?= {} + _.defaults(newConfig, origConfig) + _.defaults(newConfig.exports, origConfig.exports) + newConfig + + +module.exports = {CrudConfig, makeSimpleStore, extendConfig} diff --git a/exercises/src/stores/location.coffee b/exercises/src/stores/location.coffee new file mode 100644 index 0000000000..df3d1bc17a --- /dev/null +++ b/exercises/src/stores/location.coffee @@ -0,0 +1,71 @@ +history = require 'history' +_ = require 'underscore' + +{ExerciseActions, ExerciseStore} = require 'stores/exercise' +{VocabularyActions, VocabularyStore} = require 'stores/vocabulary' + +VIEWS = + exercises: + Body: require 'components/exercise' + Controls: require 'components/exercise/controls' + store: ExerciseStore + actions: ExerciseActions + + search: + Body: require 'components/search' + Controls: require 'components/search/controls' + + vocabulary: + Body: require 'components/vocabulary' + Controls: require 'components/vocabulary/controls' + store: ExerciseStore + actions: ExerciseActions + + +# The Location class pairs urls with components and stores +class Location + + constructor: -> + @_createHistory() + + _createHistory: -> + @history = history.createHistory() + + startListening: (cb) -> + @historyUnlisten = @history.listen(cb) + + stopListening: -> + @historyUnlisten() + + visitNewRecord: (type) -> + @history.push("/#{type}/new") + + visitSearch: -> + @history.push('/search') + + visitExercise: (id) -> + @history.push("/exercises/#{id}") + + visitVocab: (id) -> + @history.push("/vocabulary/#{id}") + + getCurrentUrlParts: -> + path = window.location.pathname + [view, id, args...] = _.tail path.split('/') + {view, id, args} + + partsForView: (view = @getCurrentUrlParts().view) -> + VIEWS[view] or VIEWS['search'] + + # callback for when a record is newly loaded from store + # Location may choose to redirect to a different editor depending on the data + onRecordLoad: (type, id, store) -> + {view} = @getCurrentUrlParts() + record = store.get(id) + # use vocab editor + if type is 'exercises' and record.vocab_term_uid + @visitVocab(id) #record.vocab_term_uid) + else + @history.push("/#{type}/#{id}") + +module.exports = Location diff --git a/exercises/src/stores/question.coffee b/exercises/src/stores/question.coffee new file mode 100644 index 0000000000..e7dabc817e --- /dev/null +++ b/exercises/src/stores/question.coffee @@ -0,0 +1,136 @@ +_ = require 'underscore' +flux = require 'flux-react' +{CrudConfig, makeSimpleStore, extendConfig} = require './helpers' +{AnswerActions, AnswerStore} = require './answer' + +QuestionConfig = { + _loaded: (obj, id) -> + for answer in obj?.answers + AnswerActions.loaded(answer, answer.id) + + obj + + syncAnswers: (id) -> + answers = _.map @_local[id]?.answers, (answer) -> + AnswerStore.get(answer.id) + @_change(id, {answers}) + + addNewAnswer: (id) -> + newAnswer = + id: AnswerStore.freshLocalId() + correctness: "0.0" + feedback_html: '' + content_html: '' + + AnswerActions.created(newAnswer, newAnswer.id) + answers = @_local[id]?.answers.push(newAnswer) + @syncAnswers(id) + + removeAnswer: (id, answerId) -> + AnswerActions.delete(answerId) + answers = _.reject @_local[id]?.answers, (answer) -> + answer.id is answerId + @_local[id]?.answers = answers + + moveAnswer: (id, answerId, direction) -> + index = _.findIndex @_local[id]?.answers, (answer) -> + answer.id is answerId + + if (index isnt -1) + temp = @_local[id]?.answers[index] + @_local[id]?.answers[index] = @_local[id]?.answers[index + direction] + @_local[id]?.answers[index + direction] = temp + + updateStem: (id, stem_html) -> + @_change(id, {stem_html}) + + updateStimulus: (id, stimulus_html) -> @_change(id, {stimulus_html}) + + updateSolution: (id, feedback) -> + solution = _.first(@_get(id).collaborator_solutions) + if (solution) + solution.content_html = feedback + else + solution = + content_html: feedback + attachments: [] + solution_type: 'detailed' + + @_change(id, {collaborator_solutions: [solution]}) + + setCorrectAnswer: (id, newAnswer, curAnswer) -> + if not AnswerStore.isCorrect(newAnswer) + AnswerActions.setCorrect(newAnswer) + AnswerActions.setIncorrect(curAnswer) if curAnswer + + togglePreserveOrder: (id) -> + {is_answer_order_important} = @_get(id) + @_change(id, {is_answer_order_important: not is_answer_order_important}) + + toggleFormat: (id, name, isSelected) -> + formats = @_get(id).formats + + formats = if isSelected + formats.concat(name) + else + _.without(formats, name) + + # toggle free-response depending on selection + if isSelected + switch name + when 'true-false' + formats = _.without(formats, 'free-response') + when 'multiple-choice' + formats = formats.concat('free-response') + + @_change(id, {formats: _.unique(formats)}) + + exports: + + hasFormat: (id, name) -> + _.include @_get(id)?.formats, name + + getAnswers: (id) -> @_get(id)?.answers or [] + + getStem: (id) -> @_get(id)?.stem_html + + getStimulus: (id) -> @_get(id)?.stimulus_html + + getSolution: (id) -> + _.first(@_get(id).collaborator_solutions)?.content_html + + getCorrectAnswer: (id) -> + _.find @_get(id)?.answers, (answer) -> AnswerStore.isCorrect(answer.id) + + isOrderPreserved: (id) -> + @_get(id).is_answer_order_important + + getTemplate: -> + answerId = AnswerStore.freshLocalId() + + formats: ['multiple-choice', 'free-response'] + stem_html:"", + stimulus_html:"", + collaborator_solutions: [{"content_html": "", "solution_type": "detailed"}], + answers:[_.extend({}, AnswerStore.getTemplate(), {id: answerId})], + is_answer_order_important: false + + validate: (id) -> + question = @_get(id) + if (not question.stem_html) + return valid: false, part: 'Question Stem' + if _.isEmpty(question.collaborator_solutions) or not _.first(question.collaborator_solutions)?.content_html + return valid: false, part: 'Detailed Solution' + + _.reduce @_get(id).answers, (memo, answer) -> + validity = AnswerStore.validate(answer.id) + + valid: memo.valid and validity.valid + part: memo.part or validity.part + , valid: true +} + +extendConfig(QuestionConfig, new CrudConfig()) +{actions, store} = makeSimpleStore(QuestionConfig) + +module.exports = {QuestionActions:actions, QuestionStore:store} diff --git a/exercises/src/stores/tagging-mixin.coffee b/exercises/src/stores/tagging-mixin.coffee new file mode 100644 index 0000000000..3de446691d --- /dev/null +++ b/exercises/src/stores/tagging-mixin.coffee @@ -0,0 +1,48 @@ +_ = require 'underscore' + +Actions = + + addBlankPrefixedTag: (id, {prefix}) -> + prefix += ':' + tags = _.clone( @_get(id).tags ) + # is there already a blank one? + unless _.find( tags, (tag) -> tag is prefix ) + tags.push(prefix) + @_change(id, {tags}) + + # Updates or creates a prefixed tag + # If previous is given, then only the tag with that value will be updated + # Otherwise it will be added (unless it exists) + # If replaceOthers is set, all others will prefix will be removed + setPrefixedTag: (id, {prefix, tag, tags, previous, replaceOthers}) -> + prefix += ':' + if tags + tags = _.reject(@_get(id).tags, (tag) -> 0 is tag.indexOf(prefix)) + .concat( _.map tags, (tag) -> prefix + tag ) + else if replaceOthers + tags = _.reject @_get(id).tags, (tag) -> 0 is tag.indexOf(prefix) + else + tags = _.clone @_get(id).tags + + if previous? + tags = _.reject tags, (tag) -> tag is prefix + previous + + if tag and not _.find(tags, (tag) -> tag is prefix + tag) + tags.push(prefix + tag) + + @_change(id, {tags}) + + +Store = + getTagsWithPrefix: (id, prefix) -> + prefix += ':' + tags = _.select @_get(id).tags, (tag) -> 0 is tag.indexOf(prefix) + _.map( tags, (tag) -> tag.replace(prefix, '') ).sort() + + +Extend = + extend: (config) -> + _.extend( config, Actions ) + _.extend( config.exports, Store ) + +module.exports = Extend diff --git a/exercises/src/stores/vocabulary.coffee b/exercises/src/stores/vocabulary.coffee new file mode 100644 index 0000000000..995edb0d80 --- /dev/null +++ b/exercises/src/stores/vocabulary.coffee @@ -0,0 +1,99 @@ +_ = require 'underscore' +flux = require 'flux-react' +{CrudConfig, makeSimpleStore, extendConfig} = require './helpers' +TaggingMixin = require './tagging-mixin' +{ExerciseStore} = require './exercise' + +VocabularyConfig = { + + _asyncStatusPublish: {} + + _loaded: (obj, exerciseId) -> + + @emit('loaded', exerciseId) + + _created:(obj, id) -> + obj.id = obj.number + @emit('updated', obj.id) + obj + + _saved: (obj, id) -> + @emit('updated', obj.id) + obj + + createBlank: (id) -> + template = @exports.getTemplate.call(@) + @loaded(template, id) + + updateDistractor: (id, oldValue, newValue) -> + distractor_literals = _.clone(@_get(id).distractor_literals or []) + index = _.indexOf distractor_literals, oldValue + if -1 is index + distractor_literals.push(newValue) + else if newValue is '' + distractor_literals.splice(index, 1) + else + distractor_literals[index] = newValue + @_change(id, {distractor_literals}) + + addBlankDistractor: (id, index) -> + distractor_literals = _.clone(@_get(id).distractor_literals or []) + index = distractor_literals.length unless index? + distractor_literals.splice(index, 0, '') + @_change(id, {distractor_literals}) + + change: (id, attrs) -> + @_change(id, attrs) + + publish: (id) -> + @_asyncStatusPublish[id] = true + @emitChange() + + published: (obj, id) -> + @_asyncStatusPublish[id] = false + @emit('published', id) + @saved(obj, id) + + exports: + getFromExerciseId: (id) -> + ex = ExerciseStore.get(id) + if ex then @exports.get.call(@, ex.vocab_term_uid) else null + + getTemplate: (id) -> + term: '' + definition: '' + distractor_literals: [''] + tags: ['dok:1', 'blooms:1', 'time:short'] + + hasExercise: (id) -> @_get(id)?.exercise_uids?.length + + getExerciseIds: (id) -> @_get(id)?.exercise_uids + + isSavable: (id) -> + @exports.isChanged.call(@, id) and + @exports.validate.call(@, id).valid and + not @exports.isSaving.call(@, id) and + not @exports.isPublishing.call(@, id) + + isPublished: (id) -> !!@_get(id)?.published_at + + isPublishing: (id) -> !!@_asyncStatusPublish[id] + + isPublishable: (id) -> + @exports.validate.call(@, id).valid and + not @exports.isChanged.call(@, id) and + not @exports.isSaving.call(@, id) and + not @exports.isPublishing.call(@, id) and + not @_get(id)?.published_at + + validate: (id) -> + return {valid: false, part: 'vocab'} unless @_get(id) + valid: true +} + +TaggingMixin.extend(VocabularyConfig) + +extendConfig(VocabularyConfig, new CrudConfig()) +{actions, store} = makeSimpleStore(VocabularyConfig) + +module.exports = {VocabularyActions:actions, VocabularyStore:store} diff --git a/exercises/test/all-source-files.coffee b/exercises/test/all-source-files.coffee new file mode 100644 index 0000000000..3fea953e9d --- /dev/null +++ b/exercises/test/all-source-files.coffee @@ -0,0 +1,2 @@ +testsContext = require.context("../src", true, /\.(cjsx|coffee)$/) +testsContext.keys().forEach(testsContext) diff --git a/exercises/test/all-tests.coffee b/exercises/test/all-tests.coffee new file mode 100644 index 0000000000..9d011a8a5a --- /dev/null +++ b/exercises/test/all-tests.coffee @@ -0,0 +1,2 @@ +testsContext = require.context("./", true, /\.spec\.(cjsx|coffee)$/) +testsContext.keys().forEach(testsContext) diff --git a/exercises/test/components/app.spec.coffee b/exercises/test/components/app.spec.coffee new file mode 100644 index 0000000000..dd74054759 --- /dev/null +++ b/exercises/test/components/app.spec.coffee @@ -0,0 +1,24 @@ +{Testing, expect, sinon, _, ReactTestUtils} = require 'shared/test/helpers' + +App = require 'components/app' +Location = require 'stores/location' +{ExerciseActions, ExerciseStore} = require 'stores/exercise' +Exercise = require 'components/exercise' +ExerciseControls = require 'components/exercise/controls' + +describe 'App component', -> + beforeEach -> + @props = + location: new Location + data: + user: + full_name: 'Bob' + + it 'renders a blank exercise when btn is clicked', -> + sinon.stub(@props.location, 'partsForView', -> + Body: Exercise, Controls: ExerciseControls, store: ExerciseStore, actions: ExerciseActions + ) + Testing.renderComponent( App, props: @props ).then ({dom}) -> + expect( dom.querySelector('.exercise-editor') ).not.to.exist + ReactTestUtils.Simulate.click dom.querySelector('.btn.exercises.blank') + expect( dom.querySelector('.exercise-editor') ).to.exist diff --git a/exercises/test/components/exercise.spec.coffee b/exercises/test/components/exercise.spec.coffee new file mode 100644 index 0000000000..ce93d26c61 --- /dev/null +++ b/exercises/test/components/exercise.spec.coffee @@ -0,0 +1,47 @@ +{Testing, expect, sinon, _, ReactTestUtils} = require 'shared/test/helpers' + +ExercisePreview = require 'components/exercise/preview' +Exercise = require 'components/exercise' +{ExerciseActions} = require 'stores/exercise' +EXERCISE = require 'exercises/1.json' +Location = require 'stores/location' + +describe 'Exercises component', -> + beforeEach -> + sinon.stub( Location.prototype, '_createHistory', -> + @history = { + push: -> 0 + } + ) + @props = + id: '1' + location: new Location + ExerciseActions.loaded(EXERCISE, @props.id) + + afterEach -> + Location::_createHistory.restore() + + it 'renders', -> + Testing.renderComponent( Exercise, props: @props ).then ({dom}) -> + expect(dom.classList.contains('.exercise-editor')).not.to.true + + it 'renders a preview of the exercise', -> + Testing.renderComponent( Exercise, props: @props ).then ({element}) => + + preview = ReactTestUtils.findRenderedComponentWithType(element, ExercisePreview) + expect(preview.props.exerciseId).to.equal(@props.id) + + it 'renders with intro and a multiple questions when exercise is MC', -> + Testing.renderComponent( Exercise, props: @props ).then ({dom}) -> + tabs = _.pluck dom.querySelectorAll('.nav-tabs li'), 'textContent' + expect(tabs).to.deep.equal(['Intro', 'Question 1', 'Question 2', 'Tags', 'Assets']) + + it 'renders with out intro and a single question when exercise is MC', -> + ExerciseActions.toggleMultiPart(@props.id) + Testing.renderComponent( Exercise, props: @props ).then ({dom}) -> + tabs = _.pluck dom.querySelectorAll('.nav-tabs li'), 'textContent' + expect(tabs).to.deep.equal(['Question', 'Tags', 'Assets']) + + it 'displays question formats on preview', -> + Testing.renderComponent( Exercise, props: @props ).then ({dom}) -> + expect(dom.querySelector('.openstax-exercise-preview .formats-listing')).to.exist diff --git a/exercises/test/components/exercise/controls.spec.coffee b/exercises/test/components/exercise/controls.spec.coffee new file mode 100644 index 0000000000..140bb0029a --- /dev/null +++ b/exercises/test/components/exercise/controls.spec.coffee @@ -0,0 +1,36 @@ +{Testing, expect, sinon, _, ReactTestUtils} = require 'shared/test/helpers' +{ExercisePreview} = require 'shared' + +ExerciseControls = require 'components/exercise/controls' +Exercise = require 'components/exercise' +{ExerciseActions, ExerciseStore} = require 'stores/exercise' +Location = require 'stores/location' +EXERCISE = require '../../../api/exercises/1.json' + + +describe 'Exercise controls component', -> + + beforeEach -> + @props = + id: '1' + location: new Location + @blankProps = + id: 'new' + location: new Location + ExerciseActions.loaded(EXERCISE, @props.id) + + it 'does not enable the save draft on blank exercises', (done) -> + Testing.renderComponent( ExerciseControls, props: @blankProps ).then ({dom}) -> + draftBtn = dom.querySelector('.btn.draft') + expect( draftBtn.hasAttribute('disabled') ).to.be.true + done() + + it 'does enables the save draft when not blank and valid and changed', (done) -> + #trigger a change + ExerciseActions.sync(1) + Testing.renderComponent( ExerciseControls, props: @props ).then ({dom}) -> + draftBtn = dom.querySelector('.btn.draft') + expect( draftBtn.hasAttribute('disabled') ).to.be.false + done() + + diff --git a/exercises/test/config/karma-coverage.config.coffee b/exercises/test/config/karma-coverage.config.coffee new file mode 100644 index 0000000000..0da3891c4a --- /dev/null +++ b/exercises/test/config/karma-coverage.config.coffee @@ -0,0 +1,32 @@ +{webpack} = require './karma.common' +_ = require 'underscore' +commonConfig = require './karma.common' + +module.exports = (karmaConfig) -> + + config = _.extend({ + + # usefull for debugging Karma config + # logLevel: karmaConfig.LOG_DEBUG + + coverageReporter: + type: 'text' + + }, commonConfig) + + config.reporters.push('coverage') + + for spec, processors of config.preprocessors + processors.push('coverage') + + config.webpack.module.postLoaders = [{ + test: /\.(cjsx|coffee)$/ + loader: 'istanbul-instrumenter' + exclude: /(test|node_modules|resources|bower_components)/ + }] + + config.plugins.push( + require('karma-coverage') + ) + + karmaConfig.set(config) diff --git a/exercises/test/config/karma-dev.config.coffee b/exercises/test/config/karma-dev.config.coffee new file mode 100644 index 0000000000..96fbf621c9 --- /dev/null +++ b/exercises/test/config/karma-dev.config.coffee @@ -0,0 +1,11 @@ +{webpack} = require './karma.common' +_ = require 'underscore' +commonConfig = require './karma.common' + +module.exports = (karmaConfig) -> + + config = _.extend(commonConfig, { + browsers: [process.env.KARMA_BROWSER or 'PhantomJS'] + }) + + karmaConfig.set(config) diff --git a/exercises/test/config/karma-in-background.js b/exercises/test/config/karma-in-background.js new file mode 100644 index 0000000000..ef1a595878 --- /dev/null +++ b/exercises/test/config/karma-in-background.js @@ -0,0 +1,11 @@ +var KarmaServer = require('karma').Server; + +var files = JSON.parse(process.argv[2]); + +server = new KarmaServer({ + configFile: __dirname + '/karma-dev.config.coffee', + files: files, + singleRun: false +}) + +server.start() diff --git a/exercises/test/config/karma.common.coffee b/exercises/test/config/karma.common.coffee new file mode 100644 index 0000000000..1760df5cc3 --- /dev/null +++ b/exercises/test/config/karma.common.coffee @@ -0,0 +1,65 @@ +_ = require 'underscore' +path = require 'path' + +module.exports = + basePath: '../../' + frameworks: ['mocha', 'chai', 'chai-sinon', 'phantomjs-shim'] + browsers: ['PhantomJS'] + reporters: ['mocha'] + + files: [ + 'test/all-tests.coffee' + 'test/all-source-files.coffee' + ] + + preprocessors: + 'src/**/*.{coffee,cjsx}': ['webpack', 'sourcemap'] + 'test/**/*': ['webpack', 'sourcemap'] + + webpack: + devtool: 'eval-source-map' + resolve: + root: [ path.resolve(__dirname, '../../src'), path.resolve(__dirname, '../../api') ] + extensions: ['', '.js', '.json', '.coffee', '.cjsx'] + module: + noParse: [ + /\/sinon\.js/ + ] + loaders: [ + { test: /\.coffee$/, loader: "coffee-loader" } + { test: /\.json$/, loader: "json-loader" } + { test: /\.cjsx$/, loader: "coffee-jsx-loader" } + ] + preLoaders: [{ + test: /\.(cjsx|coffee)$/ + loader: "coffeelint-loader" + exclude: /(node_modules|resources|bower_components)/ + }] + + webpackMiddleware: + # True will suppress error shown in console, so it has to be set to false. + quiet: false + # Suppress everything except error, so it has to be set to false as well + # to see success build. + noInfo: false + stats: + # Config for minimal console.log mess. + assets: false, + colors: true, + version: false, + hash: false, + timings: false, + chunks: false, + chunkModules: false + + + plugins:[ + require('karma-phantomjs-shim') + require('karma-mocha') + require('karma-webpack') + require('karma-mocha-reporter') + require('karma-phantomjs-launcher') + require('karma-chai') + require('karma-chai-sinon') + require('karma-sourcemap-loader') + ] diff --git a/exercises/test/config/karma.config.coffee b/exercises/test/config/karma.config.coffee new file mode 100644 index 0000000000..bc95887459 --- /dev/null +++ b/exercises/test/config/karma.config.coffee @@ -0,0 +1,5 @@ +webpack = require('webpack') +commonConfig = require './karma.common' + +module.exports = (config) -> + config.set commonConfig diff --git a/exercises/test/config/test-runner.coffee b/exercises/test/config/test-runner.coffee new file mode 100644 index 0000000000..278e59e0b1 --- /dev/null +++ b/exercises/test/config/test-runner.coffee @@ -0,0 +1,69 @@ +_ = require 'underscore' +#gulpKarma = require 'gulp-karma' +Karma = require 'karma' +gulp = require 'gulp' +gutil = require 'gulp-util' +moment = require 'moment' +path = require 'path' +spawn = require('child_process').spawn +fileExists = require 'file-exists' +_ = require 'underscore' + +class TestRunner + + isKarmaRunning: false + + pendingSpecs: [] + + runKarma: -> + specs = _.unique @pendingSpecs + + # no need to start a server if there are no pending specs, or the spec list hasn't changed + return if _.isEmpty(@pendingSpecs) + if _.isEqual(@curSpecs?.sort(), specs.sort()) + @pendingSpecs = [] + return + + # if there's already a karma instance running, then kill it, since we're starting a new one + if @isKarmaRunning and @child + process.kill(@child.pid, 'SIGTERM') + @child = null + + @isKarmaRunning = true + gutil.log("[specs]", gutil.colors.green("testing #{specs.join(' ')}")) + @pendingSpecs = [] + startAt = moment() + + @child = spawn( 'node', [ + path.join(__dirname, 'karma-in-background.js'), + JSON.stringify(specs) + ], {stdio: 'inherit'} ) + + #save the spec list that is being run + @curSpecs = specs[...] + + @child.on('exit', (exitCode) => + @isKarmaRunning = false + duration = moment.duration(moment().diff(startAt)) + elapsed = duration.minutes() + ':' + duration.seconds() + gutil.log("[test]", gutil.colors.green("done. #{specs.length} specs in #{elapsed}")) + ) + + onFileChange: (change) -> + if change.relative.match(/^src/) + testPath = change.relative.replace('src', 'test') + testPath.replace(/\.(\w+)$/, ".spec.coffee") + spec = testPath.replace(/\.(\w+)$/, ".spec.coffee") + existingSpecs = _.select([ + testPath.replace(/\.(\w+)$/, ".spec.cjsx"), testPath.replace(/\.(\w+)$/, ".spec.coffee") + ], fileExists) + if _.isEmpty(existingSpecs) + gutil.log("[change]", gutil.colors.red("no spec was found for #{change.relative}")) + else + @pendingSpecs.push(existingSpecs...) + else + @pendingSpecs.push(change.relative) + gutil.log("[test]", gutil.colors.green("pending: #{@pendingSpecs.join(' ')}")) if @pendingSpecs.length + @runKarma() + +module.exports = TestRunner diff --git a/exercises/test/example.js b/exercises/test/example.js new file mode 100644 index 0000000000..9a8032bf6a --- /dev/null +++ b/exercises/test/example.js @@ -0,0 +1,166 @@ +window.config = { + logic: { + inputs: { + scale: { + start: 1, + end: 3 + }, + mass: { + start: 1, + end: 3 + }, + speed: { + start: 1, + end: 3 + } + }, + outputs: { + ship_mass: function(_arg) { + var mass, scale, speed; + scale = _arg.scale, mass = _arg.mass, speed = _arg.speed; + return scale * Math.pow(100, mass); + }, + ship_speed: function(_arg) { + var mass, scale, speed; + scale = _arg.scale, mass = _arg.mass, speed = _arg.speed; + return scale * Math.pow(10, speed); + }, + ship_force: function(_arg) { + var mass, scale, speed; + scale = _arg.scale, mass = _arg.mass, speed = _arg.speed; + return scale * Math.pow(100, mass) * scale * Math.pow(10, speed); + }, + ship_mass_grams: function(_arg) { + var mass, scale, speed; + scale = _arg.scale, mass = _arg.mass, speed = _arg.speed; + return scale * Math.pow(100, mass) * 1000; + }, + ship_mass_div_speed: function(_arg) { + var mass, scale, speed; + scale = _arg.scale, mass = _arg.mass, speed = _arg.speed; + return scale * Math.pow(100, mass) / scale * Math.pow(10, speed); + } + } + }, + stimulus_html: 'This exercise has many parts. Each one is a different type of question. Einstein makes a {{ ship_mass }} kg spaceship', + questions: [ + { + formats: ['short-answer'], + stimulus_html: 'The spaceship moves at {{ ship_speed }} m/s', + stem_html: 'What is the rest mass in kg?', + answers: [ + { + content_html: '{{ ship_mass }}' + } + ] + }, { + formats: ['multiple-choice', 'multiple-select', 'short-answer'], + stem_html: 'What is the force if it slams into a wall?', + short_stem_html: 'Enter your answer in N', + answers: [ + { + id: 'id1', + value: '{{ ship_force }}', + content_html: '{{ ship_force }} N' + }, { + id: 'id2', + value: '{{ ship_mass }}', + content_html: '{{ ship_mass }} N', + hint: 'Remember 1 Newton (N) is 1 kg*m/s' + } + ], + correct: 'id1' + }, { + formats: ['multiple-select', 'multiple-choice', 'short-answer'], + stem_html: 'What is the force if it slams into a wall? (this has (a) and (b) options)', + short_stem_html: 'Enter your answer in N', + answers: [ + { + id: 'wrong', + content_html: '0' + }, { + id: 'wrong2', + content_html: '1' + }, { + id: 'id123', + content_html: '{{ ship_force }} N' + }, { + id: 'id456', + content_html: '{{ ship_force }} + 0
    + 0
    + 0 N' + }, { + id: 'id567', + values: ['id456', 'id123'] + } + ], + correct: 'id567' + }, + { + formats: ['fill-in-the-blank', 'true-false', 'multiple-choice'], + stimulus_html: 'Simple fill-in-the-blank questions', + stem_html: 'If the ship is traveling {{ ship_speed }} m/s and slams into a wall, the impact force is ____ N.', + answers: [ + { + content_html: '{{ ship_force }}' + }, { + content_html: '{{ ship_mass_div_speed }}', + hint: 'Remember 1 Newton (N) is 1 kg*m/s' + } + ], + correct: '{{ ship_force }}' + }, { + formats: ['fill-in-the-blank', 'true-false'], + stem_html: 'Photosynthesis ____ ATP', + answers: [ + { + content_html: 'creates' + }, { + content_html: 'smells like' + } + ], + correct: 'creates' + }, + { + formats: ['matching'], + stimulus_html: 'Matching question (for draw-a-line-to-match)', + stem_html: 'Match the words on the left with words on the right by drawing a line', + items: ['foot', 'head', 'hand'], + answers: [ + { + credit: 1, + content_html: 'sock' + }, { + credit: 1, + content_html: 'hat' + }, { + credit: 1, + content_html: 'glove' + } + ] + }, + { + formats: ['short-answer'], + stimulus_html: 'These questions have aleady been answered by the student and are meant to test that the Exercise knows not to render radio buttons, input boxes, etc', + stem_html: 'What is 2+2?', + answer: '42' + }, { + formats: ['fill-in-the-blank'], + stem_html: '2+2 is ____', + answer: '0', + correct: '4' + }, { + formats: ['multiple-choice'], + stem_html: 'What is 2+2?', + answers: [ + { + id: 'id1', + content_html: '4' + }, { + id: 'id2', + content_html: '42' + } + ], + correct: 'id1', + answer: 'id1' + } + ] +}; diff --git a/exercises/test/example.json b/exercises/test/example.json new file mode 100644 index 0000000000..5f097708a0 --- /dev/null +++ b/exercises/test/example.json @@ -0,0 +1 @@ +{"uid":"1@1","number":1,"version":1,"editors":[],"authors":[],"copyright_holders":[],"derived_from":[],"attachments":[],"title":"Sunt eum iste","stimulus_html":"\u003cspan style=\"font-size: 18px;\"\u003eThis \u003c/span\u003e\u003ci style=\"font-size: 18px;\"\u003eExercise\u003c/i\u003e\u003cspan style=\"font-size: 18px;\"\u003e has several questions that test the rendering and \u003cb\u003eediting\u003c/b\u003e of questions. Some are simple and some are more complicated in one aspect or another.\u003c/span\u003e","questions":[{"stimulus_html":"\u003cdiv\u003eQuaerat dolorum sint facilis minima tempora voluptas sit. Minima impedit eos cupiditate rerum. Ut quia a fugit. Sint dolorum rerum enim facere omnis adipisci ad.\u003c/div\u003e","stem_html":"What is \u003cspan data-math=\"1+2^3\"\u003e1+2^3\u003c/span\u003e?","answers":[{"content_html":"\u003cspan data-math=\"123\"\u003e123\u003c/span\u003e"},{"content_html":"24"},{"content_html":"9"},{"content_html":"10"}],"hints":[],"formats":["multiple-choice"],"combo_choices":[]},{"stimulus_html":"","stem_html":"What \u003cb\u003eis\u003c/b\u003e the capitol of France?","answers":[{"content_html":"Tolouse"},{"content_html":"Champagne"},{"content_html":"Dijon"},{"content_html":"Paris"},{"content_html":"\u003cdiv\u003eNice\u003c/div\u003e\u003cdiv\u003ebut not many are ; )\u003c/div\u003e\u003cdiv\u003enice, that is\u003c/div\u003e"},{"content_html":"Bordeaux"}],"hints":[],"formats":["multiple-choice"],"combo_choices":[]},{"stimulus_html":"","stem_html":"\u003cdiv\u003eLorem \u003ci\u003eipsum\u003c/i\u003e sina lorem sina ipsum sina. \u003cspan data-math=\"Lorem_2^3\"\u003eLorem_2^3\u003c/span\u003e ipsum sina lorem sina ipsum sina. Lorem ipsum sina lorem sina ipsum. Lorem ipsum sina lorem sina ipsum. \u003c/div\u003e\u003cdiv\u003eLorem ipsum sina lorem sina \u003cspan data-math=\"\\frac {\\sqrt{b^2 - 4ac}} 2 + 4\"\u003e\\frac {\\sqrt{b^2 - 4ac}} 2 + 4\u003c/span\u003e. Lorem ipsum sina lorem sina ipsum sina.Lorem ipsum sina lorem sina ipsum sina.Lorem ipsum sina lorem sina ipsum sina.\u003c/div\u003e\u003cdiv\u003eQuaerat dolorum sint facilis minima tempora voluptas sit. Minima impedit eos cupiditate rerum. Ut quia a fugit. Sint dolorum rerum enim facere omnis adipisci ad.\u003c/div\u003e\u003cdiv\u003eAnd some lists:\u003c/div\u003e\u003cul\u003e\n\u003cli\u003ered fish\u003c/li\u003e\n\u003cli\u003eblue fish\u003c/li\u003e\n\u003c/ul\u003e\u003cdiv\u003eordered:\u003c/div\u003e\u003col\u003e\n\u003cli\u003eone fish\u003c/li\u003e\n\u003cli\u003etwo fish\u003c/li\u003e\n\u003c/ol\u003e","answers":[{"content_html":"A harum voluptatum eveniet incidunt voluptatem totam exercitationem. Ducimus sit error quis tempore blanditiis aperiam. Voluptatem ex et sapiente nobis. Quia non tempore dolorum nisi similique distinctio."},{"content_html":"Quaerat dolorum sint facilis minima tempora voluptas sit. Minima impedit eos cupiditate rerum. Ut quia a fugit. Sint dolorum rerum enim facere omnis adipisci ad."},{"content_html":"Est quo eos voluptas nesciunt porro harum. Officiis eum est id. Maxime ut reprehenderit officiis quo voluptas autem."}],"hints":[],"formats":["multiple-choice"],"combo_choices":[]},{"stimulus_html":"","stem_html":"What is \u003cspan data-math=\"2+2\"\u003e2+2\u003c/span\u003e?","answers":[{"content_html":"22"},{"content_html":"7"},{"content_html":"4"},{"content_html":"0"}],"hints":[],"formats":["multiple-choice"],"combo_choices":[]},{"stimulus_html":"","stem_html":"What is a \u003cb\u003eFree Response Question\u003c/b\u003e?","answers":[],"hints":[],"formats":["free-response"],"combo_choices":[]}]} diff --git a/exercises/test/karma.config.coffee b/exercises/test/karma.config.coffee new file mode 100644 index 0000000000..c87e4e162b --- /dev/null +++ b/exercises/test/karma.config.coffee @@ -0,0 +1,11 @@ +module.exports = (config) -> + config.set + basePath: '../' + frameworks: ['mocha'] + browsers: ['PhantomJS'] + reporters: ['mocha'] + # plugins: ['karma-mocha', 'karma-phantomjs-launcher', 'karma-mocha-reporter'] + files: [ + {pattern: 'test/phantomjs-shims.js'} + {pattern: '.tmp/all-tests.js'} + ] diff --git a/test/phantomjs-shims.js b/exercises/test/phantomjs-shims.js similarity index 100% rename from test/phantomjs-shims.js rename to exercises/test/phantomjs-shims.js diff --git a/exercises/webpack.config.coffee b/exercises/webpack.config.coffee new file mode 100644 index 0000000000..f6865ab592 --- /dev/null +++ b/exercises/webpack.config.coffee @@ -0,0 +1,68 @@ +path = require 'path' +ExtractTextPlugin = require 'extract-text-webpack-plugin' + +isProduction = process.env.NODE_ENV is 'production' +LOADERS = if isProduction then [] else ["react-hot", "webpack-module-hot-accept"] +lessLoader = if isProduction + { test: /\.less$/, loader: ExtractTextPlugin.extract('css!less') } +else + { test: /\.less$/, loaders: LOADERS.concat('style-loader', 'css-loader', 'less-loader') } + +module.exports = + cache: true + + devtool: if isProduction then undefined else 'source-map' + + entry: + exercises: [ + './src/index.coffee', + './resources/styles/app.less' + ] + + output: + path: if isProduction then 'dist' else '/' + filename: '[name].js' + publicPath: if isProduction then '' else 'http://localhost:8001/dist/' + + plugins: [ new ExtractTextPlugin('exercises.css') ] + + module: + noParse: [ + /\/sinon\.js/ + ] + loaders: [ + lessLoader + { test: /\.json$/, loader: "json-loader" } + { test: /\.coffee$/, loaders: LOADERS.concat("coffee-loader") } + { test: /\.cjsx$/, loaders: LOADERS.concat("coffee-jsx-loader") } + { test: /\.(png|jpg|svg|gif)/, loader: 'file-loader?name=[name].[ext]'} + { test: /\.(woff|woff2|eot|ttf)/, loader: "url-loader?limit=30000&name=[name]-[hash].[ext]" } + ] + resolve: + root: [ path.resolve(__dirname, './src') ] + extensions: ['', '.js', '.json', '.coffee', '.cjsx'] + + devServer: + contentBase: './' + publicPath: 'http://localhost:8001/dist/' + historyApiFallback: true + inline: true + port: process.env['PORT'] or 8001 + # It suppress error shown in console, so it has to be set to false. + quiet: false, + # It suppress everything except error, so it has to be set to false as well + # to see success build. + noInfo: false + host: 'localhost', + outputPath: '/', + filename: '[name].js', + hot: true + stats: + # Config for minimal console.log mess. + assets: false, + colors: true, + version: false, + hash: false, + timings: false, + chunks: false, + chunkModules: false diff --git a/package.json b/package.json index 82cb6b4d85..edbe6da105 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,17 @@ { - "name": "openstax-tutor", + "name": "openstax-frontend", "version": "0.0.0", - "description": "Client for OpenStax Tutor", + "description": "Frontend Code for OpenStax", "main": "index.js", "scripts": { - "ci": "npm run test", - "test": "npm run coverage", + "ci": "bin/test-ci", + "test": "bin/test", + "tdd": "bin/tdd", "precoverage": "npm run validate", "coverage": "gulp coverage", - "build": "gulp build", - "build-archive": "gulp build-archive", + "serve": "bin/serve", + "build": "bin/build", + "build-archive": "bin/build tutor archive", "debug-integration": "echo 'This may require you to kill the mocha process (search for 5858)' && node-debug --hidden 'node_modules/' ./scripts/run-integration-mocha.js --bail ./test-integration/index", "test-integration:only": "mocha --bail -R spec ./test-integration/index", "pretest-integration": "if [ -z ${SERVER_URL} ]; then NODE_COVERAGE=true npm run build; fi", @@ -37,17 +39,21 @@ "bootstrap-material-design": "0.3.0", "camelcase": "1.2.1", "classnames": "2.1.5", + "dateformat": "1.0.12", "es6-promise": "2.3.0", + "eventemitter2": "0.4.14", "flux-react": "2.6.6", "font-awesome": "4.4.0", + "history": "^2.1.0", "htmlparser2": "3.8.3", + "interpolate": "0.1.0", + "jquery": "2.1.4", "keymaster": "1.6.2", - "lodash": "2.4.1", + "lodash": "4.14.1", "markdown-it": "4.4.0", "mime-types": "2.1.7", "moment": "2.13.0", "moment-timezone": "0.5.4", - "openstax-react-components": "openstax/react-components#d-20160729.a", "pluralize": "1.2.1", "promise-loader": "1.0.0", "qs": "6.1.0", @@ -59,7 +65,9 @@ "react-router": "0.13.4", "react-scroll-components": "0.2.2", "react-waypoint": "openstax/react-waypoint#b34b248947feb0b0ebf89fcef6f234f60f3e516c", + "react-widgets": "^2.8", "recordo": "0.0.5", + "svg-url-loader": "^1.1.0", "twix": "1.0.0", "ultimate-pagination": "0.7.0", "underscore": "1.8.3", @@ -88,6 +96,7 @@ "csdoc": "0.1.4", "css-loader": "0.22.0", "del": "1.1.1", + "es6-promise": "^3.0.2", "eslint": "1.10.3", "extract-text-webpack-plugin": "0.8.2", "file-exists": "0.1.1", @@ -132,6 +141,7 @@ "webpack": "1.12.2", "webpack-dev-server": "1.12.0", "webpack-module-hot-accept": "1.0.3", + "webpack-umd-external": "1.0.2", "when": "3.7.3" }, "config": { diff --git a/shared/.gitignore b/shared/.gitignore new file mode 100644 index 0000000000..f7f6419eb5 --- /dev/null +++ b/shared/.gitignore @@ -0,0 +1,6 @@ +/node_modules +*.orig +/.tmp +/dist +/assets +/test-failed-*.png diff --git a/shared/.travis.yml b/shared/.travis.yml new file mode 100644 index 0000000000..27ce7a9f0b --- /dev/null +++ b/shared/.travis.yml @@ -0,0 +1,7 @@ +# `sudo:false` for faster builds. +sudo: false +language: node_js +node_js: + - "0.10" +after_failure: + - npm list diff --git a/shared/Gulpfile.coffee b/shared/Gulpfile.coffee new file mode 100644 index 0000000000..494c02d95e --- /dev/null +++ b/shared/Gulpfile.coffee @@ -0,0 +1,128 @@ +_ = require 'underscore' +coffeelint = require 'gulp-coffeelint' +del = require 'del' +env = require 'gulp-env' +gulp = require 'gulp' +gutil = require 'gulp-util' +gzip = require 'gulp-gzip' +karma = require 'karma' +rev = require 'gulp-rev' +tar = require 'gulp-tar' +watch = require 'gulp-watch' +webpack = require 'webpack' +webpackServer = require 'webpack-dev-server' +WPExtractText = require 'extract-text-webpack-plugin' + +getWebpackConfig = require './webpack.config' + +TestRunner = require './test/config/test-runner' + +KARMA_DEV_CONFIG = + configFile: __dirname + '/test/karma-dev.config.coffee' + singleRun: false + +KARMA_CONFIG = + configFile: __dirname + '/test/config/karma.config.coffee' + singleRun: true + +KARMA_COVERAGE_CONFIG = + configFile: __dirname + '/test/config/karma-coverage.config.coffee' + singleRun: true + +DIST_DIR = './dist' + +# ----------------------------------------------------------------------- +# Build Javascript and Styles using webpack +# ----------------------------------------------------------------------- +gulp.task '_cleanDist', (done) -> + del(['./dist/*'], done) + +gulpWebpack = (name) -> + env(vars:{ NODE_ENV: 'production' }) + config = getWebpackConfig(name, process.env.NODE_ENV is 'production') + webpack(config, (err, stats) -> + throw new gutil.PluginError("webpack", err) if err + gutil.log("[webpack]", stats.toString({ + # output options + })) + ) + +gulp.task '_buildMain', _.partial(gulpWebpack, 'main') +gulp.task '_buildMainMin', _.partial(gulpWebpack, 'main.min') +gulp.task '_buildFull', _.partial(gulpWebpack, 'fullBuild') +gulp.task '_buildFullMin', _.partial(gulpWebpack, 'fullBuild.min') +gulp.task '_buildDemo', _.partial(gulpWebpack, 'demo') + +gulp.task '_build', ['_cleanDist', '_buildMain', '_buildMainMin', '_buildFull', '_buildFullMin'] + +gulp.task '_tagRev', ['_build'], -> + gulp.src("#{DIST_DIR}/*.min.*") + .pipe(rev()) + .pipe(gulp.dest(DIST_DIR)) + .pipe(rev.manifest()) + .pipe(gulp.dest(DIST_DIR)) + +# ----------------------------------------------------------------------- +# Production +# ----------------------------------------------------------------------- + +gulp.task '_archive', ['_tagRev'], -> + gulp.src(["#{DIST_DIR}/*"], base: DIST_DIR) + .pipe(tar('archive.tar')) + .pipe(gzip()) + .pipe(gulp.dest(DIST_DIR)) + +# ----------------------------------------------------------------------- +# Development +# ----------------------------------------------------------------------- +# +gulp.task '_karma', -> + server = new karma.Server(KARMA_DEV_CONFIG) + server.start() + +# TODO will rewrite this to fit new config +gulp.task '_webserver', -> + env(vars:{ NODE_ENV: 'development' }) + config = getWebpackConfig('devServer', process.env.NODE_ENV is 'production') + server = new webpackServer(webpack(config), config.devServer) + server.listen(config.devServer.port, '0.0.0.0', (err) -> + throw new gutil.PluginError("webpack-dev-server", err) if err + ) + +# ----------------------------------------------------------------------- +# Public Tasks +# ----------------------------------------------------------------------- +# +# External tasks called by various people (devs, testers, Travis, production) +# +# TODO: Add this to webpack +gulp.task 'lint', -> + gulp.src(['./src/**/*.{cjsx,coffee}', './*.coffee', './test/**/*.{cjsx,coffee}']) + .pipe(coffeelint()) + # Run through both reporters so lint failures are visible and Travis will error + .pipe(coffeelint.reporter()) + .pipe(coffeelint.reporter('fail')) + +gulp.task 'prod', ['_archive'] + +gulp.task 'serve', ['_webserver'] + +gulp.task 'test', ['lint'], (done) -> + server = new karma.Server(KARMA_CONFIG) + server.start() + +gulp.task 'coverage', -> + server = new karma.Server(KARMA_COVERAGE_CONFIG) + server.start() + +# clean out the dist directory before running since otherwise stale files might be served from there. +# The _webserver task builds and serves from memory with a fallback to files in dist +gulp.task 'dev', ['_cleanDist', '_webserver'] + +gulp.task 'tdd', ['_cleanDist', '_webserver'], -> + runner = new TestRunner() + watch('{src,test}/**/*', (change) -> + runner.onFileChange(change) unless change.unlink + ) + +gulp.task 'demo', ['_buildDemo'] diff --git a/shared/README.md b/shared/README.md new file mode 100644 index 0000000000..90206dd53d --- /dev/null +++ b/shared/README.md @@ -0,0 +1,83 @@ +# Shared FE things across OpenStax repos + +## How to use in another repo + +### Install + +You can install from npm (or bower) like so: + +```bash +npm install --save openstax/react-components#v0.0.0 +``` + +This will add the following line to your `package.json`: + +```js + "openstax-react-components": "github:openstax/react-components#v0.0.0" +``` + +For Travis to be able to install, you will need to change this to: + +```js + "openstax-react-components": "openstax/react-components#v0.0.0" +``` + +### Use + +Once installed, you need only to require it and use it like so: + +```coffeescript +{ArbitraryHtmlAndMath, Question, CardBody, FreeResponse} = require 'openstax-react-components' + + + +``` + +Should you be using this in a project without React, you can require it like this: + +```coffeescript +{ArbitraryHtmlAndMath, Question, CardBody, FreeResponse} = require 'openstax-react-components/full-build.min' + +# Mount like this +FreeResponse(DOMNode, props) + +``` + +Support for this will be deprecated. + +## Current exposed things + +As [seen here](https://github.com/openstax/react-components/blob/master/index.cjsx) + +### Exercise related components + +* Exercise +* ExerciseGroup +* FreeResponse + +### Pinned card set components + +* PinnedHeaderFooterCard +* PinnedHeader +* CardBody +* PinnableFooter + +### Smaller display components + +* Question +* ArbitraryHtmlAndMath + +### Button Components + +* RefreshButton +* AsyncButton + +### Notices +* NotificationsBar +* NotificationActions (polling for notifications) + +### Mixins + +* ChapterSectionMixin +* GetPositionMixin +* ResizeListenerMixin diff --git a/shared/api/breadcrumbs/steps.json b/shared/api/breadcrumbs/steps.json new file mode 100644 index 0000000000..1da0230909 --- /dev/null +++ b/shared/api/breadcrumbs/steps.json @@ -0,0 +1,15 @@ +{ + "steps": [{ + "is_completed": true, + "answer_id": "3", + "type": "exercise" + }, { + "is_completed": true, + "is_correct": true, + "type": "exercise" + }, { + "type": "exercise" + }, { + "type": "reading" + }] +} \ No newline at end of file diff --git a/shared/api/exercise-multipart/index.coffee b/shared/api/exercise-multipart/index.coffee new file mode 100644 index 0000000000..1342e25b2d --- /dev/null +++ b/shared/api/exercise-multipart/index.coffee @@ -0,0 +1,35 @@ +_ = require 'lodash' +steps = require './steps' + +stepsStubs = {} + +TASK_ID = '40' +commonInfo = + content_url: 'https://exercises-dev.openstax.org/exercises/120@1' + group: 'core' + related_content: [ + title: 'Physics is cool, yo' + chapter_section: '1.3' + ] + task_id: TASK_ID + +# stem and stimulus here as well to replicate real JSON +commonContent = + stimulus_html: 'This stim should only show up once.' + stem_html: 'This stem should only show up once.' + uid: '120@1' + +assignStepToTask = (step, stepIndex) -> + step.content = _.extend {}, commonContent, step.content + _.extend {questionNumber: (stepIndex + 1)}, {stepIndex: stepIndex}, commonInfo, step + + +_.forEach steps, (step, stepIndex) -> + stepStubs = + 'free-response': assignStepToTask _.omit(step, 'correct_answer_id', 'feedback_html'), stepIndex + 'multiple-choice': assignStepToTask _.omit(step, 'correct_answer_id', 'feedback_html'), stepIndex + 'review': assignStepToTask _.clone(step), stepIndex + + stepsStubs[step.id] = stepStubs + +module.exports = stepsStubs diff --git a/shared/api/exercise-multipart/steps.js b/shared/api/exercise-multipart/steps.js new file mode 100644 index 0000000000..96855d5f58 --- /dev/null +++ b/shared/api/exercise-multipart/steps.js @@ -0,0 +1,63 @@ +var steps = [{ "id": "step-id-9-1", + "type": "exercise", + "correct_answer_id": "id2", + "is_in_multipart": true, + "context": "Mathematics is an old, broad, and deep discipline (field of study). People working to improve math education need to understand \"What is Mathematics?\"", + "content": { + "questions":[ + { + "id": "987", + "formats": ["multiple-choice", "free-response"], + "stimulus_html": "Addition is fun", + "stem_html":"What is 2+2?", + "answers":[ + {"id":"id1","content_html":"22"}, + {"id":"id2","content_html":"4"} + ] + } + ] + } + }, + { "id": "step-id-9-2", + "type": "exercise", + "correct_answer_id": "id3", + "is_in_multipart": true, + "content": { + "questions":[ + { + "id": "876", + "formats": ["multiple-choice", "free-response"], + "stimulus_html": "Stimulus for Second Exercise", + "stem_html":"Is the glass half full or half empty?", + "answers":[ + {"id":"id3","content_html":"Half Full"}, + {"id":"id4","content_html":"Half Empty"} + ] + } + ] + } + }, + { "id": "step-id-9-3", + "type": "exercise", + "correct_answer_id": "idn2", + "is_in_multipart": true, + "has_recovery": true, + "feedback_html": "Two apples and then 2 more apples is four", + "content": { + "questions":[ + { + "id": "990", + "formats": ["multiple-choice", "free-response"], + "stimulus_html": "Multiplication is fun", + "stem_html":"What is 4+4?", + "answers":[ + {"id":"idn1","content_html":"222"}, + {"id":"idn2","content_html":"42"} + ] + } + ] + } + } +]; + +module.exports = steps; diff --git a/shared/api/exercise-preview/data.json b/shared/api/exercise-preview/data.json new file mode 100644 index 0000000000..3e7e5947df --- /dev/null +++ b/shared/api/exercise-preview/data.json @@ -0,0 +1,256 @@ +{ + "id": "20", + "url": "https:\/\/exercises-dev.openstax.org\/exercises\/20@2", + "preview": "Read the above content and then watch this video about DNA:\n
    Video<\/div>\n\nAfter watching it, keep it's important lessions in mind while you answer the following questions", + "has_video": true, + "has_interactive": false, + "is_excluded": false, + "pool_types": [ + "homework_core" + ], + "context": "
    \n
    Where Societies Meet—The Worst and the Best
    \n

    When cultures meet, technology can help, hinder, and even destroy. The Exxon Valdez oil spillage in Alaska nearly destroyed the local inhabitant’s entire way of life. Oil spills in the Nigerian Delta have forced many of the Ogoni tribe from their land and forced removal has meant that over 100,000 Ogoni have sought refuge in the country of Benin (University of Michigan, n.d.). And the massive Deepwater Horizon oil spill of 2006 drew great attention as it occurred in what is the most developed country, the United States. Environmental disasters continue as Western technology and its need for energy expands into less developed (peripheral) regions of the globe.

    \n

    Of course not all technology is bad. We take electric light for granted in the United States, Europe, and the rest of the developed world. Such light extends the day and allows us to work, read, and travel at night. It makes us safer and more productive. But regions in India, Africa, and elsewhere are not so fortunate. Meeting the challenge, one particular organization, Barefoot College, located in District Ajmer, Rajasthan, India, works with numerous less developed nations to bring solar electricity, water solutions, and education. The focus for the solar projects is the village elders. The elders agree to select two grandmothers to be trained as solar engineers and choose a village committee composed of men and women to help operate the solar program.\n

    \n

    The program has brought light to over 450,000 people in 1,015 villages. The environmental rewards include a large reduction in the use of kerosene and in carbon dioxide emissions. The fact that the villagers are operating the projects themselves helps minimize their sense of dependence. \n

    \n
    Otherwise skeptic or hesitant villagers are more easily convinced of the value of the solar project when they realize that the “solar engineers” are their local grandmothers. (Photo courtesy of \nAbri le Roux/flickr)
    \n \"Alt\n \n
    \n
    ", + "tags": [ + { + "data": "cb7cf05b-7e16-4a53-a498-003b01ec3d7f", + "is_visible": false, + "type": "cnxmod", + "id": "context-cnxmod:cb7cf05b-7e16-4a53-a498-003b01ec3d7f" + }, + { + "is_visible": true, + "chapter_section": [ + 1, + 2 + ], + "description": "Recognize and interpret a phylogenetic tree", + "type": "lo", + "id": "apbio-ch01-s02-lo03" + }, + { + "is_visible": false, + "type": "generic", + "id": "apbio" + }, + { + "is_visible": false, + "type": "generic", + "id": "apbio-ch01" + }, + { + "data": "4", + "is_visible": true, + "name": "Blooms: 4", + "type": "blooms", + "id": "blooms-4" + }, + { + "is_visible": false, + "type": "generic", + "id": "book:stax-bio" + }, + { + "data": "4", + "is_visible": true, + "name": "Blooms: 4", + "type": "blooms", + "id": "blooms:4" + }, + { + "is_visible": false, + "type": "generic", + "id": "type:practice" + }, + { + "is_visible": false, + "type": "generic", + "id": "book:stax-apbio" + }, + { + "is_visible": false, + "type": "generic", + "id": "filter-type:import:hs" + }, + { + "is_visible": false, + "type": "generic", + "id": "ost-chapter-review" + }, + { + "data": "long", + "is_visible": true, + "name": "Length: L", + "type": "length", + "id": "time-long" + }, + { + "data": "long", + "is_visible": true, + "name": "Length: L", + "type": "length", + "id": "time:long" + }, + { + "is_visible": false, + "type": "generic", + "id": "critical-thinking" + }, + { + "data": "4", + "is_visible": true, + "name": "DOK: 4", + "type": "dok", + "id": "dok4" + }, + { + "data": "4", + "is_visible": true, + "name": "DOK: 4", + "type": "dok", + "id": "dok:4" + }, + { + "is_visible": false, + "chapter_section": [ + 1, + 2 + ], + "type": "generic", + "id": "apbio-ch01-s02" + }, + { + "is_visible": false, + "type": "generic", + "id": "apbio-ch01-ex020" + }, + { + "is_visible": false, + "type": "generic", + "id": "exid:apbio-ch01-ex020" + } + ], + "content": { + "questions": [ + { + "combo_choices": [], + "formats": [ + "free-response", + "multiple-choice" + ], + "hints": [], + "community_solutions": [], + "collaborator_solutions": [ + { + "content_html": "Woese mainly used the sequence of ribosomal RNA subunits.", + "solution_type": "detailed", + "attachments": [] + } + ], + "answers": [ + { + "feedback_html": "Woese did not use a sequence of DNA. He used a sequence of rRNA.", + "correctness": "0.0", + "content_html": "a sequence of DNA", + "id": 24949 + }, + { + "feedback_html": "Woese used a sequence of rRNA as evidence to determine that there should be a separate domain for Archaea.", + "correctness": "1.0", + "content_html": "a sequence of rRNA", + "id": 24950 + }, + { + "feedback_html": "A sequence of rRNA, not mRNA, was used.", + "correctness": "0.0", + "content_html": "a sequence of mRNA.", + "id": 24951 + }, + { + "feedback_html": "rRNA was used as a scientific evidence by Carl Woese.", + "correctness": "0.0", + "content_html": "a sequence of tRNA.", + "id": 24952 + } + ], + "stem_html": "What scientific evidence was used by Carl Woese to determine there should be a separate domain for Archaea?", + "stimulus_html": "", + "is_answer_order_important": true, + "id": 6403 + }, + { + "combo_choices": [], + "formats": [ + "true-false" + ], + "hints": [], + "community_solutions": [], + "collaborator_solutions": [ + { + "content_html": "Based on the above do you still think so?", + "solution_type": "detailed", + "attachments": [] + } + ], + "answers": [ + { + "feedback_html": "Really?", + "correctness": "1.0", + "content_html": "YES", + "id": 24953 + }, + { + "feedback_html": "I thought so.", + "correctness": "0.0", + "content_html": "NO", + "id": 24954 + } + ], + "stem_html": "Do you think the video was helpful?", + "stimulus_html": "", + "is_answer_order_important": false, + "id": 6404 + } + ], + "stimulus_html": "Read the above content and then watch this video about DNA:\n
    " +} \ No newline at end of file diff --git a/shared/api/notifications.json b/shared/api/notifications.json new file mode 100644 index 0000000000..5da6e15acf --- /dev/null +++ b/shared/api/notifications.json @@ -0,0 +1,7 @@ +[{ + "id": "1", + "message": "Warning: Updates will happen soon! Site will be down." +}, { + "id": "2", + "message": "These are test messages." +}] diff --git a/shared/coffeelint.json b/shared/coffeelint.json new file mode 100644 index 0000000000..461c0b7c80 --- /dev/null +++ b/shared/coffeelint.json @@ -0,0 +1,49 @@ +{ + "coffeelint": { + "transforms": [ + "coffee-react-transform" + ] + }, + "indentation": { + "level": "error", + "value": 2 + }, + "line_endings": { + "value": "unix", + "level": "error" + }, + "arrow_spacing": { + "level": "error" + }, + "no_empty_functions": { + "level": "error" + }, + "no_empty_param_list": { + "level": "error" + }, + "no_interpolation_in_single_quotes": { + "level": "error" + }, + "no_throwing_strings": { + "level": "error" + }, + "no_unnecessary_double_quotes": { + "level": "ignore" + }, + "no_unnecessary_fat_arrows": { + "level": "error" + }, + "prefer_english_operator": { + "level": "error" + }, + "space_operators": { + "level": "error" + }, + "spacing_after_comma": { + "level": "error" + }, + "max_line_length": { + "value": 135, + "level": "error" + } +} diff --git a/shared/configs/base.coffee b/shared/configs/base.coffee new file mode 100644 index 0000000000..caa9f52c46 --- /dev/null +++ b/shared/configs/base.coffee @@ -0,0 +1,3 @@ +module.exports = + devPort: '3004' + styleFilename: 'main.css' diff --git a/shared/configs/webpack.base.coffee b/shared/configs/webpack.base.coffee new file mode 100644 index 0000000000..d5aa9fd3e5 --- /dev/null +++ b/shared/configs/webpack.base.coffee @@ -0,0 +1,5 @@ +module.exports = + output: + libraryTarget: 'umd' + library: 'OpenStaxReactComponents' + umdNamedDefine: true \ No newline at end of file diff --git a/shared/configs/webpack.debug.coffee b/shared/configs/webpack.debug.coffee new file mode 100644 index 0000000000..bf19c6005e --- /dev/null +++ b/shared/configs/webpack.debug.coffee @@ -0,0 +1,9 @@ +_ = require 'lodash' + +# similar custom configs as production +productionConfig = require './webpack.production' + +debugConfig = _.cloneDeep(productionConfig) +debugConfig.output.filename = 'main.js' + +module.exports = debugConfig diff --git a/shared/configs/webpack.development.coffee b/shared/configs/webpack.development.coffee new file mode 100644 index 0000000000..ace9621a33 --- /dev/null +++ b/shared/configs/webpack.development.coffee @@ -0,0 +1,6 @@ +module.exports = + entry: + demo: [ + 'demo' + 'resources/styles/demo.less' + ] \ No newline at end of file diff --git a/shared/configs/webpack.production.coffee b/shared/configs/webpack.production.coffee new file mode 100644 index 0000000000..bb2c95abfb --- /dev/null +++ b/shared/configs/webpack.production.coffee @@ -0,0 +1,24 @@ +webpack = require 'webpack' +webpackUMDExternal = require 'webpack-umd-external' + +# likely won't need any of this production stuffs since +# we won't be building shared separately anymore. +module.exports = + entry: + main: 'index' + output: + filename: 'main.min.js' + externals: webpackUMDExternal( + react: 'React' + 'react/addons': 'React.addons' + 'react-bootstrap': 'ReactBootstrap' + 'react-scroll-components': 'ReactScrollComponents' + underscore: '_' + ) + plugins: [ + new webpack.ProvidePlugin({ + React: 'react/addons' + _: 'underscore' + BS: 'react-bootstrap' + }) + ] \ No newline at end of file diff --git a/shared/demo.cjsx b/shared/demo.cjsx new file mode 100644 index 0000000000..bfab29087e --- /dev/null +++ b/shared/demo.cjsx @@ -0,0 +1,16 @@ +React = require 'react' +Demo = require './src/components/demo' +{startMathJax} = require './src/helpers/mathjax' + +loadApp = -> + unless document.readyState is 'interactive' + return false + + startMathJax() + mainDiv = document.createElement('div') + mainDiv.id = 'react-root-container' + document.body.appendChild(mainDiv) + React.render(, mainDiv) + true + +loadApp() or document.addEventListener('readystatechange', loadApp) diff --git a/shared/full-build.cjsx b/shared/full-build.cjsx new file mode 100644 index 0000000000..066595a147 --- /dev/null +++ b/shared/full-build.cjsx @@ -0,0 +1,13 @@ +exportsToPassOn = require './index' +mixinsNames = ['ChapterSectionMixin', 'GetPositionMixin', 'ResizeListenerMixin'] + +componentsToExport = _.omit(exportsToPassOn, mixinsNames) +mixins = _.pick(exportsToPassOn, mixinsNames) + +wrapComponent = (component) -> + (DOMNode, props) -> + React.render React.createElement(component, props), DOMNode + +wrappedExports = _.mapObject componentsToExport, wrapComponent + +module.exports = _.extend({}, wrappedExports, mixins) diff --git a/shared/index.cjsx b/shared/index.cjsx new file mode 100644 index 0000000000..254db4ae34 --- /dev/null +++ b/shared/index.cjsx @@ -0,0 +1,35 @@ +{PinnedHeader, CardBody, PinnableFooter} = require './src/components/pinned-header-footer-card/sections' +{Exercise, ExerciseWithScroll} = require './src/components/exercise' + +module.exports = { + ArbitraryHtmlAndMath: require './src/components/html' + AsyncButton: require './src/components/buttons/async-button' + BootrapURLs: require './src/model/urls' + Breadcrumb: require './src/components/breadcrumb' + CardBody, + ChapterSectionMixin: require './src/components/chapter-section-mixin' + CloseButton: require './src/components/buttons/close-button' + + Exercise, + ExerciseGroup: require './src/components/exercise/group' + ExerciseIdentifierLink: require './src/components/exercise-identifier-link' + ExerciseHelpers: require './src/model/exercise' + ExercisePreview: require './src/components/exercise-preview' + ExerciseWithScroll, + + FreeResponse: require './src/components/exercise/free-response' + GetPositionMixin: require './src/components/get-position-mixin' + ScrollToMixin: require './src/components/scroll-to-mixin' + KeysHelper: require './src/helpers/keys' + NotificationActions: require './src/model/notifications' + NotificationsBar: require './src/components/notifications/bar' + PinnableFooter, + PinnedHeader, + PinnedHeaderFooterCard: require './src/components/pinned-header-footer-card' + Question: require './src/components/question' + RefreshButton: require './src/components/buttons/refresh-button' + ResizeListenerMixin: require './src/components/resize-listener-mixin' + SmartOverflow: require './src/components/smart-overflow' + SpyMode: require './src/components/spy-mode' + SuretyGuard: require './src/components/surety-guard' +} diff --git a/shared/index.html b/shared/index.html new file mode 100644 index 0000000000..a74bb1c9ea --- /dev/null +++ b/shared/index.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000000..54f35c7289 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,106 @@ +{ + "name": "openstax-react-components", + "version": "0.0.0", + "description": "OpenStax shared React components", + "main": "index", + "scripts": { + "test": "gulp test && gulp prod", + "coverage": "gulp coverage", + "deployment": "gulp prod", + "test-integration": "mocha -R spec ./test-integration/", + "start": "gulp dev" + }, + "repository": { + "type": "git", + "url": "https://github.com/openstax/react-components.git" + }, + "author": "Philip Schatz ", + "license": "AGPL-3.0", + "bugs": { + "url": "https://github.com/openstax/react-components/issues" + }, + "homepage": "https://github.com/openstax/react-components", + "dependencies": { + "axios": "0.11.0", + "bootstrap": "3.3.5", + "camelcase": "1.2.1", + "classnames": "2.1.5", + "eventemitter2": "0.4.14", + "font-awesome": "4.4.0", + "keymaster": "1.6.2", + "markdown-it": "4.4.0", + "moment": "2.13.0", + "underscore": "1.8.3" + }, + "devDependencies": { + "blanket": "1.1.7", + "chai": "2.1.2", + "chai-as-promised": "5.1.0", + "chromedriver": "2.19.0", + "cjsxify": "0.2.6", + "coffee-jsx-loader": "0.1.3", + "coffee-loader": "0.7.2", + "coffee-react-transform": "3.3.0", + "coffee-script": "1.9.3", + "coffeelint": "1.13.0", + "coffeelint-loader": "0.1.1", + "css-loader": "0.22.0", + "del": "1.1.1", + "es6-promise": "2.3.0", + "extract-text-webpack-plugin": "0.8.2", + "file-exists": "0.1.1", + "file-loader": "0.8.4", + "fixed-data-table": "0.4.1", + "gulp": "3.9.0", + "gulp-coffeelint": "0.4.0", + "gulp-env": "0.2.0", + "gulp-gzip": "1.0.0", + "gulp-rev": "3.0.1", + "gulp-tar": "1.4.0", + "gulp-util": "3.0.6", + "gulp-watch": "4.3.5", + "istanbul-instrumenter-loader": "0.1.3", + "json-loader": "0.5.3", + "karma": "0.13.19", + "karma-chai": "0.1.0", + "karma-chai-sinon": "0.1.5", + "karma-chrome-launcher": "0.2.1", + "karma-coverage": "0.5.2", + "karma-mocha": "0.1.10", + "karma-mocha-reporter": "1.0.4", + "karma-nyan-reporter": "0.2.2", + "karma-phantomjs-launcher": "0.2.1", + "karma-phantomjs-shim": "1.1.1", + "karma-sourcemap-loader": "0.3.6", + "karma-webpack": "1.7.0", + "less": "2.5.3", + "less-loader": "2.2.1", + "lodash": "3.10.1", + "mocha": "2.2.5", + "phantomjs": "1.9.18", + "react": "0.13.1", + "react-bootstrap": ">=0.23.0 <=0.26.2", + "react-hot-loader": "1.3.0", + "react-scroll-components": "0.2.2", + "selenium-webdriver": "2.47.0", + "sinon": "1.17.1", + "sinon-chai": "2.8.0", + "style-loader": "0.12.4", + "url-loader": "0.5.6", + "webpack": "1.12.2", + "webpack-dev-server": "1.12.0", + "webpack-module-hot-accept": "1.0.3", + "webpack-umd-external": "1.0.2", + "when": "3.7.3" + }, + "peerDependencies": { + "react": "^0.13.1", + "react-bootstrap": ">=0.23.0 <=0.26.2", + "react-scroll-components": "^0.2.2" + }, + "config": { + "blanket": { + "pattern": "src" + } + } +} diff --git a/resources/images/icons/icon-calculator.svg b/shared/resources/images/icons/calculator-cl-icon.svg similarity index 100% rename from resources/images/icons/icon-calculator.svg rename to shared/resources/images/icons/calculator-cl-icon.svg diff --git a/shared/resources/images/icons/calculator-hs-icon.svg b/shared/resources/images/icons/calculator-hs-icon.svg new file mode 100644 index 0000000000..8fbab64a55 --- /dev/null +++ b/shared/resources/images/icons/calculator-hs-icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/icons/icon-coach.svg b/shared/resources/images/icons/coach-cl-icon.svg similarity index 100% rename from resources/images/icons/icon-coach.svg rename to shared/resources/images/icons/coach-cl-icon.svg diff --git a/shared/resources/images/icons/coach-hs-icon.svg b/shared/resources/images/icons/coach-hs-icon.svg new file mode 100644 index 0000000000..0014481978 --- /dev/null +++ b/shared/resources/images/icons/coach-hs-icon.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/icons/icon-completed.svg b/shared/resources/images/icons/completed-cl-icon.svg similarity index 100% rename from resources/images/icons/icon-completed.svg rename to shared/resources/images/icons/completed-cl-icon.svg diff --git a/shared/resources/images/icons/completed-hs-icon.svg b/shared/resources/images/icons/completed-hs-icon.svg new file mode 100644 index 0000000000..8d3716549d --- /dev/null +++ b/shared/resources/images/icons/completed-hs-icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/icons/icon-correct.svg b/shared/resources/images/icons/correct-cl-icon.svg similarity index 100% rename from resources/images/icons/icon-correct.svg rename to shared/resources/images/icons/correct-cl-icon.svg diff --git a/shared/resources/images/icons/correct-hs-icon.svg b/shared/resources/images/icons/correct-hs-icon.svg new file mode 100644 index 0000000000..0241b334bd --- /dev/null +++ b/shared/resources/images/icons/correct-hs-icon.svg @@ -0,0 +1,64 @@ + + + +image/svg+xml + + + + + + + \ No newline at end of file diff --git a/resources/images/icons/icon-end.svg b/shared/resources/images/icons/end-cl-icon.svg similarity index 100% rename from resources/images/icons/icon-end.svg rename to shared/resources/images/icons/end-cl-icon.svg diff --git a/shared/resources/images/icons/end-hs-icon.svg b/shared/resources/images/icons/end-hs-icon.svg new file mode 100644 index 0000000000..232b624ba0 --- /dev/null +++ b/shared/resources/images/icons/end-hs-icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/icons/icon-event.svg b/shared/resources/images/icons/event-cl-icon.svg similarity index 100% rename from resources/images/icons/icon-event.svg rename to shared/resources/images/icons/event-cl-icon.svg diff --git a/shared/resources/images/icons/event-hs-icon.svg b/shared/resources/images/icons/event-hs-icon.svg new file mode 100644 index 0000000000..b03298884f --- /dev/null +++ b/shared/resources/images/icons/event-hs-icon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/icons/exercise-details-action.svg b/shared/resources/images/icons/exercise-details-action.svg similarity index 100% rename from resources/images/icons/exercise-details-action.svg rename to shared/resources/images/icons/exercise-details-action.svg diff --git a/resources/images/icons/exercise-exclude-card-view-action.svg b/shared/resources/images/icons/exercise-exclude-card-view-action.svg similarity index 100% rename from resources/images/icons/exercise-exclude-card-view-action.svg rename to shared/resources/images/icons/exercise-exclude-card-view-action.svg diff --git a/resources/images/icons/exercise-exclude-details-view-action.svg b/shared/resources/images/icons/exercise-exclude-details-view-action.svg similarity index 100% rename from resources/images/icons/exercise-exclude-details-view-action.svg rename to shared/resources/images/icons/exercise-exclude-details-view-action.svg diff --git a/resources/images/icons/exercise-feedback-off-action.svg b/shared/resources/images/icons/exercise-feedback-off-action.svg similarity index 100% rename from resources/images/icons/exercise-feedback-off-action.svg rename to shared/resources/images/icons/exercise-feedback-off-action.svg diff --git a/resources/images/icons/exercise-feedback-on-action.svg b/shared/resources/images/icons/exercise-feedback-on-action.svg similarity index 100% rename from resources/images/icons/exercise-feedback-on-action.svg rename to shared/resources/images/icons/exercise-feedback-on-action.svg diff --git a/resources/images/icons/exercise-include-action.svg b/shared/resources/images/icons/exercise-include-action.svg similarity index 100% rename from resources/images/icons/exercise-include-action.svg rename to shared/resources/images/icons/exercise-include-action.svg diff --git a/resources/images/icons/exercise-report-error-action.svg b/shared/resources/images/icons/exercise-report-error-action.svg similarity index 100% rename from resources/images/icons/exercise-report-error-action.svg rename to shared/resources/images/icons/exercise-report-error-action.svg diff --git a/resources/images/icons/icon-external.svg b/shared/resources/images/icons/external-cl-icon.svg similarity index 100% rename from resources/images/icons/icon-external.svg rename to shared/resources/images/icons/external-cl-icon.svg diff --git a/shared/resources/images/icons/external-hs-icon.svg b/shared/resources/images/icons/external-hs-icon.svg new file mode 100644 index 0000000000..f2050caa71 --- /dev/null +++ b/shared/resources/images/icons/external-hs-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/resources/images/icons/icon-feedback.svg b/shared/resources/images/icons/feedback-cl-icon.svg similarity index 100% rename from resources/images/icons/icon-feedback.svg rename to shared/resources/images/icons/feedback-cl-icon.svg diff --git a/shared/resources/images/icons/feedback-hs-icon.svg b/shared/resources/images/icons/feedback-hs-icon.svg new file mode 100644 index 0000000000..85ca782b10 --- /dev/null +++ b/shared/resources/images/icons/feedback-hs-icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/resources/images/icons/icon-homework.svg b/shared/resources/images/icons/homework-cl-icon.svg similarity index 100% rename from resources/images/icons/icon-homework.svg rename to shared/resources/images/icons/homework-cl-icon.svg diff --git a/shared/resources/images/icons/homework-hs-icon.svg b/shared/resources/images/icons/homework-hs-icon.svg new file mode 100644 index 0000000000..2870808cef --- /dev/null +++ b/shared/resources/images/icons/homework-hs-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/icon-calculator.svg b/shared/resources/images/icons/icon-calculator.svg new file mode 100644 index 0000000000..8fbab64a55 --- /dev/null +++ b/shared/resources/images/icons/icon-calculator.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/icon-coach.svg b/shared/resources/images/icons/icon-coach.svg new file mode 100644 index 0000000000..0014481978 --- /dev/null +++ b/shared/resources/images/icons/icon-coach.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/icon-completed.svg b/shared/resources/images/icons/icon-completed.svg new file mode 100644 index 0000000000..8d3716549d --- /dev/null +++ b/shared/resources/images/icons/icon-completed.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/icon-correct.svg b/shared/resources/images/icons/icon-correct.svg new file mode 100644 index 0000000000..0241b334bd --- /dev/null +++ b/shared/resources/images/icons/icon-correct.svg @@ -0,0 +1,64 @@ + + + +image/svg+xml + + + + + + + \ No newline at end of file diff --git a/shared/resources/images/icons/icon-end.svg b/shared/resources/images/icons/icon-end.svg new file mode 100644 index 0000000000..232b624ba0 --- /dev/null +++ b/shared/resources/images/icons/icon-end.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/icon-event.svg b/shared/resources/images/icons/icon-event.svg new file mode 100644 index 0000000000..b03298884f --- /dev/null +++ b/shared/resources/images/icons/icon-event.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/icon-external.svg b/shared/resources/images/icons/icon-external.svg new file mode 100644 index 0000000000..f2050caa71 --- /dev/null +++ b/shared/resources/images/icons/icon-external.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/icon-feedback.svg b/shared/resources/images/icons/icon-feedback.svg new file mode 100644 index 0000000000..85ca782b10 --- /dev/null +++ b/shared/resources/images/icons/icon-feedback.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/shared/resources/images/icons/icon-homework.svg b/shared/resources/images/icons/icon-homework.svg new file mode 100644 index 0000000000..2870808cef --- /dev/null +++ b/shared/resources/images/icons/icon-homework.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/resources/images/icons/icon-incorrect.svg b/shared/resources/images/icons/icon-incorrect.svg similarity index 100% rename from resources/images/icons/icon-incorrect.svg rename to shared/resources/images/icons/icon-incorrect.svg diff --git a/shared/resources/images/icons/icon-interactive-placeholder.svg b/shared/resources/images/icons/icon-interactive-placeholder.svg new file mode 100644 index 0000000000..29d7405f2a --- /dev/null +++ b/shared/resources/images/icons/icon-interactive-placeholder.svg @@ -0,0 +1,19 @@ + + + + + diff --git a/shared/resources/images/icons/icon-interactive.svg b/shared/resources/images/icons/icon-interactive.svg new file mode 100644 index 0000000000..0aeec98500 --- /dev/null +++ b/shared/resources/images/icons/icon-interactive.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/resources/images/icons/icon-laptop.svg b/shared/resources/images/icons/icon-laptop.svg similarity index 100% rename from resources/images/icons/icon-laptop.svg rename to shared/resources/images/icons/icon-laptop.svg diff --git a/resources/images/icons/icon-light-bulb.svg b/shared/resources/images/icons/icon-light-bulb.svg similarity index 100% rename from resources/images/icons/icon-light-bulb.svg rename to shared/resources/images/icons/icon-light-bulb.svg diff --git a/shared/resources/images/icons/icon-multipart.svg b/shared/resources/images/icons/icon-multipart.svg new file mode 100644 index 0000000000..7feac4c131 --- /dev/null +++ b/shared/resources/images/icons/icon-multipart.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/resources/images/icons/icon-personalized-interstitial.svg b/shared/resources/images/icons/icon-personalized-interstitial.svg similarity index 100% rename from resources/images/icons/icon-personalized-interstitial.svg rename to shared/resources/images/icons/icon-personalized-interstitial.svg diff --git a/resources/images/icons/icon-personalized.svg b/shared/resources/images/icons/icon-personalized.svg similarity index 100% rename from resources/images/icons/icon-personalized.svg rename to shared/resources/images/icons/icon-personalized.svg diff --git a/resources/images/icons/icon-reading.svg b/shared/resources/images/icons/icon-reading.svg similarity index 100% rename from resources/images/icons/icon-reading.svg rename to shared/resources/images/icons/icon-reading.svg diff --git a/resources/images/icons/icon-recover.svg b/shared/resources/images/icons/icon-recover.svg similarity index 100% rename from resources/images/icons/icon-recover.svg rename to shared/resources/images/icons/icon-recover.svg diff --git a/resources/images/icons/icon-review-interstitial.svg b/shared/resources/images/icons/icon-review-interstitial.svg similarity index 100% rename from resources/images/icons/icon-review-interstitial.svg rename to shared/resources/images/icons/icon-review-interstitial.svg diff --git a/resources/images/icons/icon-review.svg b/shared/resources/images/icons/icon-review.svg similarity index 100% rename from resources/images/icons/icon-review.svg rename to shared/resources/images/icons/icon-review.svg diff --git a/resources/images/icons/icon-test.svg b/shared/resources/images/icons/icon-test.svg similarity index 100% rename from resources/images/icons/icon-test.svg rename to shared/resources/images/icons/icon-test.svg diff --git a/shared/resources/images/icons/icon-video-placeholder.svg b/shared/resources/images/icons/icon-video-placeholder.svg new file mode 100644 index 0000000000..7cb98943e4 --- /dev/null +++ b/shared/resources/images/icons/icon-video-placeholder.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/resources/images/icons/icon-video.svg b/shared/resources/images/icons/icon-video.svg similarity index 100% rename from resources/images/icons/icon-video.svg rename to shared/resources/images/icons/icon-video.svg diff --git a/resources/images/icons/icon-worked-example.svg b/shared/resources/images/icons/icon-worked-example.svg similarity index 100% rename from resources/images/icons/icon-worked-example.svg rename to shared/resources/images/icons/icon-worked-example.svg diff --git a/shared/resources/images/icons/incorrect-cl-icon.svg b/shared/resources/images/icons/incorrect-cl-icon.svg new file mode 100644 index 0000000000..0b31f1a87a --- /dev/null +++ b/shared/resources/images/icons/incorrect-cl-icon.svg @@ -0,0 +1,64 @@ + + + +image/svg+xml + + + + + + + \ No newline at end of file diff --git a/shared/resources/images/icons/incorrect-hs-icon.svg b/shared/resources/images/icons/incorrect-hs-icon.svg new file mode 100644 index 0000000000..0b31f1a87a --- /dev/null +++ b/shared/resources/images/icons/incorrect-hs-icon.svg @@ -0,0 +1,64 @@ + + + +image/svg+xml + + + + + + + \ No newline at end of file diff --git a/resources/images/icons/icon-interactive.svg b/shared/resources/images/icons/interactive-cl-icon.svg similarity index 100% rename from resources/images/icons/icon-interactive.svg rename to shared/resources/images/icons/interactive-cl-icon.svg diff --git a/shared/resources/images/icons/interactive-hs-icon.svg b/shared/resources/images/icons/interactive-hs-icon.svg new file mode 100644 index 0000000000..9cf380219b --- /dev/null +++ b/shared/resources/images/icons/interactive-hs-icon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/interactive-placeholder-cl-icon.svg b/shared/resources/images/icons/interactive-placeholder-cl-icon.svg new file mode 100644 index 0000000000..29d7405f2a --- /dev/null +++ b/shared/resources/images/icons/interactive-placeholder-cl-icon.svg @@ -0,0 +1,19 @@ + + + + + diff --git a/shared/resources/images/icons/interactive-placeholder-hs-icon.svg b/shared/resources/images/icons/interactive-placeholder-hs-icon.svg new file mode 100644 index 0000000000..29d7405f2a --- /dev/null +++ b/shared/resources/images/icons/interactive-placeholder-hs-icon.svg @@ -0,0 +1,19 @@ + + + + + diff --git a/shared/resources/images/icons/laptop-cl-icon.svg b/shared/resources/images/icons/laptop-cl-icon.svg new file mode 100644 index 0000000000..4d82b7698f --- /dev/null +++ b/shared/resources/images/icons/laptop-cl-icon.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/laptop-hs-icon.svg b/shared/resources/images/icons/laptop-hs-icon.svg new file mode 100644 index 0000000000..4d82b7698f --- /dev/null +++ b/shared/resources/images/icons/laptop-hs-icon.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/light-bulb-cl-icon.svg b/shared/resources/images/icons/light-bulb-cl-icon.svg new file mode 100644 index 0000000000..fe4b79b23a --- /dev/null +++ b/shared/resources/images/icons/light-bulb-cl-icon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/light-bulb-hs-icon.svg b/shared/resources/images/icons/light-bulb-hs-icon.svg new file mode 100644 index 0000000000..fe4b79b23a --- /dev/null +++ b/shared/resources/images/icons/light-bulb-hs-icon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/personalized-cl-icon.svg b/shared/resources/images/icons/personalized-cl-icon.svg new file mode 100644 index 0000000000..a6f727a0c3 --- /dev/null +++ b/shared/resources/images/icons/personalized-cl-icon.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/personalized-hs-icon.svg b/shared/resources/images/icons/personalized-hs-icon.svg new file mode 100644 index 0000000000..a6f727a0c3 --- /dev/null +++ b/shared/resources/images/icons/personalized-hs-icon.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/reading-cl-icon.svg b/shared/resources/images/icons/reading-cl-icon.svg new file mode 100644 index 0000000000..8ebd9a1c5f --- /dev/null +++ b/shared/resources/images/icons/reading-cl-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/reading-hs-icon.svg b/shared/resources/images/icons/reading-hs-icon.svg new file mode 100644 index 0000000000..8ebd9a1c5f --- /dev/null +++ b/shared/resources/images/icons/reading-hs-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/recover-cl-icon.svg b/shared/resources/images/icons/recover-cl-icon.svg new file mode 100644 index 0000000000..01170362d0 --- /dev/null +++ b/shared/resources/images/icons/recover-cl-icon.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/shared/resources/images/icons/recover-hs-icon.svg b/shared/resources/images/icons/recover-hs-icon.svg new file mode 100644 index 0000000000..01170362d0 --- /dev/null +++ b/shared/resources/images/icons/recover-hs-icon.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/shared/resources/images/icons/review-cl-icon.svg b/shared/resources/images/icons/review-cl-icon.svg new file mode 100644 index 0000000000..db2a817be6 --- /dev/null +++ b/shared/resources/images/icons/review-cl-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/shared/resources/images/icons/review-hs-icon.svg b/shared/resources/images/icons/review-hs-icon.svg new file mode 100644 index 0000000000..db2a817be6 --- /dev/null +++ b/shared/resources/images/icons/review-hs-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/shared/resources/images/icons/sim-cl-icon.svg b/shared/resources/images/icons/sim-cl-icon.svg new file mode 100644 index 0000000000..f8f5e184e6 --- /dev/null +++ b/shared/resources/images/icons/sim-cl-icon.svg @@ -0,0 +1 @@ +icon-fa-sim \ No newline at end of file diff --git a/shared/resources/images/icons/sim-hs-icon.svg b/shared/resources/images/icons/sim-hs-icon.svg new file mode 100644 index 0000000000..f8f5e184e6 --- /dev/null +++ b/shared/resources/images/icons/sim-hs-icon.svg @@ -0,0 +1 @@ +icon-fa-sim \ No newline at end of file diff --git a/shared/resources/images/icons/test-cl-icon.svg b/shared/resources/images/icons/test-cl-icon.svg new file mode 100644 index 0000000000..c154a9180f --- /dev/null +++ b/shared/resources/images/icons/test-cl-icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/test-hs-icon.svg b/shared/resources/images/icons/test-hs-icon.svg new file mode 100644 index 0000000000..c154a9180f --- /dev/null +++ b/shared/resources/images/icons/test-hs-icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + diff --git a/shared/resources/images/icons/video-cl-icon.svg b/shared/resources/images/icons/video-cl-icon.svg new file mode 100644 index 0000000000..d21d00cfff --- /dev/null +++ b/shared/resources/images/icons/video-cl-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/shared/resources/images/icons/video-hs-icon.svg b/shared/resources/images/icons/video-hs-icon.svg new file mode 100644 index 0000000000..d21d00cfff --- /dev/null +++ b/shared/resources/images/icons/video-hs-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/shared/resources/images/icons/video-placeholder-cl-icon.svg b/shared/resources/images/icons/video-placeholder-cl-icon.svg new file mode 100644 index 0000000000..7cb98943e4 --- /dev/null +++ b/shared/resources/images/icons/video-placeholder-cl-icon.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/shared/resources/images/icons/video-placeholder-hs-icon.svg b/shared/resources/images/icons/video-placeholder-hs-icon.svg new file mode 100644 index 0000000000..7cb98943e4 --- /dev/null +++ b/shared/resources/images/icons/video-placeholder-hs-icon.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/shared/resources/images/icons/worked-example-cl-icon.svg b/shared/resources/images/icons/worked-example-cl-icon.svg new file mode 100644 index 0000000000..e700e3b9aa --- /dev/null +++ b/shared/resources/images/icons/worked-example-cl-icon.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + x+y + + diff --git a/shared/resources/images/icons/worked-example-hs-icon.svg b/shared/resources/images/icons/worked-example-hs-icon.svg new file mode 100644 index 0000000000..e700e3b9aa --- /dev/null +++ b/shared/resources/images/icons/worked-example-hs-icon.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + x+y + + diff --git a/shared/resources/styles/_components.less b/shared/resources/styles/_components.less new file mode 100644 index 0000000000..b113a4f887 --- /dev/null +++ b/shared/resources/styles/_components.less @@ -0,0 +1,16 @@ +// This file should be imported wrapped within another less file only. +// Each of them begin with a `&-`, allowing whatever file that is importing +// this file to easily namespace all the components at once. + +@import './components/html/index'; +@import './components/exercise/step-card'; +@import './components/exercise/group'; +@import './components/exercise-badges/index'; +@import './components/exercise-preview/index'; +@import './components/breadcrumbs/step'; +@import './components/question'; +@import './components/close'; +@import './components/spy-mode'; +@import './components/smart-overflow'; +@import './components/notifications-bar'; +@import './components/surety-guard'; diff --git a/shared/resources/styles/components/breadcrumbs/coach.less b/shared/resources/styles/components/breadcrumbs/coach.less new file mode 100644 index 0000000000..19cfa599b2 --- /dev/null +++ b/shared/resources/styles/components/breadcrumbs/coach.less @@ -0,0 +1,141 @@ +@concept-coach-breadcrumb-background: @openstax-white; +@concept-coach-breadcrumb-color: @openstax-neutral; +@concept-coach-breadcrumb-border-radius: 0; + +.make-breadcrumb-clickable() { + &.active { + color: @openstax-white; + background: @openstax-info; + border-color: @openstax-info; + cursor: default; + font-weight: 400; + } + + &:hover, + &:focus { + border-color: @openstax-info; + outline: none; + } +} + +.concept-coach-breadcrumbs() { + .task-breadcrumbs { + margin: 1rem 0; + } + + .openstax-breadcrumbs-step { + border-width: 2px; + border-style: solid; + border-radius: @concept-coach-breadcrumb-border-radius; + background: @concept-coach-breadcrumb-background; + border-color: @concept-coach-breadcrumb-background; + color: @concept-coach-breadcrumb-color; + margin-right: 1rem; + font-size: 2.5rem; + line-height: 3.8rem; + width: auto; + position: relative; + + &:hover, + &.active { + box-shadow: none; + border-radius: @concept-coach-breadcrumb-border-radius; + .scale(1); + } + + .transition(~'background 0.1s ease-in-out, border-color 0.1s ease-in-out'); + .make-breadcrumb-clickable(); + + &::before { + font-weight: 300; + position: relative; + } + + &.disabled { + background: transparent; + border-color: transparent; + color: lighten(@concept-coach-breadcrumb-color, 25%); + cursor: default; + + &:hover { + border-color: transparent; + } + } + + &.end:not(.disabled) { + background: @openstax-white; + border-color: @openstax-white; + color: @concept-coach-breadcrumb-color; + cursor: pointer; + } + + &:not(.active) { + &.status-incorrect, + &.status-correct { + background: @concept-coach-breadcrumb-background; + } + &.status-incorrect { + color: @openstax-incorrect-color; + } + + &.status-correct { + color: @openstax-correct-color; + } + } + + + &.breadcrumb-end[data-label] { + top: -0.5em; + + &::before { + text-align: center; + width: 100%; + padding: 0 1rem; + font-style: italic; + text-transform: capitalize; + content: attr(data-label); + } + } + } + + .icon-end { + display: none; + } + + .icon-stack { + > .icon-sm, + > .icon-lg { + top: -1.5rem; + left: 2.2rem; + width: 26px; + height: 26px; + border-radius: 50%; + border: 3px solid @openstax-neutral-light; + } + } + + + .icon-incorrect, + .icon-correct { + .fa-icon(); + font-size: 2.4rem; + line-height: 1.8rem; + background: @openstax-white; + } + + .icon-incorrect { + color: @openstax-incorrect-color; + + &::after { + content: @fa-var-times-circle; + } + } + + .icon-correct { + color: @openstax-correct-color; + + &::after { + content: @fa-var-check-circle; + } + } +} \ No newline at end of file diff --git a/shared/resources/styles/components/breadcrumbs/icons.less b/shared/resources/styles/components/breadcrumbs/icons.less new file mode 100644 index 0000000000..0191fe063c --- /dev/null +++ b/shared/resources/styles/components/breadcrumbs/icons.less @@ -0,0 +1,28 @@ +// Note: Use tokens instead of strings to be able to do LESS compile-time validation + +.x-course-icon-bg(feedback); +.x-course-icon-bg(homework, exercise); +.x-course-icon-bg(homework); +.x-course-icon-bg(external); +.x-course-icon-bg(event); +.x-course-icon-bg(end); +.x-course-icon-bg(interactive); +.x-course-icon-bg(coach); +.x-course-icon-bg(video); +.x-course-icon-bg(reading); +.x-course-icon-bg(recover); +.x-course-icon-bg(test); +.x-course-icon-bg(review, spaced_practice); +.x-course-icon-bg(personalized); +.x-course-icon-bg(worked-example); + +// The following are usually overlays on other icons +.x-course-icon-bg(correct); +.x-course-icon-bg(incorrect); + +.icon-placeholder { + &:before { + content: '?'; + font-style: normal; + } +} diff --git a/shared/resources/styles/components/breadcrumbs/index.less b/shared/resources/styles/components/breadcrumbs/index.less new file mode 100644 index 0000000000..651adb0985 --- /dev/null +++ b/shared/resources/styles/components/breadcrumbs/index.less @@ -0,0 +1,75 @@ +// http://caniuse.com/#search=counter +.task-homework, +.task-practice, +.task-chapter_practice, +.task-concept_coach, +.task-page_practice { + .task-breadcrumbs { + counter-reset: step; + } + .openstax-breadcrumbs-step:not(.breadcrumb-end) { + &:before { + counter-increment: step; + content: counter(step); + .crumb-text(); + } + i:not(.icon-incorrect):not(.icon-correct) { + display: none; + } + } +} + +// label readings with chapter info if it exists +.task-reading { + .openstax-breadcrumbs-step[data-chapter] { + + background: @reading-color; + color: @openstax-white; + + &.active, + &.completed { + color: @openstax-white; + } + + &::before { + content: attr(data-chapter); + .crumb-text(); + } + i:not(.icon-incorrect):not(.icon-correct) { + display: none; + } + } +} + +&.is-college { + .task-reading { + .openstax-breadcrumbs-step[data-chapter] { + background: @openstax-white; + color: @openstax-neutral; + border: 1px solid @openstax-neutral-lighter; + } + &.active, + &.completed { + color: @openstax-neutral; + } + } +} + +.task-breadcrumbs { + margin-left: -2px; + margin-right: -2px; + + &.shrink { + .openstax-breadcrumbs-step { + &:not(:hover):not(.active) { + .scale(.75, .75); + margin-left: -16px; + } + &:hover, &.active { + margin-right: 10px; + margin-left: -6px; + .openstax-icon-active(1.2, 0.12); + } + } + } +} diff --git a/shared/resources/styles/components/breadcrumbs/step.less b/shared/resources/styles/components/breadcrumbs/step.less new file mode 100644 index 0000000000..015e1f6c45 --- /dev/null +++ b/shared/resources/styles/components/breadcrumbs/step.less @@ -0,0 +1,37 @@ +&-breadcrumbs-step { + + &.completed{ + .icon-end { .x-icon-bg(completed); } + } + + .crumb-circle(2rem); + position: relative; + font-weight: 500; + cursor: pointer; + margin: 2px; + display: inline-block; + text-align: center; + background: @openstax-white; + color: @openstax-neutral; + + &:hover, + &.active { + .openstax-icon-active(1.4, 0.5); + } + + &.active { + color: @openstax-primary; + } + &.completed { + background: @openstax-answer-background; + color: @openstax-answer-color; + } + &.status-correct { + background: @openstax-correct-background; + color: @openstax-correct-color; + } + &.status-incorrect { + background: @openstax-incorrect-background; + color: @openstax-incorrect-color; + } +} \ No newline at end of file diff --git a/shared/resources/styles/components/close.less b/shared/resources/styles/components/close.less new file mode 100644 index 0000000000..1acee8733d --- /dev/null +++ b/shared/resources/styles/components/close.less @@ -0,0 +1,5 @@ +&-close-x { + &::before { + content: "\00d7"; + } +} diff --git a/shared/resources/styles/components/exercise-badges/index.less b/shared/resources/styles/components/exercise-badges/index.less new file mode 100644 index 0000000000..8fcb833227 --- /dev/null +++ b/shared/resources/styles/components/exercise-badges/index.less @@ -0,0 +1,15 @@ +&-exercise-badges { + display: flex; + justify-content: flex-end; + align-items: center; + color: @openstax-neutral; + .icon { + height: 20px; + margin-right: 0.5rem; + } + span { + display: flex; + align-items: center; + & + span { margin-left: 8px; } + } +} \ No newline at end of file diff --git a/shared/resources/styles/components/exercise-preview/index.less b/shared/resources/styles/components/exercise-preview/index.less new file mode 100644 index 0000000000..555b829dee --- /dev/null +++ b/shared/resources/styles/components/exercise-preview/index.less @@ -0,0 +1,299 @@ +&-exercise-preview { + position: relative; + @action-square-width: 140px; + @action-square-height: 130px; + + @action-icon-circle-size: 80px; + .sans(@size: 1.5rem, @line-height: 3rem) { + font-family: 'Lato', Helvetica, sans-serif; + font-weight: 400; + font-style: normal; + font-size: @size; + line-height: @line-height; + } + iframe { + width: 100%; + } + img { + max-width: 100%; + display: block; + margin: 0 auto 20px auto; + } + + &.non-interactive { + + .placeholder { + height: 200px; + margin: 0 auto; + display: block; + } + } + + &.is-vertically-truncated { + .context { + .height-limited-panel(300px); + } + .question-stem { + .height-limited-panel(370px); + } + .panel-body { + .height-limited-panel(720px); + } + } + + + &.has-actions { + .panel-body { + &:hover { + .controls-overlay { + visibility: visible; + background-color: fade( @openstax-neutral-light, 80%); + .message { opacity: 1; } + } + } + } + .controls-overlay { + cursor: pointer; + position: absolute; + top: 0; right: 0; bottom: 0; left: 0; // completely cover panel body + visibility: hidden; + z-index: 1; + display: flex; + border: none; + justify-content: center; + align-items: center; + .transition(all .25s ease-in-out); + + .message { + display: block; + font-size: 1.5rem; + line-height: 1.5rem; + font-weight: 700; + opacity: 0; + .transition(all .25s ease-in-out); + .openstax-disable-text-select(); + .action { + display: inline-block; + text-align: center; + color: white; + user-select: none; + width: @action-square-width; + height: @action-square-height; + &:hover { box-shadow: 0 0 5px rgba(0,0,0,0.5); } + + &:before { + .fa-icon(); + content: ""; + display: block; + margin: 10% auto; + background-repeat: no-repeat; + background-size: 100%; + padding-top: 0; + margin-bottom: 5px; + font-weight: 300; + text-align: center; + background-color: white; + font-size: @action-icon-circle-size * 0.75; + border-radius: @action-icon-circle-size / 2; + width: @action-icon-circle-size; + height: @action-icon-circle-size; + } + } + } + } + } // end controls-overlay + + + &.actions-on-side { + @action-square-width: 67px; + @action-square-height: 75px; + @size-scale: 0.5; + .panel-body, .panel-body:hover { + padding-left: 90px; + position: relative; + .controls-overlay { + visibility: visible; + right: inherit; + width: @action-square-width; + background-color: transparent; + pointer-events: none; + .message { opacity: 1; } + } + .message { + pointer-events: all; + flex-direction: column; + .action { + height: @action-square-height; + width: @action-square-width; + font-size: 12px; + line-height: 12px; + &:before { + padding-top: 4px; + margin-bottom: 0; + font-size: @action-icon-circle-size * 0.75 * @size-scale; + border-radius: (@action-icon-circle-size / 2) * @size-scale; + width: @action-icon-circle-size * @size-scale; + height: @action-icon-circle-size * @size-scale; + } + &.report-error:before{ padding-top: 0px; font-size: 90px * @size-scale; }; + } + } + } + + } + + .selected-mask { + position: absolute; + top: 0; right: 0; bottom: 0; left: 0; // completely cover panel body + z-index: 1; + opacity: 0.4; + background-color: @openstax-neutral-light; + pointer-events: none; + } + + .panel-body { + position: relative; + + // used by formats and tags + .metadata-styles() { + color: @openstax-neutral; + font-size: 1.2rem; + line-height: 1.4em; + } + .question-stem { + font-weight: 700; + margin-bottom: 1rem; + font-size: 1.6rem; + line-height: 1.4em; + } + + .answers-table { + counter-reset: answer; + font-size: 1.4rem; + line-height: 1.4em; + margin-bottom: 2rem; + + .answers-answer { + padding-left: 15px; + counter-increment: answer; + display: table; + + &.answer-correct { + color: @openstax-secondary; + padding-left: 0; + + &:before { + .fa-icon(); + padding-top: 0.15em; + padding-right: .125em; + content: @fa-var-check; + display: table-cell; + } + } + + .answer-label { + font-weight: 400; + display: table-cell; + } + + .answer-letter { + padding-right: 1rem; + display: table-cell; + &:after { + content: counter(answer, lower-latin) ')'; + } + } + + .answer-answer { + display: table-cell; + padding-bottom: 5px; + } + } + + .question-feedback-content { + display: none; + margin-bottom: 5px; + color: @openstax-neutral; + margin-left: 0; + .sans(1.1rem, 1.1rem); + font-style: italic; + } + + } + + .formats-listing { + .metadata-styles(); + .header { + display: inline-block; + margin-right: 1rem; + } + span { + &:after { content: ", "; } + &:last-of-type { + &:after{ content: ""; } + } + } + + } + + .detailed-solution { + display: none; + } + + .exercise-tags { + .metadata-styles(); + .exercise-tag + .exercise-tag:before { + content: ','; + margin-right: 1rem; + } + + .lo-tag { + margin-right: 1rem; + display: block; + content: ''; + } + } + .exercise-uid { + font-size: 11px; + line-height: 11px; + color: @openstax-neutral-medium; + } + } + + &.is-displaying-feedback { + .panel-body .answers-table .question-feedback-content { display: table; } + .detailed-solution{ display: block; } + } + + .stimulus { + margin-bottom: 1rem; + .preview { + width: 200px; + height: 200px; + margin: auto; + overflow: hidden; + background-repeat: no-repeat; + background-size: 100% 100%; + text-indent: 400px; // move text out of the frame so only background image shows up + &.video { .x-icon-bg("video-placeholder"); } + &.interactive { .x-icon-bg("interactive-placeholder"); } + } + } + + // use absolute positioning for the identifer so it's link is clickable through the overlay + .panel-footer .controls { + display: flex; + justify-content: space-between; + font-size: 1.1rem; + color: @openstax-neutral; + + .exercise-identifier-link a { color: @openstax-neutral; } + + .toggle { + border: 0; + background-color: transparent; + font-size: 1.2rem; + } + } + +} // end of exercise.card styles diff --git a/shared/resources/styles/components/exercise/group.less b/shared/resources/styles/components/exercise/group.less new file mode 100644 index 0000000000..2aeaabb540 --- /dev/null +++ b/shared/resources/styles/components/exercise/group.less @@ -0,0 +1,17 @@ +&-step-group { + font-size: 1.6rem; + color: @openstax-neutral; + padding-bottom: 2rem; + padding-top: 0; + text-align: right; + + i.fa { + display: inline-block; + margin: 0 5px; + } +} + +&-step-group-label { + display: inline-block; + margin-left: 20px; +} diff --git a/shared/resources/styles/components/exercise/step-card.less b/shared/resources/styles/components/exercise/step-card.less new file mode 100644 index 0000000000..e84326280c --- /dev/null +++ b/shared/resources/styles/components/exercise/step-card.less @@ -0,0 +1,56 @@ +&-exercise { + .exercise-typography(); + + @{placeholder-selector} { + font-style: italic; + } + + .exercise-stimulus { + margin-bottom: 20px; + } + + textarea { + width: 100%; + min-height: 8em; + line-height: 1.5em; + margin: 1em 0 0 0; + padding: 0.75em; + + &[disabled] { + border: none; + } + } + +} + +&-exercise-card, &-multipart-exercise-card { + position: relative; + .exercise-identifier-link { + position: absolute; + right: @answer-horizontal-spacing; + bottom: @answer-vertical-spacing; + color: @answer-label-color; + font-size: 1.2rem; + line-height: 1.2rem; + } + + &.deleted-reading, + &.deleted-homework { + .btn { + display: none; + } + } + &.deleted-homework { + .openstax-question:not(.openstax-question-preview) { + .question-feedback { + display: none; + } + } + } +} + +&-multipart-exercise-card { + .card-body { + margin-bottom: 4rem; + } +} diff --git a/shared/resources/styles/components/html/index.less b/shared/resources/styles/components/html/index.less new file mode 100644 index 0000000000..29477e4ca1 --- /dev/null +++ b/shared/resources/styles/components/html/index.less @@ -0,0 +1,9 @@ +&-has-html { + .openstax-tables(); + + .frame-wrapper { + margin: 1rem 0; + } +} + + diff --git a/shared/resources/styles/components/notifications-bar.less b/shared/resources/styles/components/notifications-bar.less new file mode 100644 index 0000000000..424c0e11cd --- /dev/null +++ b/shared/resources/styles/components/notifications-bar.less @@ -0,0 +1,73 @@ +&-notifications-bar { + position: absolute; + left: 0; + right: 0; + background-color: @openstax-primary; + + .notification { + .flex-display(); + .flex-direction(row); + .justify-content(flex-start); + .align-items(center); + color: @openstax-neutral-lighter; + padding: 15px; + font-size: 2rem; + line-height: 2rem; + + .body, .error { + .flex-display(); + .flex-direction(row); + .justify-content(center); + .align-items(center); + .message { + text-align: right; + } + } + .body { + .flex(1); + } + + & + .notification { + border-top: 1px solid darken(@openstax-primary, 5%); + } + + .icon { + margin-right: 1rem; + font-size: 3rem; + } + .dismiss, .action { + color: @openstax-neutral-lighter; + cursor: pointer; + text-decoration: none; + margin-left: 1rem; + &:hover { + text-decoration: underline; + color: @openstax-neutral-light; + border-color: @openstax-neutral-light; + } + } + .dismiss { + font-size: 1.5rem; + } + .action { + font-size: 1.8rem; + } + &.email { + + + input { + width: 10rem; + margin-left: 1rem; + color: @openstax-neutral-darker; + } + .form-group { margin: 0; } + .pin-check { + border: 2px solid @openstax-neutral-lighter; + border-radius: 20px; + padding: 4px; + width: 60px; + text-align: center; + } + } + } +} diff --git a/shared/resources/styles/components/pinned-header-footer-card/index.less b/shared/resources/styles/components/pinned-header-footer-card/index.less new file mode 100644 index 0000000000..679fcc3af6 --- /dev/null +++ b/shared/resources/styles/components/pinned-header-footer-card/index.less @@ -0,0 +1,10 @@ +body.pinned-shy { + .pinned-header, .navbar-fixed-top { + .make-shy(); + } +} + +body.pinned-force-shy { + // force min height to allow shy scroll on a short task step + min-height: 120%; +} diff --git a/shared/resources/styles/components/pinned-header-footer-card/sections.less b/shared/resources/styles/components/pinned-header-footer-card/sections.less new file mode 100644 index 0000000000..4336cf02b2 --- /dev/null +++ b/shared/resources/styles/components/pinned-header-footer-card/sections.less @@ -0,0 +1,20 @@ +.pinned-footer { + .fixed-bar(); + background: fadeout(@openstax-white, 20%); + padding: 10px @openstax-navbar-padding-horizontal; + bottom: 0; +} + +.pinned-header { + .make-shy-animate(); +} + +.pinned-on { + .pinned-header { + top: @openstax-navbar-height; + .fixed-bar(); + } +} + +// always wrap around elements by extending bootstrap's clearfix +.card-body { &:extend(.clearfix all); } diff --git a/shared/resources/styles/components/question.less b/shared/resources/styles/components/question.less new file mode 100644 index 0000000000..8b0252cd18 --- /dev/null +++ b/shared/resources/styles/components/question.less @@ -0,0 +1,353 @@ +@correct-answer-color: @openstax-correct-color; +@correct-answer-background: @openstax-correct-background; +@wrong-answer-color: @openstax-incorrect-color; +@wrong-answer-background: @openstax-incorrect-background; + +@free-response-color: @openstax-neutral-dark; +@free-response-background: @openstax-neutral-lighter; + +@answer-label-color: @openstax-neutral-medium; +@answer-label-color-hover: @openstax-neutral-dark; +@answer-label-color-selected: @openstax-info; + +@answer-vertical-spacing: 1.5rem; +@answer-horizontal-spacing: 1rem; +@answer-horizontal-buffer: @answer-vertical-spacing + @answer-horizontal-spacing; +@answer-bubble-size: 4rem; +@answer-label-spacing: @answer-bubble-size + @answer-horizontal-buffer; + +@answer-transition: ~'0.1s ease-in-out'; + +@feedback-horizontal-spacing: 2 * @answer-horizontal-spacing; +@feedback-vertical-spacing: @feedback-horizontal-spacing; +@feedback-horizontal-buffer: 2 * @feedback-horizontal-spacing; + +.openstax-bubble(@diameter, @border-size){ + width: @diameter; + height: @diameter; + border-radius: 2 * @diameter; + border-width: @border-size; + border-style: solid; + + &::after { + width: @diameter; + height: @diameter; + line-height: @diameter; + margin-left: -1 * @border-size; + margin-top: -1 * @border-size; + text-align: center; + display: inline-block; + } +} + +.answer-fa-icon(){ + .fa-icon(); + // em used here for line-height for compatibility with IE + // http://caniuse.com/#feat=rem -- rem ignored in pseudo elements + line-height: 1.6em; + font-size: 2.5rem; +} + +.answer-bubble(){ + .openstax-bubble(@answer-bubble-size, 2px); + border-color: lighten(@answer-label-color, 15%); + color: @answer-label-color-hover; + + .transition(~'color @{answer-transition}, border-color @{answer-transition}, background-color @{answer-transition}'); +} + +.answer-bubble(hover){ + border-color: @answer-label-color-selected; +} + +.answer-bubble(checked){ + border-color: @answer-label-color-selected; + background-color: @answer-label-color-selected; + color: @openstax-white; +} + +.answer-bubble(wrong){ + border-color: @wrong-answer-color; + background-color: @wrong-answer-color; + color: @openstax-white; + + &::after { + .answer-fa-icon(); + content: @fa-var-close; + } +} + +.answer-bubble(correct){ + border-color: @correct-answer-color; + background-color: @correct-answer-color; + color: @openstax-white; + + &::after { + .answer-fa-icon(); + content: @fa-var-check; + } +} + +.answer-bubble(correct-answer){ + border-color: @correct-answer-color; + color: @correct-answer-color; +} + +.answer-left-block(){ + display: block; + float: left; +} + +.answer(){ + color: @answer-label-color; + .answer-letter { + .answer-bubble(); + } +} + +.answer(hover){ + color: @answer-label-color-hover; + .answer-letter { + .answer-bubble(hover); + } +} + +.answer(checked){ + color: @answer-label-color-selected; + .answer-letter { + .answer-bubble(checked); + } +} + +.answer(wrong){ + color: @wrong-answer-color; + .answer-letter { + .answer-bubble(wrong); + } +} + +.answer(correct){ + color: @correct-answer-color; + .answer-letter { + .answer-bubble(correct); + } +} + +.answer(correct-answer){ + color: @correct-answer-color; + .answer-letter { + .answer-bubble(correct-answer); + } + + &::before { + content: 'correct answer'; + color: @answer-label-color; + margin-left: -1.25 * @answer-bubble-size; + width: 1.25 * @answer-bubble-size; + text-align: center; + font-size: 1.2rem; + // em used here for line-height for compatibility with IE + // http://caniuse.com/#feat=rem -- rem ignored in pseudo elements + line-height: 1em; + margin-top: 0.8rem; + .answer-left-block(); + } +} + + +&-question { + + .clearfix(); + + .detailed-solution { + margin-bottom: 1.5rem; + + .header { + color: @openstax-neutral-darker; + margin-bottom: 0.5rem; + .sans(1.3rem, 1.3rem); + } + + .solution { + color: @openstax-neutral; + .sans(1.1rem, 1.1rem); + font-style: italic; + } + } + + img { + display: block; + margin: auto; + max-width: 100%; + } + + .question-stem { + margin-bottom: 0; + } + + .answers-table { + margin-bottom: 20px; + } + + .instructions { + font-size: 1.4rem; + font-style: italic; + margin-top: 10px; + + i { + margin-left: 5px; + } + + .text-info{ + color: @light-blue; + padding-left: 5px; + cursor: pointer; + font-style: normal; + } + + } + + .multiple-choice-prompt { + font-weight: 600; + } + + // free response + .free-response { + padding: @answer-horizontal-spacing @answer-horizontal-buffer; + margin: @answer-vertical-spacing 0 @answer-horizontal-spacing @answer-vertical-spacing; + border-left: @answer-horizontal-spacing solid @free-response-background; + font-style: italic; + } + + + &:not(.openstax-question-preview) { + // answers + counter-reset: answer 0; + + .answers-answer { + counter-increment: answer 1; + width: initial; + } + + .answer-content { + width: calc(~'100% - @{answer-label-spacing}'); + margin-left: @answer-horizontal-spacing; + margin-top: 0.5 * @answer-horizontal-spacing; + .answer-left-block(); + } + + .answer-letter { + .answer-left-block(); + &::after { + content: counter(answer, lower-latin); + } + } + + .answer-label { + font-weight: normal; + width: 100%; + padding: @answer-vertical-spacing 0 0 0; + margin: 0; + + .transition(~'color @{answer-transition}'); + .answer(); + } + + // a selectable answer + .answer-input-box:not([disabled]) ~ .answer-label { + cursor: pointer; + + &:hover { + .answer(hover); + } + } + + // a selected answer + &:not(.has-correct-answer){ + .answer-input-box { + display: none; + + &:checked { + + .answer-label, + + .answer-label:hover { + .answer(checked); + } + } + } + + .answer-checked{ + .answer-label { + .answer(checked); + } + } + } + + // answer that has been checked + &.has-correct-answer { + .answer-checked { + &:not(.answer-correct) { + .answer-label { + .answer(wrong); + } + } + + &.answer-correct { + .answer-label { + .answer(correct); + } + } + } + + .answer-correct:not(.answer-checked) { + .answer-label { + .answer(correct-answer); + } + } + } + + .question-feedback { + .popover(); + + font-style: italic; + color: @answer-label-color; + + position: relative; + display: block; + width: calc(~'100% + @{feedback-horizontal-buffer}'); + max-width: calc(~'100% + @{feedback-horizontal-buffer}'); + margin-left: -1 * @feedback-horizontal-spacing; + margin-top: -1 * (@answer-vertical-spacing - @popover-arrow-width/10); + margin-bottom: @answer-horizontal-spacing; + z-index: 1; + .box-shadow(0 0 10px rgba(0, 0, 0, .25)); + border: none; + + .arrow { + &::after { + border-width: @popover-arrow-width; + content: ""; + } + .popover > .arrow(); + } + + &.bottom, + &.top { + > .arrow { + left: @answer-bubble-size/2 + @feedback-horizontal-spacing; + } + + } + &.bottom { + margin-top: -5px; + } + &.top { + margin-bottom: -5px; + } + + .question-feedback-content { + .popover-content(); + padding: @feedback-vertical-spacing @feedback-horizontal-spacing; + } + } + } +} diff --git a/shared/resources/styles/components/smart-overflow.less b/shared/resources/styles/components/smart-overflow.less new file mode 100644 index 0000000000..9148bdc0e2 --- /dev/null +++ b/shared/resources/styles/components/smart-overflow.less @@ -0,0 +1,5 @@ +&-smart-overflow { + overflow-y: auto; + overflow-x: hidden; + max-height: none; +} \ No newline at end of file diff --git a/shared/resources/styles/components/spy-mode.less b/shared/resources/styles/components/spy-mode.less new file mode 100644 index 0000000000..af9c9a6e42 --- /dev/null +++ b/shared/resources/styles/components/spy-mode.less @@ -0,0 +1,30 @@ +&-debug-content { + + &.is-enabled { + .debug-toggle-link { + color: @openstax-neutral; + } + .visible-when-debugging { + display: block; + border: 1px solid; + padding: 5px; + border-radius: 4px; + margin: 5px 0; + } + } + + .visible-when-debugging { + display: none; + } + + .debug-toggle-link { + position: fixed; + bottom: 0px; + left: 0px; + z-index: 10; // overlay navbar + line-height: 1.5rem; + font-size: 1.5rem; + color: transparent; + &:hover { color: @openstax-neutral; } + } +} diff --git a/shared/resources/styles/components/surety-guard.less b/shared/resources/styles/components/surety-guard.less new file mode 100644 index 0000000000..afacb41a6a --- /dev/null +++ b/shared/resources/styles/components/surety-guard.less @@ -0,0 +1,9 @@ +&-surety-guard { + .controls { + border-top: 1px solid @openstax-neutral-light; + display: flex; + justify-content: space-around; + margin-top: 0.5rem; + padding-top: 0.5rem; + } +} diff --git a/shared/resources/styles/demo.less b/shared/resources/styles/demo.less new file mode 100644 index 0000000000..b104cf36ad --- /dev/null +++ b/shared/resources/styles/demo.less @@ -0,0 +1,23 @@ +@import "./main"; + +.demos section { + margin-top: 50px; +} + +.demo { + margin: 100px 0; + +} +.notices { + .test-message { + display: flex; + align-items: center; + justify-content: space-between; + width: 500px; + } +} + +.exercisePreview-demo, .exercise-demo, .multipartExercise-demo { + max-width: 800px; + margin: auto; +} diff --git a/shared/resources/styles/globals/externals.less b/shared/resources/styles/globals/externals.less new file mode 100644 index 0000000000..e42a41bf3f --- /dev/null +++ b/shared/resources/styles/globals/externals.less @@ -0,0 +1,2 @@ +@import './openstax-bootstrap'; +@import './font-awesome'; diff --git a/shared/resources/styles/globals/font-awesome.less b/shared/resources/styles/globals/font-awesome.less new file mode 100644 index 0000000000..7b8718d25b --- /dev/null +++ b/shared/resources/styles/globals/font-awesome.less @@ -0,0 +1,26 @@ +// Import font awesome by individually requiring it's less files +// This is needed to work around a bug with less variable interpolating url() +// http://stackoverflow.com/questions/7025031/less-css-url-variable-always-shows-server-base-url-before-variable-output + +@import "@{fa-path}less/variables.less"; +@import "@{fa-path}less/mixins.less"; +// path.less is ommited and our own font-face declaration is used +// our declaration contains a valid path to the files, and ommits +// legacy font definitions. +@font-face { + font-family: 'FontAwesome'; + src: url('./@{fa-path}fonts/fontawesome-webfont.eot?v=@{fa-version}'); + src: url('./@{fa-path}fonts/fontawesome-webfont.woff?v=@{fa-version}'), + url('./@{fa-path}fonts/fontawesome-webfont.woff2?v=@{fa-version}') format('woff2'); + font-weight: normal; + font-style: normal; +} +@import "@{fa-path}less/core.less"; +@import "@{fa-path}less/larger.less"; +@import "@{fa-path}less/fixed-width.less"; +@import "@{fa-path}less/list.less"; +@import "@{fa-path}less/bordered-pulled.less"; +@import "@{fa-path}less/animated.less"; +@import "@{fa-path}less/rotated-flipped.less"; +@import "@{fa-path}less/stacked.less"; +@import "@{fa-path}less/icons.less"; \ No newline at end of file diff --git a/shared/resources/styles/globals/icons.less b/shared/resources/styles/globals/icons.less new file mode 100644 index 0000000000..0a1ff51013 --- /dev/null +++ b/shared/resources/styles/globals/icons.less @@ -0,0 +1,41 @@ +// # Overview +// +// - add `` tag in the HTML +// - Similar to font-awesome icons +// - use `` for stacking icons + +// # Support stacking icons +// +// Example: +// +// ```html +// +// +// +// +// ``` + +.icon-stack { + position: relative; +} + +.icon-stack > .icon-sm, +.icon-stack > .icon-lg { + position: absolute; + left: 0; + width: 100%; + text-align: center; +} + +// # Icon Classes + +.icon-xs { .x-icon-size(@icon-size-xs); } +.icon-sm { .x-icon-size(@icon-size-sm); } +.icon-md { .x-icon-size(@icon-size-md); } +.icon-lg { .x-icon-size(@icon-size-lg); } +.icon-xlg { .x-icon-size(@icon-size-xlg); } + +.icon-sm.icon-personalized { + width: @icon-size-sm * 2; + background-size: @icon-size-sm * 2 @icon-size-sm; +} diff --git a/shared/resources/styles/globals/maths.less b/shared/resources/styles/globals/maths.less new file mode 100644 index 0000000000..da0c15ecb2 --- /dev/null +++ b/shared/resources/styles/globals/maths.less @@ -0,0 +1,6 @@ +// Math Rendering +// Hides all data-math elements, but still reserves their space in the DOM. +// when .MathJax renders them, it'll be inside a .MathJax element which is displayed +[data-math]:not(.math-rendered) { + visibility: hidden; +} diff --git a/shared/resources/styles/globals/openstax-bootstrap.less b/shared/resources/styles/globals/openstax-bootstrap.less new file mode 100644 index 0000000000..0b535c809a --- /dev/null +++ b/shared/resources/styles/globals/openstax-bootstrap.less @@ -0,0 +1,50 @@ +// Core variables and mixins +@import "@{bootstrap-path}/variables"; +@import "@{bootstrap-path}/mixins"; + +// Reset and dependencies +@import "@{bootstrap-path}/normalize"; +@import "@{bootstrap-path}/print"; +// @import "@{bootstrap-path}/glyphicons"; + +// Core CSS +@import "@{bootstrap-path}/scaffolding"; +@import "@{bootstrap-path}/type"; +@import "@{bootstrap-path}/code"; +@import "@{bootstrap-path}/grid"; +@import "@{bootstrap-path}/tables"; +@import "@{bootstrap-path}/forms"; +@import "@{bootstrap-path}/buttons"; + +// Components +@import "@{bootstrap-path}/component-animations"; +@import "@{bootstrap-path}/dropdowns"; +@import "@{bootstrap-path}/button-groups"; +@import "@{bootstrap-path}/input-groups"; +// @import "@{bootstrap-path}/navs"; +// @import "@{bootstrap-path}/navbar"; +// @import "@{bootstrap-path}/breadcrumbs"; +// @import "@{bootstrap-path}/pagination"; +// @import "@{bootstrap-path}/pager"; +// @import "@{bootstrap-path}/labels"; +// @import "@{bootstrap-path}/badges"; +// @import "@{bootstrap-path}/jumbotron"; +// @import "@{bootstrap-path}/thumbnails"; +@import "@{bootstrap-path}/alerts"; +@import "@{bootstrap-path}/progress-bars"; +// @import "@{bootstrap-path}/media"; +@import "@{bootstrap-path}/list-group"; +@import "@{bootstrap-path}/panels"; +@import "@{bootstrap-path}/responsive-embed"; +// @import "@{bootstrap-path}/wells"; +@import "@{bootstrap-path}/close"; + +// Components w/ JavaScript +@import "@{bootstrap-path}/modals"; +@import "@{bootstrap-path}/tooltip"; +@import "@{bootstrap-path}/popovers"; +// @import "@{bootstrap-path}/carousel"; + +// Utility classes +@import "@{bootstrap-path}/utilities"; +@import "@{bootstrap-path}/responsive-utilities"; diff --git a/shared/resources/styles/main.less b/shared/resources/styles/main.less new file mode 100644 index 0000000000..8776fbb438 --- /dev/null +++ b/shared/resources/styles/main.less @@ -0,0 +1,22 @@ +// External Libraries +@import './variables/paths'; +@import './globals/externals'; +@import './globals/maths'; + +@import './variables/index'; +@import './variables/colors'; +@import './variables/openstax-bootstrap'; +@import './mixins/index'; + +@import './components/pinned-header-footer-card/index'; +@import './components/pinned-header-footer-card/sections'; + +.openstax { + @import './_components'; + + &-wrapper { + @import './components/breadcrumbs/index'; + @import './components/breadcrumbs/icons'; + @import './globals/icons'; + } +} diff --git a/shared/resources/styles/mixins/flexbox.less b/shared/resources/styles/mixins/flexbox.less new file mode 100644 index 0000000000..2ae1fb416b --- /dev/null +++ b/shared/resources/styles/mixins/flexbox.less @@ -0,0 +1,137 @@ +// -------------------------------------------------- +// Flexbox LESS mixins +// The spec: http://www.w3.org/TR/css3-flexbox +// -------------------------------------------------- + +// Flexbox display +// flex or inline-flex +.flex-display(@display: flex) { + display: ~"-webkit-@{display}"; + display: ~"-moz-@{display}"; + display: ~"-ms-@{display}box"; // IE10 uses -ms-flexbox + display: ~"-ms-@{display}"; // IE11 + display: @display; +} + +// The 'flex' shorthand +// - applies to: flex items +// , initial, auto, or none +.flex(@columns: initial) { + -webkit-flex: @columns; + -moz-flex: @columns; + -ms-flex: @columns; + flex: @columns; +} + +// Flex Flow Direction +// - applies to: flex containers +// row | row-reverse | column | column-reverse +.flex-direction(@direction: row) { + -webkit-flex-direction: @direction; + -moz-flex-direction: @direction; + -ms-flex-direction: @direction; + flex-direction: @direction; +} + +// Flex Line Wrapping +// - applies to: flex containers +// nowrap | wrap | wrap-reverse +.flex-wrap(@wrap: nowrap) { + -webkit-flex-wrap: @wrap; + -moz-flex-wrap: @wrap; + -ms-flex-wrap: @wrap; + flex-wrap: @wrap; +} + +// Flex Direction and Wrap +// - applies to: flex containers +// || +.flex-flow(@flow) { + -webkit-flex-flow: @flow; + -moz-flex-flow: @flow; + -ms-flex-flow: @flow; + flex-flow: @flow; +} + +// Display Order +// - applies to: flex items +// +.flex-order(@order: 0) { + -webkit-order: @order; + -moz-order: @order; + -ms-order: @order; + order: @order; +} + +// Flex grow factor +// - applies to: flex items +// +.flex-grow(@grow: 0) { + -webkit-flex-grow: @grow; + -moz-flex-grow: @grow; + -ms-flex-grow: @grow; + flex-grow: @grow; +} + +// Flex shrink +// - applies to: flex item shrink factor +// +.flex-shrink(@shrink: 1) { + -webkit-flex-shrink: @shrink; + -moz-flex-shrink: @shrink; + -ms-flex-shrink: @shrink; + flex-shrink: @shrink; +} + +// Flex basis +// - the initial main size of the flex item +// - applies to: flex itemsnitial main size of the flex item +// +.flex-basis(@width: auto) { + -webkit-flex-basis: @width; + -moz-flex-basis: @width; + -ms-flex-basis: @width; + flex-basis: @width; +} + +// Axis Alignment +// - applies to: flex containers +// flex-start | flex-end | center | space-between | space-around +.justify-content(@justify: flex-start) { + -webkit-justify-content: @justify; + -moz-justify-content: @justify; + -ms-flex-pack: @justify; // IE 10 + -ms-justify-content: @justify; + justify-content: @justify; +} + +// Packing Flex Lines +// - applies to: multi-line flex containers +// flex-start | flex-end | center | space-between | space-around | stretch +.align-content(@align: stretch) { + -webkit-align-content: @align; + -moz-align-content: @align; + -ms-align-content: @align; + align-content: @align; +} + +// Cross-axis Alignment +// - applies to: flex containers +// flex-start | flex-end | center | baseline | stretch +.align-items(@align: stretch) { + -webkit-align-items: @align; + -moz-align-items: @align; + -ms-flex-align: @align; // IE 10 + -ms-align-items: @align; + align-items: @align; +} + +// Cross-axis Alignment +// - applies to: flex items +// auto | flex-start | flex-end | center | baseline | stretch +.align-self(@align: auto) { + -webkit-align-self: @align; + -moz-align-self: @align; + -ms-align-self: @align; + align-self: @align; +} diff --git a/shared/resources/styles/mixins/icon.less b/shared/resources/styles/mixins/icon.less new file mode 100644 index 0000000000..e52a2b0823 --- /dev/null +++ b/shared/resources/styles/mixins/icon.less @@ -0,0 +1,52 @@ +@icon-path: '../../images/icons'; +@icon-size-xs: 1rem; +@icon-size-sm: @icon-size-xs * 1.6; +@icon-size-md: @icon-size-xs * 2; +@icon-size-lg: @icon-size-xs * 4; +@icon-size-xlg: @icon-size-xs * 6; + +.x-icon-size(@size) { + display: inline-block; + width: @size; + height: @size; + background-size: @size @size; + background-repeat: no-repeat; + background-position: center; + .transition(~"transform .1s ease-in-out, margin .3s ease-in-out"); +} + +.x-course-icon-bg(@name; @selector: @name) { + // default to high-school icons + .icon-@{selector} { background-image: url("@{icon-path}/@{name}-hs-icon.svg"); } + &.is-high-school { + .icon-@{selector} { background-image: url("@{icon-path}/@{name}-hs-icon.svg"); } + } + &.is-college { + .icon-@{selector} { background-image: url("@{icon-path}/@{name}-cl-icon.svg"); } + } +} + +// x-icon-bg defaults to hs icons +.x-icon-bg(@name; @course_type: 'hs') { + background-image: url("@{icon-path}/@{name}-@{course_type}-icon.svg"); +} + +.openstax-icon-active(@scale: 1.4; @shadow: 0.12) { + .scale(@scale); + .box-shadow(0 1px 6px rgba(0, 0, 0, @shadow)); + border-radius: 50%; +} + +.crumb-circle(@radius){ + font-size: @radius; + border-radius: 2 * @radius; + line-height: 2 * @radius; + min-width: 2 * @radius; +} + +.crumb-text(){ + position: absolute; + width: 100%; + text-align: center; + left: 0; +} diff --git a/shared/resources/styles/mixins/index.less b/shared/resources/styles/mixins/index.less new file mode 100644 index 0000000000..07ede6e5ac --- /dev/null +++ b/shared/resources/styles/mixins/index.less @@ -0,0 +1,109 @@ +@import './flexbox'; +@import './icon'; + + +.openstax-tables() { + table { + width: 100%; + margin-bottom: 32px; + + padding: 20px 0; + line-height: 2.5rem; + thead { + th { + padding: 8px 10px; + border-bottom: solid 2px @openstax-neutral-light; + } + tr:first-of-type:not(:last-child) th { + padding-left: 0; + } + } + tr:nth-child(odd) { + background: @openstax-neutral-lightest; + } + tr:nth-child(even) { + background: @openstax-white; + } + td { + padding: 8px 10px; + } + tbody { + border-top: solid 4px @openstax-neutral-light; + } + caption, + thead tr:first-of-type:not(:last-child) th{ + background: @openstax-white; + + font-weight: 900; + color: @openstax-neutral-darker; + } + } +} + +.openstax-tables(@table-header, @table-bottom-color) { + table { + thead { + th { + background: @table-header; + } + } + tbody { + border-bottom: solid 4px @table-bottom-color; + } + } +} + +.fixed-bar() { + left: 0; + position: fixed; + width: 100%; + z-index: 2; +} + +.sans(@size: 1.5rem, @line-height: 3rem) { + font-family: 'Lato', Helvetica, sans-serif; + font-weight: 400; + font-style: normal; + font-size: @size; + line-height: @line-height; +} + +.make-shy() { + .transform-origin(0 0); + .translate(0, -@openstax-navbar-height); +} + +.make-shy-animate() { + .transition(transform 0.2s ease-out); +} + +.exercise-typography() { + font-size: 1.8rem; + line-height: 1.68em; +} + +.openstax-disable-text-select() { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Chrome/Safari/Opera */ + -khtml-user-select: none; /* Konqueror */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + not supported by any browser */ +} + +// limit an element's height to max-height and display a gradient at the bottom +.height-limited-panel(@max-height: 150px){ + position: relative; + max-height: @max-height; + overflow: hidden; + &:after { + content: ''; + width: 100%; + height: 150px; + position: absolute; + left: 0; + top: @max-height - 150px; + background: linear-gradient(to top, white, white 10%, rgba(255,255,255,0)); + } +} diff --git a/shared/resources/styles/no-conflict.less b/shared/resources/styles/no-conflict.less new file mode 100644 index 0000000000..044f621c72 --- /dev/null +++ b/shared/resources/styles/no-conflict.less @@ -0,0 +1,21 @@ +@import './variables/paths'; + +@import './variables/index'; +@import './variables/colors'; +@import './variables/openstax-bootstrap'; +@import './mixins/index'; + +@import "@{bootstrap-path}/mixins"; +@import "@{fa-path}less/variables.less"; +@import "@{fa-path}less/mixins.less"; + +.openstax { + @import "@{bootstrap-path}/popovers"; + @import './_components'; + + &-wrapper { + @import './components/breadcrumbs/index'; + @import './components/breadcrumbs/icons'; + @import './globals/icons'; + } +} diff --git a/shared/resources/styles/variables/colors.less b/shared/resources/styles/variables/colors.less new file mode 100644 index 0000000000..656f418bd4 --- /dev/null +++ b/shared/resources/styles/variables/colors.less @@ -0,0 +1,64 @@ +@orange: rgb(244, 118, 65); +@green: rgb(119, 175, 65); +@dark-blue: rgb(35, 48, 102); +@yellow: rgb(244, 208, 25); + +@pale-yellow: rgb(255, 255, 187); +@light-green: rgb(139, 199, 83); +@light-blue: rgb(0, 193, 222); + +@red: rgb(194, 0, 47); +@white: rgb(255, 255, 255); +@black: rgb(0, 0, 0); + + +@openstax-primary: @orange; +@openstax-secondary: @green; +@openstax-tertiary: @dark-blue; +@openstax-quaternary: @yellow; +@openstax-highlight: @pale-yellow; + +@openstax-secondary-light: @light-green; +@openstax-tertiary-light: @light-blue; + +@openstax-success: @openstax-secondary; +@openstax-info: @openstax-tertiary-light; +@openstax-info-bg: lighten(@openstax-info, 53%); // even lighter blue +@openstax-warning: @openstax-quaternary; +@openstax-danger: @red; // red + +@reading-color: @openstax-quaternary; +@homework-color: @openstax-tertiary-light; +@external-color: @openstax-tertiary; +@event-color: @openstax-secondary; +@extras-color: @openstax-primary; + +@openstax-correct-color: @openstax-success; +@openstax-correct-background: lighten(saturate(@openstax-success, 60%), 38%); +@openstax-incorrect-color: @openstax-danger; +@openstax-incorrect-background: lighten(@openstax-danger, 55%); +@openstax-answer-background: @openstax-info-bg; +@openstax-answer-color: @openstax-tertiary-light; // darker than hover + +@openstax-white: @white; +@openstax-neutral-lightest: rgb(249, 249, 249); // nearly white +@openstax-neutral-bright: rgb(245, 245, 245); // bright gray +@openstax-neutral-lighter: rgb(241, 241, 241); // light gray +@openstax-neutral-light: rgb(229, 229, 229); // light gray +@openstax-neutral-medium: rgb(160, 160, 160); // light gray +@openstax-neutral: rgb(129, 129, 129); // gray +@openstax-neutral-dark: rgb(95, 97, 99); // dark gray +@openstax-neutral-darker: rgb(66, 66, 66); // very dark gray +@openstax-black: @black; + +@page-background: @openstax-neutral-lighter; +@footer-background: @openstax-neutral-bright; +@border-color: @openstax-neutral-light; + +@form-error-color: #E35C3B; + +@link-color: darken(@openstax-info, 5%); +@exercise-question-uid-color: lighten(@openstax-neutral,20%); + +// NOTE: Make borders and input background colors one of the above grays with a high opacity so it works on +// multiple backgrounds. diff --git a/shared/resources/styles/variables/index.less b/shared/resources/styles/variables/index.less new file mode 100644 index 0000000000..069fccba77 --- /dev/null +++ b/shared/resources/styles/variables/index.less @@ -0,0 +1,4 @@ +@placeholder-selector: ~'::-webkit-input-placeholder, :-moz-placeholder, ::-moz-placeholder, :-ms-input-placeholder'; + +@openstax-navbar-height: 61px; +@openstax-navbar-padding-horizontal: 4rem; diff --git a/shared/resources/styles/variables/openstax-bootstrap.less b/shared/resources/styles/variables/openstax-bootstrap.less new file mode 100644 index 0000000000..69c2053024 --- /dev/null +++ b/shared/resources/styles/variables/openstax-bootstrap.less @@ -0,0 +1,865 @@ +// +// Variables +// -------------------------------------------------- + + +//== Colors +// +//## Gray and brand colors for use across Bootstrap. + +@gray-base: @openstax-black; +@gray-darker: @openstax-neutral-darker; // #222 +@gray-dark: @openstax-neutral-dark; // #333 +@gray: @openstax-neutral; // #555 +@gray-light: @openstax-neutral-light; // #777 +@gray-lighter: @openstax-neutral-lighter; // #eee + +@brand-primary: @openstax-primary; // openstax-primary, orange +@brand-success: @openstax-success; // openstax-secondary, green +@brand-info: @openstax-info; // openstax-tertiary-light, light blue-ish +@brand-warning: @openstax-warning; // openstax-quaternary, yellow +@brand-danger: @openstax-danger; // openstax-danger, red + +@state-info-bg: @openstax-info-bg; +// @container-large-desktop: (1200px + @grid-gutter-width); + +@link-hover-color: darken(@link-color, 5%); + + +@popover-max-width: none; +// //== Scaffolding +// // +// //## Settings for some of the most global styles. + +// //** Background color for ``. +// @body-bg: #fff; +// //** Global text color on ``. +// @text-color: @gray-dark; + +// //** Global textual link color. +// @link-color: @brand-primary; +// //** Link hover color set via `darken()` function. +// //** Link hover decoration. +// @link-hover-decoration: underline; + + +// //== Typography +// // +// //## Font, line-height, and color for body text, headings, and more. + +@font-family-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif; +// @font-family-serif: Georgia, "Times New Roman", Times, serif; +// //** Default monospace fonts for ``, ``, and `
    `.
    +// @font-family-monospace:   Menlo, Monaco, Consolas, "Courier New", monospace;
    +@font-family-base:        @font-family-sans-serif;
    +
    +@font-size-base:          14px;
    +// @font-size-large:         ceil((@font-size-base * 1.25)); // ~18px
    +// @font-size-small:         ceil((@font-size-base * 0.85)); // ~12px
    +
    +// @font-size-h1:            floor((@font-size-base * 2.6)); // ~36px
    +// @font-size-h2:            floor((@font-size-base * 2.15)); // ~30px
    +// @font-size-h3:            ceil((@font-size-base * 1.7)); // ~24px
    +// @font-size-h4:            ceil((@font-size-base * 1.25)); // ~18px
    +// @font-size-h5:            @font-size-base;
    +// @font-size-h6:            ceil((@font-size-base * 0.85)); // ~12px
    +
    +// //** Unit-less `line-height` for use in components like buttons.
    +@line-height-base:        1.428571429; // 20/14
    +// //** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
    +// @line-height-computed:    floor((@font-size-base * @line-height-base)); // ~20px
    +
    +// //** By default, this inherits from the ``.
    +// @headings-font-family:    inherit;
    +// @headings-font-weight:    500;
    +// @headings-line-height:    1.1;
    +// @headings-color:          inherit;
    +
    +
    +// //== Iconography
    +// //
    +// //## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
    +
    +// //** Load fonts from this directory.
    +// @icon-font-path:          "../fonts/";
    +// //** File name for all font files.
    +// @icon-font-name:          "glyphicons-halflings-regular";
    +// //** Element ID within SVG icon file.
    +// @icon-font-svg-id:        "glyphicons_halflingsregular";
    +
    +
    +// //== Components
    +// //
    +// //## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
    +
    +// @padding-base-vertical:     6px;
    +// @padding-base-horizontal:   12px;
    +
    +// @padding-large-vertical:    10px;
    +// @padding-large-horizontal:  16px;
    +
    +// @padding-small-vertical:    5px;
    +// @padding-small-horizontal:  10px;
    +
    +// @padding-xs-vertical:       1px;
    +// @padding-xs-horizontal:     5px;
    +
    +// @line-height-large:         1.3333333; // extra decimals for Win 8.1 Chrome
    +// @line-height-small:         1.5;
    +
    +// @border-radius-base:        4px;
    +@border-radius-large:       6px;
    +// @border-radius-small:       3px;
    +
    +// //** Global color for active items (e.g., navs or dropdowns).
    +// @component-active-color:    #fff;
    +// //** Global background color for active items (e.g., navs or dropdowns).
    +// @component-active-bg:       @brand-primary;
    +
    +// //** Width of the `border` for generating carets that indicator dropdowns.
    +// @caret-width-base:          4px;
    +// //** Carets increase slightly in size for larger components.
    +// @caret-width-large:         5px;
    +
    +
    +// //== Tables
    +// //
    +// //## Customizes the `.table` component with basic values, each used across all table variations.
    +
    +// //** Padding for ``s and ``s.
    +// @table-cell-padding:            8px;
    +// //** Padding for cells in `.table-condensed`.
    +// @table-condensed-cell-padding:  5px;
    +
    +// //** Default background color used for all tables.
    +// @table-bg:                      transparent;
    +// //** Background color used for `.table-striped`.
    +// @table-bg-accent:               #f9f9f9;
    +// //** Background color used for `.table-hover`.
    +// @table-bg-hover:                #f5f5f5;
    +// @table-bg-active:               @table-bg-hover;
    +
    +// //** Border color for table and cell borders.
    +// @table-border-color:            #ddd;
    +
    +
    +// //== Buttons
    +// //
    +// //## For each of Bootstrap's buttons, define text, background and border color.
    +
    +// @btn-font-weight:                normal;
    +
    +// @btn-default-color:              #333;
    +// @btn-default-bg:                 #fff;
    +// @btn-default-border:             #ccc;
    +
    +// @btn-primary-color:              #fff;
    +// @btn-primary-bg:                 @brand-primary;
    +// @btn-primary-border:             darken(@btn-primary-bg, 5%);
    +
    +// @btn-success-color:              #fff;
    +// @btn-success-bg:                 @brand-success;
    +// @btn-success-border:             darken(@btn-success-bg, 5%);
    +
    +// @btn-info-color:                 #fff;
    +// @btn-info-bg:                    @brand-info;
    +// @btn-info-border:                darken(@btn-info-bg, 5%);
    +
    +// @btn-warning-color:              #fff;
    +// @btn-warning-bg:                 @brand-warning;
    +// @btn-warning-border:             darken(@btn-warning-bg, 5%);
    +
    +// @btn-danger-color:               #fff;
    +// @btn-danger-bg:                  @brand-danger;
    +// @btn-danger-border:              darken(@btn-danger-bg, 5%);
    +
    +// @btn-link-disabled-color:        @gray-light;
    +
    +
    +// //== Forms
    +// //
    +// //##
    +
    +// //** `` background color
    +// @input-bg:                       #fff;
    +// //** `` background color
    +// @input-bg-disabled:              @gray-lighter;
    +
    +// //** Text color for ``s
    +// @input-color:                    @gray;
    +// //** `` border color
    +// @input-border:                   #ccc;
    +
    +// // TODO: Rename `@input-border-radius` to `@input-border-radius-base` in v4
    +// //** Default `.form-control` border radius
    +// // This has no effect on ``s in CSS.
    +// @input-border-radius:            @border-radius-base;
    +// //** Large `.form-control` border radius
    +// @input-border-radius-large:      @border-radius-large;
    +// //** Small `.form-control` border radius
    +// @input-border-radius-small:      @border-radius-small;
    +
    +// //** Border color for inputs on focus
    +// @input-border-focus:             #66afe9;
    +
    +// //** Placeholder text color
    +// @input-color-placeholder:        #999;
    +
    +// //** Default `.form-control` height
    +// @input-height-base:              (@line-height-computed + (@padding-base-vertical * 2) + 2);
    +// //** Large `.form-control` height
    +// @input-height-large:             (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);
    +// //** Small `.form-control` height
    +// @input-height-small:             (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
    +
    +// //** `.form-group` margin
    +// @form-group-margin-bottom:       15px;
    +
    +// @legend-color:                   @gray-dark;
    +// @legend-border-color:            #e5e5e5;
    +
    +// //** Background color for textual input addons
    +// @input-group-addon-bg:           @gray-lighter;
    +// //** Border color for textual input addons
    +// @input-group-addon-border-color: @input-border;
    +
    +// //** Disabled cursor for form controls and buttons.
    +// @cursor-disabled:                not-allowed;
    +
    +
    +// //== Dropdowns
    +// //
    +// //## Dropdown menu container and contents.
    +
    +// //** Background for the dropdown menu.
    +// @dropdown-bg:                    #fff;
    +// //** Dropdown menu `border-color`.
    +// @dropdown-border:                rgba(0,0,0,.15);
    +// //** Dropdown menu `border-color` **for IE8**.
    +// @dropdown-fallback-border:       #ccc;
    +// //** Divider color for between dropdown items.
    +// @dropdown-divider-bg:            #e5e5e5;
    +
    +// //** Dropdown link text color.
    +// @dropdown-link-color:            @gray-dark;
    +// //** Hover color for dropdown links.
    +// @dropdown-link-hover-color:      darken(@gray-dark, 5%);
    +// //** Hover background for dropdown links.
    +// @dropdown-link-hover-bg:         #f5f5f5;
    +
    +// //** Active dropdown menu item text color.
    +// @dropdown-link-active-color:     @component-active-color;
    +// //** Active dropdown menu item background color.
    +// @dropdown-link-active-bg:        @component-active-bg;
    +
    +// //** Disabled dropdown menu item background color.
    +// @dropdown-link-disabled-color:   @gray-light;
    +
    +// //** Text color for headers within dropdown menus.
    +// @dropdown-header-color:          @gray-light;
    +
    +// //** Deprecated `@dropdown-caret-color` as of v3.1.0
    +// @dropdown-caret-color:           #000;
    +
    +
    +// //-- Z-index master list
    +// //
    +// // Warning: Avoid customizing these values. They're used for a bird's eye view
    +// // of components dependent on the z-axis and are designed to all work together.
    +// //
    +// // Note: These variables are not generated into the Customizer.
    +
    +// @zindex-navbar:            1000;
    +// @zindex-dropdown:          1000;
    +@zindex-popover:           1060;
    +// @zindex-tooltip:           1070;
    +// @zindex-navbar-fixed:      1030;
    +// @zindex-modal-background:  1040;
    +// @zindex-modal:             1050;
    +
    +
    +// //== Media queries breakpoints
    +// //
    +// //## Define the breakpoints at which your layout will change, adapting to different screen sizes.
    +
    +// // Extra small screen / phone
    +// //** Deprecated `@screen-xs` as of v3.0.1
    +// @screen-xs:                  480px;
    +// //** Deprecated `@screen-xs-min` as of v3.2.0
    +// @screen-xs-min:              @screen-xs;
    +// //** Deprecated `@screen-phone` as of v3.0.1
    +// @screen-phone:               @screen-xs-min;
    +
    +// // Small screen / tablet
    +// //** Deprecated `@screen-sm` as of v3.0.1
    +// @screen-sm:                  768px;
    +// @screen-sm-min:              @screen-sm;
    +// //** Deprecated `@screen-tablet` as of v3.0.1
    +// @screen-tablet:              @screen-sm-min;
    +
    +// // Medium screen / desktop
    +// //** Deprecated `@screen-md` as of v3.0.1
    +// @screen-md:                  992px;
    +// @screen-md-min:              @screen-md;
    +// //** Deprecated `@screen-desktop` as of v3.0.1
    +// @screen-desktop:             @screen-md-min;
    +
    +// // Large screen / wide desktop
    +// //** Deprecated `@screen-lg` as of v3.0.1
    +// @screen-lg:                  1200px;
    +// @screen-lg-min:              @screen-lg;
    +// //** Deprecated `@screen-lg-desktop` as of v3.0.1
    +// @screen-lg-desktop:          @screen-lg-min;
    +
    +// // So media queries don't overlap when required, provide a maximum
    +// @screen-xs-max:              (@screen-sm-min - 1);
    +// @screen-sm-max:              (@screen-md-min - 1);
    +// @screen-md-max:              (@screen-lg-min - 1);
    +
    +
    +// //== Grid system
    +// //
    +// //## Define your custom responsive grid.
    +
    +// //** Number of columns in the grid.
    +// @grid-columns:              12;
    +// //** Padding between columns. Gets divided in half for the left and right.
    +// @grid-gutter-width:         30px;
    +// // Navbar collapse
    +// //** Point at which the navbar becomes uncollapsed.
    +// @grid-float-breakpoint:     @screen-sm-min;
    +// //** Point at which the navbar begins collapsing.
    +// @grid-float-breakpoint-max: (@grid-float-breakpoint - 1);
    +
    +
    +// //== Container sizes
    +// //
    +// //## Define the maximum width of `.container` for different screen sizes.
    +
    +// // Small screen / tablet
    +// @container-tablet:             (720px + @grid-gutter-width);
    +// //** For `@screen-sm-min` and up.
    +// @container-sm:                 @container-tablet;
    +
    +// // Medium screen / desktop
    +// @container-desktop:            (940px + @grid-gutter-width);
    +// //** For `@screen-md-min` and up.
    +// @container-md:                 @container-desktop;
    +
    +// // Large screen / wide desktop
    +// //** For `@screen-lg-min` and up.
    +// @container-lg:                 @container-large-desktop;
    +
    +
    +// //== Navbar
    +// //
    +// //##
    +
    +// // Basics of a navbar
    +// @navbar-height:                    50px;
    +// @navbar-margin-bottom:             @line-height-computed;
    +// @navbar-border-radius:             @border-radius-base;
    +// @navbar-padding-horizontal:        floor((@grid-gutter-width / 2));
    +// @navbar-padding-vertical:          ((@navbar-height - @line-height-computed) / 2);
    +// @navbar-collapse-max-height:       340px;
    +
    +// @navbar-default-color:             #777;
    +// @navbar-default-bg:                #f8f8f8;
    +// @navbar-default-border:            darken(@navbar-default-bg, 6.5%);
    +
    +// // Navbar links
    +// @navbar-default-link-color:                #777;
    +// @navbar-default-link-hover-color:          #333;
    +// @navbar-default-link-hover-bg:             transparent;
    +// @navbar-default-link-active-color:         #555;
    +// @navbar-default-link-active-bg:            darken(@navbar-default-bg, 6.5%);
    +// @navbar-default-link-disabled-color:       #ccc;
    +// @navbar-default-link-disabled-bg:          transparent;
    +
    +// // Navbar brand label
    +// @navbar-default-brand-color:               @navbar-default-link-color;
    +// @navbar-default-brand-hover-color:         darken(@navbar-default-brand-color, 10%);
    +// @navbar-default-brand-hover-bg:            transparent;
    +
    +// // Navbar toggle
    +// @navbar-default-toggle-hover-bg:           #ddd;
    +// @navbar-default-toggle-icon-bar-bg:        #888;
    +// @navbar-default-toggle-border-color:       #ddd;
    +
    +
    +// // Inverted navbar
    +// // Reset inverted navbar basics
    +// @navbar-inverse-color:                      lighten(@gray-light, 15%);
    +// @navbar-inverse-bg:                         #222;
    +// @navbar-inverse-border:                     darken(@navbar-inverse-bg, 10%);
    +
    +// // Inverted navbar links
    +// @navbar-inverse-link-color:                 lighten(@gray-light, 15%);
    +// @navbar-inverse-link-hover-color:           #fff;
    +// @navbar-inverse-link-hover-bg:              transparent;
    +// @navbar-inverse-link-active-color:          @navbar-inverse-link-hover-color;
    +// @navbar-inverse-link-active-bg:             darken(@navbar-inverse-bg, 10%);
    +// @navbar-inverse-link-disabled-color:        #444;
    +// @navbar-inverse-link-disabled-bg:           transparent;
    +
    +// // Inverted navbar brand label
    +// @navbar-inverse-brand-color:                @navbar-inverse-link-color;
    +// @navbar-inverse-brand-hover-color:          #fff;
    +// @navbar-inverse-brand-hover-bg:             transparent;
    +
    +// // Inverted navbar toggle
    +// @navbar-inverse-toggle-hover-bg:            #333;
    +// @navbar-inverse-toggle-icon-bar-bg:         #fff;
    +// @navbar-inverse-toggle-border-color:        #333;
    +
    +
    +// //== Navs
    +// //
    +// //##
    +
    +// //=== Shared nav styles
    +// @nav-link-padding:                          10px 15px;
    +// @nav-link-hover-bg:                         @gray-lighter;
    +
    +// @nav-disabled-link-color:                   @gray-light;
    +// @nav-disabled-link-hover-color:             @gray-light;
    +
    +// //== Tabs
    +// @nav-tabs-border-color:                     #ddd;
    +
    +// @nav-tabs-link-hover-border-color:          @gray-lighter;
    +
    +// @nav-tabs-active-link-hover-bg:             @body-bg;
    +// @nav-tabs-active-link-hover-color:          @gray;
    +// @nav-tabs-active-link-hover-border-color:   #ddd;
    +
    +// @nav-tabs-justified-link-border-color:            #ddd;
    +// @nav-tabs-justified-active-link-border-color:     @body-bg;
    +
    +// //== Pills
    +// @nav-pills-border-radius:                   @border-radius-base;
    +// @nav-pills-active-link-hover-bg:            @component-active-bg;
    +// @nav-pills-active-link-hover-color:         @component-active-color;
    +
    +
    +// //== Pagination
    +// //
    +// //##
    +
    +// @pagination-color:                     @link-color;
    +// @pagination-bg:                        #fff;
    +// @pagination-border:                    #ddd;
    +
    +// @pagination-hover-color:               @link-hover-color;
    +// @pagination-hover-bg:                  @gray-lighter;
    +// @pagination-hover-border:              #ddd;
    +
    +// @pagination-active-color:              #fff;
    +// @pagination-active-bg:                 @brand-primary;
    +// @pagination-active-border:             @brand-primary;
    +
    +// @pagination-disabled-color:            @gray-light;
    +// @pagination-disabled-bg:               #fff;
    +// @pagination-disabled-border:           #ddd;
    +
    +
    +// //== Pager
    +// //
    +// //##
    +
    +// @pager-bg:                             @pagination-bg;
    +// @pager-border:                         @pagination-border;
    +// @pager-border-radius:                  15px;
    +
    +// @pager-hover-bg:                       @pagination-hover-bg;
    +
    +// @pager-active-bg:                      @pagination-active-bg;
    +// @pager-active-color:                   @pagination-active-color;
    +
    +// @pager-disabled-color:                 @pagination-disabled-color;
    +
    +
    +// //== Jumbotron
    +// //
    +// //##
    +
    +// @jumbotron-padding:              30px;
    +// @jumbotron-color:                inherit;
    +// @jumbotron-bg:                   @gray-lighter;
    +// @jumbotron-heading-color:        inherit;
    +// @jumbotron-font-size:            ceil((@font-size-base * 1.5));
    +
    +
    +// //== Form states and alerts
    +// //
    +// //## Define colors for form feedback states and, by default, alerts.
    +
    +// @state-success-text:             #3c763d;
    +// @state-success-bg:               #dff0d8;
    +// @state-success-border:           darken(spin(@state-success-bg, -10), 5%);
    +
    +// @state-info-text:                #31708f;
    +// @state-info-bg:                  #d9edf7;
    +// @state-info-border:              darken(spin(@state-info-bg, -10), 7%);
    +
    +// @state-warning-text:             #8a6d3b;
    +// @state-warning-bg:               #fcf8e3;
    +// @state-warning-border:           darken(spin(@state-warning-bg, -10), 5%);
    +
    +// @state-danger-text:              #a94442;
    +// @state-danger-bg:                #f2dede;
    +// @state-danger-border:            darken(spin(@state-danger-bg, -10), 5%);
    +
    +
    +// //== Tooltips
    +// //
    +// //##
    +
    +// //** Tooltip max width
    +// @tooltip-max-width:           200px;
    +// //** Tooltip text color
    +// @tooltip-color:               #fff;
    +// //** Tooltip background color
    +// @tooltip-bg:                  #000;
    +// @tooltip-opacity:             .9;
    +
    +// //** Tooltip arrow width
    +// @tooltip-arrow-width:         5px;
    +// //** Tooltip arrow color
    +// @tooltip-arrow-color:         @tooltip-bg;
    +
    +
    +//== Popovers
    +//
    +//##
    +
    +//** Popover body background color
    +@popover-bg:                          #fff;
    +//** Popover maximum width
    +@popover-max-width:                   276px;
    +//** Popover border color
    +@popover-border-color:                rgba(0,0,0,.2);
    +//** Popover fallback border color
    +@popover-fallback-border-color:       #ccc;
    +
    +//** Popover title background color
    +@popover-title-bg:                    darken(@popover-bg, 3%);
    +
    +//** Popover arrow width
    +@popover-arrow-width:                 10px;
    +//** Popover arrow color
    +@popover-arrow-color:                 @popover-bg;
    +
    +//** Popover outer arrow width
    +@popover-arrow-outer-width:           (@popover-arrow-width + 1);
    +//** Popover outer arrow color
    +@popover-arrow-outer-color:           fadein(@popover-border-color, 5%);
    +//** Popover outer arrow fallback color
    +@popover-arrow-outer-fallback-color:  darken(@popover-fallback-border-color, 20%);
    +
    +
    +// //== Labels
    +// //
    +// //##
    +
    +// //** Default label background color
    +// @label-default-bg:            @gray-light;
    +// //** Primary label background color
    +// @label-primary-bg:            @brand-primary;
    +// //** Success label background color
    +// @label-success-bg:            @brand-success;
    +// //** Info label background color
    +// @label-info-bg:               @brand-info;
    +// //** Warning label background color
    +// @label-warning-bg:            @brand-warning;
    +// //** Danger label background color
    +// @label-danger-bg:             @brand-danger;
    +
    +// //** Default label text color
    +// @label-color:                 #fff;
    +// //** Default text color of a linked label
    +// @label-link-hover-color:      #fff;
    +
    +
    +// //== Modals
    +// //
    +// //##
    +
    +// //** Padding applied to the modal body
    +// @modal-inner-padding:         15px;
    +
    +// //** Padding applied to the modal title
    +// @modal-title-padding:         15px;
    +// //** Modal title line-height
    +// @modal-title-line-height:     @line-height-base;
    +
    +// //** Background color of modal content area
    +// @modal-content-bg:                             #fff;
    +// //** Modal content border color
    +// @modal-content-border-color:                   rgba(0,0,0,.2);
    +// //** Modal content border color **for IE8**
    +// @modal-content-fallback-border-color:          #999;
    +
    +// //** Modal backdrop background color
    +// @modal-backdrop-bg:           #000;
    +// //** Modal backdrop opacity
    +// @modal-backdrop-opacity:      .5;
    +// //** Modal header border color
    +// @modal-header-border-color:   #e5e5e5;
    +// //** Modal footer border color
    +// @modal-footer-border-color:   @modal-header-border-color;
    +
    +// @modal-lg:                    900px;
    +// @modal-md:                    600px;
    +// @modal-sm:                    300px;
    +
    +
    +// //== Alerts
    +// //
    +// //## Define alert colors, border radius, and padding.
    +
    +// @alert-padding:               15px;
    +// @alert-border-radius:         @border-radius-base;
    +// @alert-link-font-weight:      bold;
    +
    +// @alert-success-bg:            @state-success-bg;
    +// @alert-success-text:          @state-success-text;
    +// @alert-success-border:        @state-success-border;
    +
    +// @alert-info-bg:               @state-info-bg;
    +// @alert-info-text:             @state-info-text;
    +// @alert-info-border:           @state-info-border;
    +
    +// @alert-warning-bg:            @state-warning-bg;
    +// @alert-warning-text:          @state-warning-text;
    +// @alert-warning-border:        @state-warning-border;
    +
    +// @alert-danger-bg:             @state-danger-bg;
    +// @alert-danger-text:           @state-danger-text;
    +// @alert-danger-border:         @state-danger-border;
    +
    +
    +// //== Progress bars
    +// //
    +// //##
    +
    +// //** Background color of the whole progress component
    +// @progress-bg:                 #f5f5f5;
    +// //** Progress bar text color
    +// @progress-bar-color:          #fff;
    +// //** Variable for setting rounded corners on progress bar.
    +// @progress-border-radius:      @border-radius-base;
    +
    +// //** Default progress bar color
    +// @progress-bar-bg:             @brand-primary;
    +// //** Success progress bar color
    +// @progress-bar-success-bg:     @brand-success;
    +// //** Warning progress bar color
    +// @progress-bar-warning-bg:     @brand-warning;
    +// //** Danger progress bar color
    +// @progress-bar-danger-bg:      @brand-danger;
    +// //** Info progress bar color
    +// @progress-bar-info-bg:        @brand-info;
    +
    +
    +// //== List group
    +// //
    +// //##
    +
    +// //** Background color on `.list-group-item`
    +// @list-group-bg:                 #fff;
    +// //** `.list-group-item` border color
    +// @list-group-border:             #ddd;
    +// //** List group border radius
    +// @list-group-border-radius:      @border-radius-base;
    +
    +// //** Background color of single list items on hover
    +// @list-group-hover-bg:           #f5f5f5;
    +// //** Text color of active list items
    +// @list-group-active-color:       @component-active-color;
    +// //** Background color of active list items
    +// @list-group-active-bg:          @component-active-bg;
    +// //** Border color of active list elements
    +// @list-group-active-border:      @list-group-active-bg;
    +// //** Text color for content within active list items
    +// @list-group-active-text-color:  lighten(@list-group-active-bg, 40%);
    +
    +// //** Text color of disabled list items
    +// @list-group-disabled-color:      @gray-light;
    +// //** Background color of disabled list items
    +// @list-group-disabled-bg:         @gray-lighter;
    +// //** Text color for content within disabled list items
    +// @list-group-disabled-text-color: @list-group-disabled-color;
    +
    +// @list-group-link-color:         #555;
    +// @list-group-link-hover-color:   @list-group-link-color;
    +// @list-group-link-heading-color: #333;
    +
    +
    +// //== Panels
    +// //
    +// //##
    +
    +// @panel-bg:                    #fff;
    +// @panel-body-padding:          15px;
    +// @panel-heading-padding:       10px 15px;
    +// @panel-footer-padding:        @panel-heading-padding;
    +// @panel-border-radius:         @border-radius-base;
    +
    +// //** Border color for elements within panels
    +// @panel-inner-border:          #ddd;
    +// @panel-footer-bg:             #f5f5f5;
    +
    +// @panel-default-text:          @gray-dark;
    +// @panel-default-border:        #ddd;
    +// @panel-default-heading-bg:    #f5f5f5;
    +
    +// @panel-primary-text:          #fff;
    +// @panel-primary-border:        @brand-primary;
    +// @panel-primary-heading-bg:    @brand-primary;
    +
    +// @panel-success-text:          @state-success-text;
    +// @panel-success-border:        @state-success-border;
    +// @panel-success-heading-bg:    @state-success-bg;
    +
    +// @panel-info-text:             @state-info-text;
    +// @panel-info-border:           @state-info-border;
    +// @panel-info-heading-bg:       @state-info-bg;
    +
    +// @panel-warning-text:          @state-warning-text;
    +// @panel-warning-border:        @state-warning-border;
    +// @panel-warning-heading-bg:    @state-warning-bg;
    +
    +// @panel-danger-text:           @state-danger-text;
    +// @panel-danger-border:         @state-danger-border;
    +// @panel-danger-heading-bg:     @state-danger-bg;
    +
    +
    +// //== Thumbnails
    +// //
    +// //##
    +
    +// //** Padding around the thumbnail image
    +// @thumbnail-padding:           4px;
    +// //** Thumbnail background color
    +// @thumbnail-bg:                @body-bg;
    +// //** Thumbnail border color
    +// @thumbnail-border:            #ddd;
    +// //** Thumbnail border radius
    +// @thumbnail-border-radius:     @border-radius-base;
    +
    +// //** Custom text color for thumbnail captions
    +// @thumbnail-caption-color:     @text-color;
    +// //** Padding around the thumbnail caption
    +// @thumbnail-caption-padding:   9px;
    +
    +
    +// //== Wells
    +// //
    +// //##
    +
    +// @well-bg:                     #f5f5f5;
    +// @well-border:                 darken(@well-bg, 7%);
    +
    +
    +// //== Badges
    +// //
    +// //##
    +
    +// @badge-color:                 #fff;
    +// //** Linked badge text color on hover
    +// @badge-link-hover-color:      #fff;
    +// @badge-bg:                    @gray-light;
    +
    +// //** Badge text color in active nav link
    +// @badge-active-color:          @link-color;
    +// //** Badge background color in active nav link
    +// @badge-active-bg:             #fff;
    +
    +// @badge-font-weight:           bold;
    +// @badge-line-height:           1;
    +// @badge-border-radius:         10px;
    +
    +
    +// //== Breadcrumbs
    +// //
    +// //##
    +
    +// @breadcrumb-padding-vertical:   8px;
    +// @breadcrumb-padding-horizontal: 15px;
    +// //** Breadcrumb background color
    +// @breadcrumb-bg:                 #f5f5f5;
    +// //** Breadcrumb text color
    +// @breadcrumb-color:              #ccc;
    +// //** Text color of current page in the breadcrumb
    +// @breadcrumb-active-color:       @gray-light;
    +// //** Textual separator for between breadcrumb elements
    +// @breadcrumb-separator:          "/";
    +
    +
    +// //== Carousel
    +// //
    +// //##
    +
    +// @carousel-text-shadow:                        0 1px 2px rgba(0,0,0,.6);
    +
    +// @carousel-control-color:                      #fff;
    +// @carousel-control-width:                      15%;
    +// @carousel-control-opacity:                    .5;
    +// @carousel-control-font-size:                  20px;
    +
    +// @carousel-indicator-active-bg:                #fff;
    +// @carousel-indicator-border-color:             #fff;
    +
    +// @carousel-caption-color:                      #fff;
    +
    +
    +// //== Close
    +// //
    +// //##
    +
    +// @close-font-weight:           bold;
    +// @close-color:                 #000;
    +// @close-text-shadow:           0 1px 0 #fff;
    +
    +
    +// //== Code
    +// //
    +// //##
    +
    +// @code-color:                  #c7254e;
    +// @code-bg:                     #f9f2f4;
    +
    +// @kbd-color:                   #fff;
    +// @kbd-bg:                      #333;
    +
    +// @pre-bg:                      #f5f5f5;
    +// @pre-color:                   @gray-dark;
    +// @pre-border-color:            #ccc;
    +// @pre-scrollable-max-height:   340px;
    +
    +
    +// //== Type
    +// //
    +// //##
    +
    +// //** Horizontal offset for forms and lists.
    +// @component-offset-horizontal: 180px;
    +// //** Text muted color
    +// @text-muted:                  @gray-light;
    +// //** Abbreviations and acronyms border color
    +// @abbr-border-color:           @gray-light;
    +// //** Headings small color
    +// @headings-small-color:        @gray-light;
    +// //** Blockquote small color
    +// @blockquote-small-color:      @gray-light;
    +// //** Blockquote font size
    +// @blockquote-font-size:        (@font-size-base * 1.25);
    +// //** Blockquote border color
    +// @blockquote-border-color:     @gray-lighter;
    +// //** Page header border color
    +// @page-header-border-color:    @gray-lighter;
    +// //** Width of horizontal description list titles
    +// @dl-horizontal-offset:        @component-offset-horizontal;
    +// //** Horizontal line color.
    +// @hr-border:                   @gray-lighter;
    diff --git a/shared/resources/styles/variables/paths.less b/shared/resources/styles/variables/paths.less
    new file mode 100644
    index 0000000000..dc4c0eb435
    --- /dev/null
    +++ b/shared/resources/styles/variables/paths.less
    @@ -0,0 +1,2 @@
    +@bootstrap-path: '~bootstrap/less/';
    +@fa-path: '~font-awesome/';
    diff --git a/shared/src/components/breadcrumb/index.cjsx b/shared/src/components/breadcrumb/index.cjsx
    new file mode 100644
    index 0000000000..63a926bcce
    --- /dev/null
    +++ b/shared/src/components/breadcrumb/index.cjsx
    @@ -0,0 +1,87 @@
    +React = require 'react'
    +_ = require 'underscore'
    +classnames = require 'classnames'
    +
    +Breadcrumb = React.createClass
    +  displayName: 'Breadcrumb'
    +  propTypes:
    +    crumb: React.PropTypes.object.isRequired
    +    goToStep: React.PropTypes.func.isRequired
    +    step: React.PropTypes.object.isRequired
    +    canReview: React.PropTypes.bool
    +    currentStep: React.PropTypes.number
    +    onMouseEnter: React.PropTypes.func
    +    onMouseLeave: React.PropTypes.func
    +
    +  getDefaultProps: ->
    +    canReview: true
    +    step: {}
    +
    +  getInitialState: ->
    +    @getState(@props)
    +
    +  componentWillReceiveProps: (nextProps) ->
    +    nextState = @getState(nextProps)
    +    @setState(nextState)
    +
    +  getState: ({crumb, currentStep, step, canReview}) ->
    +    isCorrect = false
    +    isIncorrect = false
    +    isCurrent = crumb.key is currentStep
    +    isCompleted = step?.is_completed
    +    isEnd = crumb.type is 'end'
    +    crumbType = if isEnd then crumb.type else step?.type
    +
    +    if isCompleted
    +      if canReview and step.correct_answer_id?
    +        if step.is_correct
    +          isCorrect = true
    +        else if step.answer_id
    +          isIncorrect = true
    +
    +    {isCorrect, isIncorrect, isCurrent, isCompleted, isEnd, crumbType}
    +
    +  render: ->
    +    {step, crumb, goToStep, className} = @props
    +    {isCorrect, isIncorrect, isCurrent, isCompleted, isEnd, crumbType} = @state
    +
    +    propsToPassOn = _.omit(@props, 'onClick', 'title', 'className', 'data-chapter', 'key', 'step')
    +
    +    if isCurrent
    +      title = "Current Step (#{crumbType})"
    +
    +    if isCompleted
    +      title ?= "Step Completed (#{crumbType}). Click to review"
    +
    +    if isCorrect
    +      status = 
    +
    +    if isIncorrect
    +      status = 
    +
    +    if isEnd
    +      title = "#{step.title} Completion"
    +
    +    classes = classnames 'openstax-breadcrumbs-step', 'icon-stack', 'icon-lg', step.group, "breadcrumb-#{crumbType}", className,
    +      current: isCurrent
    +      active: isCurrent
    +      completed: isCompleted
    +      'status-correct': isCorrect
    +      'status-incorrect': isIncorrect
    +
    +    # build list of icon classes from the crumb type and the step labels
    +    crumbClasses = _.map(crumb.data.labels, (label) -> "icon-#{label}") if crumb.data.labels?
    +    iconClasses = classnames "icon-#{crumbType}", crumbClasses
    +
    +    
    +      
    +      {status}
    +    
    +
    +module.exports = Breadcrumb
    diff --git a/shared/src/components/buttons/async-button.cjsx b/shared/src/components/buttons/async-button.cjsx
    new file mode 100644
    index 0000000000..10843a58b5
    --- /dev/null
    +++ b/shared/src/components/buttons/async-button.cjsx
    @@ -0,0 +1,88 @@
    +React = require 'react'
    +BS = require 'react-bootstrap'
    +_ = require 'underscore'
    +
    +RefreshButton = require './refresh-button'
    +
    +module.exports = React.createClass
    +  displayName: 'AsyncButton'
    +
    +  propTypes:
    +    isWaiting: React.PropTypes.bool.isRequired
    +    isDone: React.PropTypes.bool
    +    isFailed: React.PropTypes.bool
    +    waitingText: React.PropTypes.node # TODO: This should be a Component or array
    +    failedState: React.PropTypes.func
    +    failedProps: React.PropTypes.object
    +    doneText: React.PropTypes.node
    +    isJob: React.PropTypes.bool
    +    timeoutLength: React.PropTypes.number
    +
    +  getInitialState: ->
    +    isTimedout: false
    +    delayTimeout: null
    +
    +  componentWillReceiveProps: (nextProps) ->
    +    if @props.isWaiting isnt nextProps.isWaiting
    +      @clearDelay() unless nextProps.isWaiting
    +      @setState(delayTimeout: null)
    +
    +  componentDidUpdate: ->
    +    {isWaiting, isJob} = @props
    +    {isTimedout, delayTimeout} = @state
    +
    +    if not delayTimeout and isWaiting and not isTimedout
    +      timeout = @props.timeoutLength or if isJob then 600000 else 30000
    +      delayTimeout = _.delay @checkForTimeout, timeout
    +      @setState({delayTimeout})
    +
    +  checkForTimeout: ->
    +    {isWaiting} = @props
    +    @setState(isTimedout: true, delayTimeout: null) if isWaiting and @isMounted()
    +
    +  clearDelay: ->
    +    {delayTimeout} = @state
    +    clearTimeout(delayTimeout)
    +
    +  getDefaultProps: ->
    +    isDone: false
    +    isFailed: false
    +    waitingText: 'Loading…'
    +    failedState: RefreshButton
    +    failedProps:
    +      beforeText: 'There was a problem.  '
    +    doneText: ''
    +    isJob: false
    +
    +  render: ->
    +    {className, disabled} = @props
    +    {isWaiting, isDone, isFailed} = @props
    +    {children, waitingText, failedProps, doneText} = @props
    +    {isTimedout} = @state
    +    # needs to be capitalized so JSX will transpile as a variable, not element
    +    FailedState = @props.failedState
    +
    +    buttonTypeClass = 'async-button'
    +
    +    if isFailed or isTimedout
    +      stateClass = 'is-failed'
    +      return 
    +    else if isWaiting
    +      stateClass = 'is-waiting'
    +      text = waitingText
    +      disabled = true
    +      spinner = 
    +    else if isDone
    +      stateClass = 'is-done'
    +      text = doneText
    +    else
    +      stateClass = null
    +      text = children
    +
    +    
    +        {spinner}
    +        {text}
    +    
    diff --git a/shared/src/components/buttons/close-button.cjsx b/shared/src/components/buttons/close-button.cjsx
    new file mode 100644
    index 0000000000..f0e7ae9990
    --- /dev/null
    +++ b/shared/src/components/buttons/close-button.cjsx
    @@ -0,0 +1,7 @@
    +React = require 'react'
    +classnames = require 'classnames'
    +
    +module.exports = React.createClass
    +  render: ->
    +    classNames = classnames 'openstax-close-x', 'close', @props.className
    +    
    diff --git a/shared/src/components/buttons/refresh-button.cjsx b/shared/src/components/buttons/refresh-button.cjsx
    new file mode 100644
    index 0000000000..7bcd0dd73c
    --- /dev/null
    +++ b/shared/src/components/buttons/refresh-button.cjsx
    @@ -0,0 +1,25 @@
    +React = require 'react'
    +
    +module.exports = React.createClass
    +  displayName: 'RefreshButton'
    +
    +  propTypes:
    +    beforeText: React.PropTypes.string
    +    buttonText: React.PropTypes.string
    +    afterText: React.PropTypes.string
    +
    +  getDefaultProps: ->
    +    beforeText: 'There was a problem loading. '
    +    buttonText: 'Refresh'
    +    afterText: ' to try again.'
    +
    +  render: ->
    +    {beforeText, buttonText, afterText} = @props
    +
    +    # Wrap text in quotes so whitespace is preserved
    +    # and button is not right next to text.
    +    
    +      {beforeText}
    +      {buttonText}
    +      {afterText}
    +    
    diff --git a/shared/src/components/chapter-section-mixin.cjsx b/shared/src/components/chapter-section-mixin.cjsx
    new file mode 100644
    index 0000000000..8f364e586d
    --- /dev/null
    +++ b/shared/src/components/chapter-section-mixin.cjsx
    @@ -0,0 +1,24 @@
    +_ = require 'underscore'
    +
    +module.exports =
    +  getDefaultProps: ->
    +    sectionSeparator: '.'
    +    skipZeros: true
    +    inputStringSeparator: '.'
    +
    +  sectionFormat: (section, separator) ->
    +    {inputStringSeparator, skipZeros, sectionSeparator} = @props
    +
    +    if _.isString(section)
    +      sectionArray = section.split(inputStringSeparator)
    +
    +    sectionArray = section if _.isArray(section)
    +    # prevent mutation
    +    sectionArray = _.clone(sectionArray)
    +    # ignore 0 in chapter sections
    +    sectionArray.pop() if skipZeros and _.last(sectionArray) is 0
    +
    +    if sectionArray instanceof Array
    +      sectionArray.join(separator or sectionSeparator)
    +    else
    +      section
    diff --git a/shared/src/components/demo.cjsx b/shared/src/components/demo.cjsx
    new file mode 100644
    index 0000000000..f3650a5417
    --- /dev/null
    +++ b/shared/src/components/demo.cjsx
    @@ -0,0 +1,351 @@
    +React = require 'react'
    +BS = require 'react-bootstrap'
    +_ = require 'lodash'
    +EventEmitter2 = require 'eventemitter2'
    +classnames = require 'classnames'
    +
    +{Exercise, ExerciseWithScroll} = require './exercise'
    +Notifications = require '../model/notifications'
    +URLs = require '../model/urls'
    +NotificationBar = require './notifications/bar'
    +SuretyGuard = require './surety-guard'
    +exerciseStub = require '../../api/exercise'
    +multipartExerciseStub = require '../../api/exercise-multipart'
    +exerciseEvents = new EventEmitter2(wildcard: true)
    +STEP_ID = exerciseStub['free-response'].id
    +MULTIPART_STEP_IDS = _.keys(multipartExerciseStub)
    +SINGLEPART_STEP_IDS = [STEP_ID]
    +
    +steps = []
    +steps[STEP_ID] = {}
    +_.forEach multipartExerciseStub, (step, stepId) ->
    +  steps[stepId] = {}
    +
    +stubForExercise = {}
    +stubForExercise[STEP_ID] = exerciseStub
    +
    +stubsForExercises = _.extend {}, multipartExerciseStub, stubForExercise
    +
    +ExercisePreview = require './exercise-preview'
    +exercisePreviewStub = require '../../api/exercise-preview/data'
    +
    +Breadcrumb = require './breadcrumb'
    +breadcrumbStub = require '../../api/breadcrumbs/steps'
    +
    +ArbitraryHtmlAndMath = require './html'
    +HTMLStub = require '../../api/html/data'
    +
    +getCurrentPanel = (stepId) ->
    +  step = steps[stepId]
    +  panel = 'free-response'
    +  if step.answer_id
    +    panel = 'review'
    +  else if step.free_response
    +    panel = 'multiple-choice'
    +  panel
    +
    +getUpdatedStep = (stepId) ->
    +  step = steps[stepId]
    +  panel = getCurrentPanel(stepId)
    +  steps[stepId] = _.merge({}, stubsForExercises[stepId][panel], step)
    +
    +getProps = (stepIds) ->
    +  localSteps = {}
    +
    +  _.forEach stepIds, (stepId) ->
    +    localSteps[stepId] = getUpdatedStep(stepId)
    +
    +  parts = _.map stepIds, (stepId) ->
    +    localSteps[stepId]
    +
    +  props =
    +    parts: parts
    +    canOnlyContinue: (stepId) ->
    +      localSteps[stepId].correct_answer_id?
    +    getCurrentPanel: getCurrentPanel
    +    setAnswerId: (stepId, answerId) ->
    +      localSteps[stepId].answer_id = answerId
    +    setFreeResponseAnswer: (stepId, freeResponse) ->
    +      localSteps[stepId].free_response = freeResponse
    +      exerciseEvents.emit('change')
    +    onContinue: ->
    +      exerciseEvents.emit('change')
    +    onStepCompleted: ->
    +      console.info('onStepCompleted')
    +    onNextStep: ->
    +      console.info('onNextStep')
    +
    +SuretyDemo = React.createClass
    +
    +  getInitialState: -> triggered: false
    +  onConfirm: -> @setState(triggered: true)
    +
    +  render: ->
    +    if @state.triggered
    +      message = 'you seem to be sure'
    +
    +    
    +

    {message}

    + + + Perform Dangerous Operation! + + +
    + +ExerciseDemo = React.createClass + displayName: 'ExerciseDemo' + getInitialState: -> + exerciseProps: getProps(SINGLEPART_STEP_IDS) + getDefaultProps: -> + goToStep: -> + console.info('goToStep', arguments) + update: -> + @setState(exerciseProps: getProps(SINGLEPART_STEP_IDS)) + componentWillMount: -> + exerciseEvents.on('change', @update) + componentWillUnmount: -> + exerciseEvents.off('change', @update) + render: -> + {exerciseProps} = @state + + +MultipartExerciseDemo = React.createClass + displayName: 'MultipartExerciseDemo' + getInitialState: -> + exerciseProps: getProps(MULTIPART_STEP_IDS) + getDefaultProps: -> + goToStep: -> + console.info('goToStep', arguments) + update: -> + @setState(exerciseProps: getProps(MULTIPART_STEP_IDS)) + componentWillMount: -> + exerciseEvents.on('change', @update) + componentWillUnmount: -> + exerciseEvents.off('change', @update) + render: -> + {exerciseProps, currentStep} = @state + {goToStep, onPartEnter, onPartLeave} = @props + + + +ExercisePreviewDemo = React.createClass + displayName: 'ExercisePreviewDemo' + getInitialState: -> + isSelected: false + toggles: + feedback: false + interactive: false + tags: false + formats: false + height: false + + onToggle: (ev) -> + toggles = @state.toggles + toggles[ev.target.name] = ev.target.checked + @setState({toggles}) + + onSelection: -> + @setState(isSelected: not @state.isSelected) + + onDetailsClick: (ev, exercise) -> + console.warn "Exercise details was clicked" + + render: -> + + + + + + + + + + + + + + +BreadcrumbDemo = React.createClass + displayName: 'BreadcrumbDemo' + getInitialState: -> + currentStep: 0 + + goToStep: (stepIndex) -> + console.info("goToStep #{stepIndex}") + @setState(currentStep: stepIndex) + + render: -> + {currentStep} = @state + + crumbs = _.map(breadcrumbStub.steps, (crumbStep, index) -> + crumb = + key: index + data: crumbStep + crumb: true + type: 'step' + ) + + crumbs.push(type: 'end', key: crumbs.length + 1, data: {}) + + breadcrumbsNoReview = _.map(crumbs, (crumb) => + + ) + + breadcrumbsReview = _.map(crumbs, (crumb, key) => + if crumb.type is 'step' and crumb.data.is_completed + crumb.data.correct_answer_id = "3" + + + ) + +
    +
    +

    Reading, no review

    +
    + {breadcrumbsNoReview} +
    +
    +
    +

    Homework, no review

    +
    + {breadcrumbsNoReview} +
    +
    +
    +

    Reading, with review

    +
    + {breadcrumbsReview} +
    +
    +
    +

    Homework, with review

    +
    + {breadcrumbsReview} +
    +
    +
    + +HTMLDemo = React.createClass + displayName: 'HTMLDemo' + render: -> + + + +NoticesDemo = React.createClass + getInitialState: -> {running: false} + + startPoll: -> + # These will be loaded from the app's bootsrap data in normal use + URLs.update( + base_accounts_url: 'http://localhost:2999' + tutor_notices_url: 'http://localhost:3001/api/notifications' + accounts_profile_url: 'http://localhost:2999/profile' + ) + Notifications.startPolling() + + showMessage: -> + Notifications.display( + message: @refs.message.getDOMNode().value, + level: @refs.type.getDOMNode().value + ) + + render: -> +
    + +
    + Test Message: + + + +
    + +
    + +Demo = React.createClass + displayName: 'Demo' + render: -> + demos = + exercisePreview: + notices: + multipartExercise: + exercise: + surety: + breadcrumbs: + html: + + demos = _.map(demos, (demo, name) -> + + +

    {"#{name}"}

    +
    {demo}
    +
    +
    + ) + + {demos} + + +module.exports = Demo diff --git a/shared/src/components/exercise-badges/index.cjsx b/shared/src/components/exercise-badges/index.cjsx new file mode 100644 index 0000000000..7e89a386fe --- /dev/null +++ b/shared/src/components/exercise-badges/index.cjsx @@ -0,0 +1,50 @@ +React = require 'react' +_ = require 'underscore' +Exercise = require '../../model/exercise' +Interactive = require './interactive-icon' +MultiPart = require './multipart-icon' +classnames = require 'classnames' + +ExerciseBadges = React.createClass + + propTypes: + isMultipart: React.PropTypes.bool + hasInteractive: React.PropTypes.bool + hasVideo: React.PropTypes.bool + exercise: React.PropTypes.object + + getDefaultProps: -> + isMultipart: false + hasInteractive: false + hasVideo: false + exercise: {} + + render: -> + classes = classnames 'openstax-exercise-badges', @props.className + + badges = [] + if @props.isMultipart or Exercise.isMultipart(@props.exercise) + badges.push + Multi-part question + + + if @props.hasInteractive or Exercise.hasInteractive(@props.exercise) + badges.push + Interactive + + + if @props.hasVideo or Exercise.hasVideo(@props.exercise) + badges.push + Video + + + if badges.length +
    + {badges} +
    + else + null + + + +module.exports = ExerciseBadges diff --git a/shared/src/components/exercise-badges/interactive-icon.cjsx b/shared/src/components/exercise-badges/interactive-icon.cjsx new file mode 100644 index 0000000000..01e34154c7 --- /dev/null +++ b/shared/src/components/exercise-badges/interactive-icon.cjsx @@ -0,0 +1,28 @@ +# coffeelint: disable=max_line_length + +React = require 'react' + +# Basically just an icon, +# create as plain class without this binding and never updates +class InteractiveIcon extends React.Component + + shouldComponentUpdate: -> + false + + render: -> + + + + + + + + + +module.exports = InteractiveIcon diff --git a/shared/src/components/exercise-badges/multipart-icon.cjsx b/shared/src/components/exercise-badges/multipart-icon.cjsx new file mode 100644 index 0000000000..acbb9a44de --- /dev/null +++ b/shared/src/components/exercise-badges/multipart-icon.cjsx @@ -0,0 +1,25 @@ +# coffeelint: disable=max_line_length + +React = require 'react' + +# Basically just an icon, +# create as plain class without this binding and never updates +class MultipartIcon extends React.Component + + shouldComponentUpdate: -> + false + + render: -> + + + + + + + + + +module.exports = MultipartIcon diff --git a/shared/src/components/exercise-identifier-link.cjsx b/shared/src/components/exercise-identifier-link.cjsx new file mode 100644 index 0000000000..86e6951a33 --- /dev/null +++ b/shared/src/components/exercise-identifier-link.cjsx @@ -0,0 +1,22 @@ +_ = require 'underscore' +React = require 'react' +Exercise = require '../model/exercise' +ChapterSectionMixin = require './chapter-section-mixin' + +ExerciseIdentifierLink = React.createClass + + mixins: [ChapterSectionMixin] + + propTypes: + bookUUID: React.PropTypes.string + exerciseId: React.PropTypes.string.isRequired + project: React.PropTypes.oneOf(['concept-coach', 'tutor']) + + render: -> + url = Exercise.troubleUrl(@props) +
    + + ID# {@props.exerciseId} | Report an error + +
    +module.exports = ExerciseIdentifierLink diff --git a/shared/src/components/exercise-preview/controls-overlay.cjsx b/shared/src/components/exercise-preview/controls-overlay.cjsx new file mode 100644 index 0000000000..0ac24d361c --- /dev/null +++ b/shared/src/components/exercise-preview/controls-overlay.cjsx @@ -0,0 +1,38 @@ +React = require 'react' +_ = require 'underscore' +classnames = require 'classnames' + +ControlsOverlay = React.createClass + + propTypes: + onClick: React.PropTypes.func + actions: React.PropTypes.object + exercise: React.PropTypes.object.isRequired + + onClick: (ev) -> + @props.onClick(ev, @props.exercise) + + onActionClick: (ev, handler) -> + ev.stopPropagation() if @props.onClick # needed to prevent click from triggering onOverlay handler + handler(ev, @props.exercise) + + render: -> + return null if _.isEmpty(@props.actions) + +
    +
    + {for type, action of @props.actions +
    + {action.message} +
    } +
    +
    + + +module.exports = ControlsOverlay diff --git a/shared/src/components/exercise-preview/index.cjsx b/shared/src/components/exercise-preview/index.cjsx new file mode 100644 index 0000000000..6eceda57ea --- /dev/null +++ b/shared/src/components/exercise-preview/index.cjsx @@ -0,0 +1,123 @@ +React = require 'react' +_ = require 'underscore' +classnames = require 'classnames' +BS = require 'react-bootstrap' + +ArbitraryHtmlAndMath = require '../html' +ExerciseIdentifierLink = require '../exercise-identifier-link' +Question = require '../question' +ExerciseBadges = require '../exercise-badges' +ControlsOverlay = require './controls-overlay' +Exercise = require '../../model/exercise' + +ExercisePreview = React.createClass + + propTypes: + extractTag: React.PropTypes.func + displayFeedback: React.PropTypes.bool + displayAllTags: React.PropTypes.bool + displayFormats: React.PropTypes.bool + panelStyle: React.PropTypes.string + className: React.PropTypes.string + header: React.PropTypes.element + hideAnswers: React.PropTypes.bool + onOverlayClick: React.PropTypes.func + isSelected: React.PropTypes.bool + isInteractive: React.PropTypes.bool + overlayActions: React.PropTypes.object + exercise: React.PropTypes.shape( + content: React.PropTypes.object + tags: React.PropTypes.array + ).isRequired + isVerticallyTruncated: React.PropTypes.bool + + getDefaultProps: -> + panelStyle: 'default' + isInteractive: true + overlayActions: {} + extractTag: (tag) -> + content = _.compact([tag.name, tag.description]).join(' ') or tag.id + isLO = _.include(['lo', 'aplo'], tag.type) + {content, isLO} + + renderTag: (tag) -> + {content, isLO} = @props.extractTag(tag) + classes = if isLO + content = "LO: #{content}" if isLO + 'lo-tag' + else + 'exercise-tag' + {content} + + + renderFooter: -> +
    + {@props.children} +
    + + renderStimulus: -> + if @props.isInteractive or not @props.exercise.preview + + else + + + render: -> + content = @props.exercise.content + + tags = _.clone @props.exercise.tags + unless @props.displayAllTags + tags = _.where tags, is_visible: true + renderedTags = _.map(_.sortBy(tags, 'name'), @renderTag) + classes = classnames( 'openstax-exercise-preview', @props.className, { + 'answers-hidden': @props.hideAnswers + 'has-actions': not _.isEmpty(@props.overlayActions) + 'is-selected': @props.isSelected + 'actions-on-side': @props.actionsOnSide + 'non-interactive': @props.isInteractive is false + 'is-vertically-truncated': @props.isVerticallyTruncated + 'is-displaying-formats': @props.displayFormats + 'is-displaying-feedback': @props.displayFeedback + }) + + questions = _.map(content.questions, (question, questionIter) => + question = _.omit(question, 'answers') if @props.hideAnswers + + + {@props.questionFooters?[questionIter]} + + ) + + + {
    if @props.isSelected} + + + + + + { unless _.isEmpty(@props.exercise.context)} + + {@renderStimulus()} + + {questions} +
    Exercise ID: {@props.exercise.content.uid}
    +
    {renderedTags}
    + + +module.exports = ExercisePreview diff --git a/shared/src/components/exercise/controls.cjsx b/shared/src/components/exercise/controls.cjsx new file mode 100644 index 0000000000..b3d0bdb4bd --- /dev/null +++ b/shared/src/components/exercise/controls.cjsx @@ -0,0 +1,142 @@ +React = require 'react/addons' +BS = require 'react-bootstrap' +_ = require 'underscore' + +AsyncButton = require '../buttons/async-button' +{propTypes, props} = require './props' + +ExContinueButton = React.createClass + displayName: 'ExContinueButton' + propTypes: propTypes.ExContinueButton + getDefaultProps: -> + isContinueFailed: false + waitingText: null + isContinueEnabled: true + + render: -> + {isContinueEnabled, isContinueFailed, waitingText, onContinue, children} = @props + buttonText = children or 'Continue' + + + {buttonText} + + + +ExReviewControls = React.createClass + displayName: 'ExReviewControls' + propTypes: propTypes.ExReviewControls + getDefaultProps: -> + review: '' + canTryAnother: false + isRecovering: false + canRefreshMemory: false + + render: -> + {review, canTryAnother, tryAnother, isRecovering, children} = @props + {canRefreshMemory, refreshMemory} = @props + {isContinueFailed, waitingText, onContinue, isContinueEnabled} = @props + + continueButtonText = if canTryAnother then 'Move On' else children + + if canTryAnother + tryAnotherButton = + Try Another + + + if canRefreshMemory + refreshMemoryButton = + Refresh My Memory + + + continueButton = + + {continueButtonText} + unless review is 'completed' + +
    + {tryAnotherButton} + {continueButton} +
    + +CONTROLS = + 'free-response': ExContinueButton + 'multiple-choice': ExContinueButton + 'review': ExReviewControls + 'teacher-read-only': ExContinueButton + +CONTROLS_TEXT = + 'free-response': 'Answer' + 'multiple-choice': 'Submit' + 'review': 'Next Question' + 'teacher-read-only': 'Next Question' + +ExControlButtons = React.createClass + displayName: 'ExerciseControlButtons' + getDefaultProps: -> + disabled: false + isContinueEnabled: false + allowKeyNext: false + shouldComponentUpdate: (nextProps) -> + nextProps.panel? + render: -> + {panel, controlButtons, controlText} = @props + + ControlButtons = CONTROLS[panel] + controlText ?= CONTROLS_TEXT[panel] + + controlProps = _.pick(@props, props.ExReviewControls) + controlProps.children = controlText + + + + +ExerciseDefaultFooter = React.createClass + displayName: 'ExerciseDefaultFooter' + render: -> +
    {@props.controlButtons}
    + +ExFooter = React.createClass + displayName: 'ExFooter' + getDefaultProps: -> + disabled: false + isContinueEnabled: false + allowKeyNext: false + footer: + + render: -> + {footer, idLink} = @props + + footerProps = _.pick(@props, props.StepFooter) + footerProps.controlButtons ?= + +
    + {React.addons.cloneWithProps(footer, footerProps)} + {idLink} +
    + + +module.exports = {ExContinueButton, ExReviewControls, ExControlButtons, ExFooter} diff --git a/shared/src/components/exercise/free-response.cjsx b/shared/src/components/exercise/free-response.cjsx new file mode 100644 index 0000000000..4117bc99ff --- /dev/null +++ b/shared/src/components/exercise/free-response.cjsx @@ -0,0 +1,26 @@ +React = require 'react' + +FreeResponse = React.createClass + displayName: 'FreeResponse' + propTypes: + free_response: React.PropTypes.string.isRequired + + getDefaultProps: -> + free_response: '' + + render: -> + {free_response, student_names} = @props + FreeResponse = null + + freeResponseProps = + className: 'free-response' + freeResponseProps['data-student-names'] = student_names.join(', ') if student_names? + + if free_response? and free_response.length + FreeResponse =
    + {free_response} +
    + + FreeResponse + +module.exports = FreeResponse diff --git a/shared/src/components/exercise/group.cjsx b/shared/src/components/exercise/group.cjsx new file mode 100644 index 0000000000..7da608d5fc --- /dev/null +++ b/shared/src/components/exercise/group.cjsx @@ -0,0 +1,88 @@ +React = require 'react' +_ = require 'underscore' +BS = require 'react-bootstrap' +camelCase = require 'camelcase' + +ChapterSectionMixin = require '../chapter-section-mixin' +ExerciseIdentifierLink = require '../exercise-identifier-link' + +DEFAULT_GROUP = + show: false +REVIEW_GROUP = + show: true + label: 'Spaced Practice' + tooltip: + 'concept-coach': + '''Did you know? Research shows you can strengthen your memory — + and spend less time studying — if you revisit material over multiple study sessions. + OpenStax Concept Coach will include review questions from prior sections to give your + learning a boost.''' + 'tutor': + '''Did you know? Research shows you can strengthen your memory — + and spend less time studying — if you revisit material over multiple study sessions. + OpenStax Tutor will include review questions from prior sections to give your + learning a boost.''' + +RULES = + default: DEFAULT_GROUP + core: DEFAULT_GROUP + recovery: DEFAULT_GROUP + personalized: + show: true + label: 'Personalized' + # TODO deprecate spaced practice when BE is updated + 'spaced practice': REVIEW_GROUP + spaced_practice: REVIEW_GROUP + +ExerciseGroup = React.createClass + displayName: 'ExerciseGroup' + + propTypes: + group: React.PropTypes.oneOf(_.keys(RULES)).isRequired + project: React.PropTypes.oneOf(['tutor', 'concept-coach']).isRequired + + getDefaultProps: -> + group: 'default' + related_content: [] + + getPossibleGroups: -> + _.keys(RULES) + + getGroupLabel: (group, related_content) -> + + if RULES[group].label? + labels = RULES[group].label + + labels + + render: -> + {group, related_content, exercise_uid, project} = @props + groupDOM = [] + + if RULES[group].show + className = group.replace(' ', '_') + labels = @getGroupLabel(group, related_content) + isSpacedPractice = className is 'spaced_practice' + icon = + + spacedPracticeHeading =

    What is spaced practice?

    + + groupDOM = [ + icon + {labels} + ] + + if RULES[group].show and RULES[group].tooltip + popover = + {spacedPracticeHeading if isSpacedPractice} + {RULES[group].tooltip[project]} + + groupDOM.push + + + +
    + {groupDOM} +
    + +module.exports = ExerciseGroup diff --git a/shared/src/components/exercise/index.cjsx b/shared/src/components/exercise/index.cjsx new file mode 100644 index 0000000000..c7df0beb0a --- /dev/null +++ b/shared/src/components/exercise/index.cjsx @@ -0,0 +1,176 @@ +React = require 'react' +_ = require 'underscore' +classnames = require 'classnames' + +ExercisePart = require './part' +{ExFooter} = require './controls' +{CardBody} = require '../pinned-header-footer-card/sections' +ExerciseGroup = require './group' +ExerciseBadges = require '../exercise-badges' +ExerciseIdentifierLink = require '../exercise-identifier-link' +ScrollToMixin = require '../scroll-to-mixin' + +ExerciseMixin = + + propTypes: + parts: React.PropTypes.array.isRequired + canOnlyContinue: React.PropTypes.func.isRequired + currentStep: React.PropTypes.number.isRequired + + isSinglePart: -> + {parts} = @props + parts.length is 1 + + canAllContinue: -> + {parts, canOnlyContinue} = @props + + _.every parts, (part) -> + canOnlyContinue(part.id) + + shouldControl: (id) -> + not @props.canOnlyContinue(id) + + renderPart: (part, partProps) -> + props = _.omit(@props, 'parts', 'canOnlyContinue', 'footer', 'goToStep', 'controlButtons') + + + + renderSinglePart: -> + {parts, footer, canOnlyContinue, controlButtons} = @props + + part = _.first(parts) + + partProps = + idLink: @renderIdLink() + focus: true + includeGroup: true + footer: footer + controlButtons: controlButtons + + @renderPart(part, partProps) + + renderMultiParts: -> + {parts, currentStep} = @props + + _.map parts, (part, index) => + # disable keyStep if this is not the current step + keySet = null if part.stepIndex isnt currentStep + + partProps = + pinned: false + focus: part.stepIndex is currentStep + includeGroup: false + includeFooter: @shouldControl(part.id) + keySet: keySet + stepPartIndex: part.stepIndex + key: "exercise-part-#{index}" + + # stim and stem are the same for different steps currently. + # they should only show up once. + unless index is 0 + part.content = _.omit(part.content, 'stimulus_html', 'stem_html') + + @renderPart(part, partProps) + + renderGroup: -> + {parts} = @props + step = _.last(parts) + + + + renderFooter: -> + {parts, onNextStep, currentStep, pinned} = @props + step = _.last(parts) + + if @canAllContinue() + canContinueControlProps = + isContinueEnabled: true + onContinue: _.partial onNextStep, currentStep: step.stepIndex + + footerProps = _.omit(@props, 'onContinue') + footerProps.idLink = @renderIdLink(false) unless pinned + + + + renderIdLink: (related = true) -> + {parts} = @props + step = _.last(parts) + + related_content = step.related_content if related + + if step.content?.uid + + + +ExerciseWithScroll = React.createClass + displayName: 'ExerciseWithScroll' + mixins: [ExerciseMixin, ScrollToMixin] + + componentDidMount: -> + {currentStep} = @props + @scrollToStep(currentStep) if currentStep? + + componentWillReceiveProps: (nextProps) -> + @scrollToStep(nextProps.currentStep) if nextProps.currentStep isnt @props.currentStep + + scrollToStep: (currentStep) -> + stepSelector = "[data-step='#{currentStep}']" + @scrollToSelector(stepSelector, {updateHistory: false, unlessInView: true}) + + render: -> + {parts, footer, pinned} = @props + classes = classnames('openstax-multipart-exercise-card', { + "deleted-homework": @props.task?.type is 'homework' and @props.task?.is_deleted + }) + + if @isSinglePart() + @renderSinglePart() + else + footer ?= @renderFooter() if pinned + + + {@renderGroup()} + {@renderMultiParts()} + {@renderIdLink(false) if pinned} + + + +Exercise = React.createClass + displayName: 'Exercise' + mixins: [ExerciseMixin] + render: -> + {footer, pinned} = @props + classes = classnames('openstax-multipart-exercise-card', { + "deleted-homework": @props.task?.type is 'homework' and @props.task?.is_deleted + }) + + if @isSinglePart() + + { @renderSinglePart() } + + else + + + {@renderGroup()} + {@renderMultiParts()} + {@renderIdLink(false) if pinned} + + +module.exports = {Exercise, ExerciseWithScroll} diff --git a/shared/src/components/exercise/mode.cjsx b/shared/src/components/exercise/mode.cjsx new file mode 100644 index 0000000000..35104badd4 --- /dev/null +++ b/shared/src/components/exercise/mode.cjsx @@ -0,0 +1,126 @@ +React = require 'react' +_ = require 'underscore' + +ArbitraryHtmlAndMath = require '../html' +Question = require '../question' +FreeResponse = require './free-response' + +RESPONSE_CHAR_LIMIT = 10000 + +{propTypes, props} = require './props' +modeType = propTypes.ExerciseStepCard.panel +modeProps = _.extend {}, propTypes.ExFreeResponse, propTypes.ExMulitpleChoice, propTypes.ExReview, mode: modeType + +ExMode = React.createClass + displayName: 'ExMode' + propTypes: modeProps + getDefaultProps: -> + disabled: false + free_response: '' + answer_id: '' + getInitialState: -> + {free_response, answer_id} = @props + + freeResponse: free_response + answerId: answer_id + + componentDidMount: -> + {mode} = @props + @setFreeResponseFocusState() if mode is 'free-response' + + componentDidUpdate: (prevProps) -> + {mode, focus} = prevProps + @setFreeResponseFocusState() if mode is 'free-response' and focus isnt @props.focus + + componentWillReceiveProps: (nextProps) -> + {free_response, answer_id, cachedFreeResponse} = nextProps + + nextAnswers = {} + freeResponse = free_response or cachedFreeResponse or '' + + nextAnswers.freeResponse = freeResponse if @state.freeResponse isnt freeResponse + nextAnswers.answerId = answer_id if @state.answerId isnt answer_id + + @setState(nextAnswers) unless _.isEmpty(nextAnswers) + + setFreeResponseFocusState: -> + {focus} = @props + if focus + @refs.freeResponse?.getDOMNode?().focus?() + else + @refs.freeResponse?.getDOMNode?().blur?() + + onFreeResponseChange: -> + freeResponse = @refs.freeResponse?.getDOMNode()?.value + if freeResponse.length <= RESPONSE_CHAR_LIMIT + @setState({freeResponse}) + @props.onFreeResponseChange?(freeResponse) + + onAnswerChanged: (answer) -> + return if answer.id is @state.answerId or @props.mode isnt 'multiple-choice' + @setState {answerId: answer.id} + @props.onAnswerChanged?(answer) + + getFreeResponse: -> + {mode, free_response, disabled} = @props + {freeResponse} = @state + + + if mode is 'free-response' +