diff --git a/.bowerrc b/.bowerrc new file mode 100644 index 0000000..bac96ad --- /dev/null +++ b/.bowerrc @@ -0,0 +1,4 @@ +{ + "directory": "vendor/assets/bower_components", + "analytics": false +} diff --git a/.deploy.conf.sample b/.deploy.conf.sample new file mode 100644 index 0000000..f7f33ec --- /dev/null +++ b/.deploy.conf.sample @@ -0,0 +1,2 @@ +branch=develop +app_env=sandbox diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..9c90984 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,21 @@ +# http://eslint.org/docs/user-guide/configuring +# http://eslint.org/docs/2.0.0/rules/ +env: + browser: true + jquery: true +extends: 'eslint:recommended' +plugins: + - ignore-erb +rules: + indent: + - error + - 2 + linebreak-style: + - error + - unix + no-trailing-spaces: + - warn + quotes: off + semi: + - error + - always diff --git a/.gitignore b/.gitignore index 9efdd7a..df8a1a8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ /tmp/* !/log/.keep !/tmp/.keep +tmux*log ### Ruby ### *.gem @@ -36,6 +37,13 @@ /.bundle/ /vendor/bundle /lib/bundler/man/ +/vendor/assets/* +bower_components/ +node_modules/ +.DS_Store + +## Public directory assets +public/assets ### Vim ### [._]*.s[a-w][a-z] @@ -50,3 +58,8 @@ application.yml # Ignore Byebug command history file. .byebug_history + +# Ignore application configuration +/config/application.yml +.container-setup +.deploy.conf diff --git a/.rubocop.yml b/.rubocop.yml index 65f43eb..ddbc53a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,6 +3,10 @@ AllCops: - db/schema.rb - db/seeds.rb - bin/**/* + - vendor/assets/**/* + +Style/AndOr: + Enabled: false Style/ClassAndModuleChildren: Exclude: @@ -21,6 +25,8 @@ Style/ExtraSpacing: Style/IndentationConsistency: EnforcedStyle: rails + Exclude: + - config/routes.rb Style/MethodDefParentheses: Enabled: false @@ -32,9 +38,14 @@ Style/SpaceBeforeFirstArg: Style/StringLiterals: Enabled: false +Style/StructInheritance: + Exclude: + - app/policies/**/* + Metrics/AbcSize: Exclude: - db/migrate/**/* + Max: 20 Metrics/LineLength: Max: 110 @@ -47,3 +58,6 @@ Metrics/LineLength: Metrics/MethodLength: Exclude: - db/migrate/* + +Rails: + Enabled: true diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..2bf1c1c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.3.1 diff --git a/.tmux.conf b/.tmux.conf deleted file mode 100644 index 2b3df9b..0000000 --- a/.tmux.conf +++ /dev/null @@ -1,5 +0,0 @@ -set -g mode-mouse on -set -g mouse-resize-pane on -set -g mouse-select-pane on -set -g mouse-select-window on -set -g history-limit 30000 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 213f238..0000000 --- a/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -FROM mysql:5.7 -MAINTAINER Mark Moser - -WORKDIR ~/ - -ENV MYSQL_ROOT_PASSWORD=root -ENV BUILD_PACKAGES="build-essential libmysqlclient-dev openssl graphviz nodejs curl wget zlib1g-dev tmux" - -RUN apt-get update \ - && apt-get install --fix-missing -y $BUILD_PACKAGES \ - && /usr/sbin/mysqld --initialize --user=mysql \ - && service mysql start \ - && echo 'gem: --no-document' >> ~/.gemrc \ - && cp ~/.gemrc /etc/gemrc \ - && chmod uog+r /etc/gemrc \ - && mkdir -p /usr/app \ - && ln -s /usr/bin/nodejs /usr/bin/node \ - && echo "alias ll='ls -Ahl'" >> /root/.bashrc \ - && echo "alias la='ls -ahl'" >> /root/.bashrc \ - && echo "export HISTCONTROL=ignoredups" >> /root/.bashrc - -# install current Ruby -RUN curl -L --progress https://github.com/postmodern/ruby-install/archive/v0.6.0.tar.gz | tar xz \ - && cd ruby-install-0.6.0 \ - && make install \ - && cd ../ \ - && ruby-install --system ruby \ - && gem install bundler - -COPY .tmux.conf /root/ -COPY Gemfile* /root/ -RUN cd /root \ - && bundle install \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - -EXPOSE 3000 3306 35729 -WORKDIR /usr/app -CMD ["mysqld"] - -# ./start-docker.sh -# ./start-server.sh -# rails server -b 0.0.0.0 diff --git a/Gemfile b/Gemfile index 2549807..bd5d9ab 100644 --- a/Gemfile +++ b/Gemfile @@ -1,41 +1,62 @@ +# frozen_string_literal: true source 'https://rubygems.org' gem 'figaro', '~> 1.1.1' gem 'bcrypt', '~> 3.1.7' gem 'mysql2', '>= 0.3.18', '< 0.5' -gem 'rails', '~> 5.0.0' +gem 'rails', '~> 5.0', '>= 5.0.0.1' -# gem 'autoprefixer-rails' gem 'jbuilder', '~> 2.6' gem 'jquery-rails' gem 'json', '~> 2.0.2' +gem 'mailjet', '~> 1.3.8' gem 'puma', '~> 3.0' +gem 'pundit' gem 'sass-rails', '~> 5.0' +gem 'settingslogic', '~> 2.0.9' gem 'turbolinks', '~> 5' gem 'uglifier', '>= 1.3.0' +# assets +gem 'bourbon' +gem 'neat' + +# Foundation for Emails +gem 'inky-rb', require: 'inky' +gem 'premailer-rails' + group :development do + gem 'better_errors' gem 'rack-livereload' gem 'rails-erd' gem 'web-console' end group :development, :test do - gem 'awesome_print' - gem 'binding_of_caller' - gem 'byebug', platform: :mri + gem 'spring' + gem 'spring-watcher-listen', '~> 2.0.0' + gem 'listen' + gem 'guard' + gem 'guard-brakeman' gem 'guard-livereload' gem 'guard-minitest' gem 'guard-rubocop' gem 'guard-shell' - gem 'listen', '~> 3.0' + gem 'minitest-reporters' + gem 'policy-assertions' + gem 'rails-controller-testing' + + gem 'awesome_print' + gem 'binding_of_caller' + gem 'byebug', platform: :mri gem 'pry-byebug' gem 'pry-rails' + + gem 'brakeman' gem 'rubocop', '~> 0.42.0' - gem 'spring' - gem 'spring-watcher-listen', '~> 2.0.0' + gem 'simplecov', require: false end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem diff --git a/Gemfile.lock b/Gemfile.lock index 404f65b..589a2b6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,56 +1,70 @@ GEM remote: https://rubygems.org/ specs: - actioncable (5.0.0) - actionpack (= 5.0.0) + actioncable (5.0.0.1) + actionpack (= 5.0.0.1) nio4r (~> 1.2) websocket-driver (~> 0.6.1) - actionmailer (5.0.0) - actionpack (= 5.0.0) - actionview (= 5.0.0) - activejob (= 5.0.0) + actionmailer (5.0.0.1) + actionpack (= 5.0.0.1) + actionview (= 5.0.0.1) + activejob (= 5.0.0.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.0.0) - actionview (= 5.0.0) - activesupport (= 5.0.0) + actionpack (5.0.0.1) + actionview (= 5.0.0.1) + activesupport (= 5.0.0.1) rack (~> 2.0) rack-test (~> 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.0.0) - activesupport (= 5.0.0) + actionview (5.0.0.1) + activesupport (= 5.0.0.1) builder (~> 3.1) erubis (~> 2.7.0) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - activejob (5.0.0) - activesupport (= 5.0.0) + activejob (5.0.0.1) + activesupport (= 5.0.0.1) globalid (>= 0.3.6) - activemodel (5.0.0) - activesupport (= 5.0.0) - activerecord (5.0.0) - activemodel (= 5.0.0) - activesupport (= 5.0.0) + activemodel (5.0.0.1) + activesupport (= 5.0.0.1) + activerecord (5.0.0.1) + activemodel (= 5.0.0.1) + activesupport (= 5.0.0.1) arel (~> 7.0) - activesupport (5.0.0) + activesupport (5.0.0.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (~> 0.7) minitest (~> 5.1) tzinfo (~> 1.1) + addressable (2.4.0) ansi (1.5.0) - arel (7.1.0) + arel (7.1.1) ast (2.3.0) awesome_print (1.7.0) bcrypt (3.1.11) + better_errors (2.1.1) + coderay (>= 1.0.0) + erubis (>= 2.6.6) + rack (>= 0.9.0) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) + bourbon (4.2.7) + sass (~> 3.4) + thor (~> 0.19) + brakeman (3.4.0) builder (3.2.2) byebug (9.0.5) choice (0.2.0) coderay (1.1.1) concurrent-ruby (1.0.2) + css_parser (1.4.5) + addressable debug_inspector (0.0.2) + docile (1.1.5) + domain_name (0.5.20160615) + unf (>= 0.0.5, < 1.0.0) em-websocket (0.5.1) eventmachine (>= 0.12.9) http_parser.rb (~> 0.6.0) @@ -61,7 +75,8 @@ GEM figaro (1.1.1) thor (~> 0.14) formatador (0.2.5) - globalid (0.3.6) + foundation_emails (2.2.0.0) + globalid (0.3.7) activesupport (>= 4.1.0) guard (2.14.0) formatador (>= 0.2.4) @@ -72,13 +87,16 @@ GEM pry (>= 0.9.12) shellany (~> 0.0) thor (>= 0.18.1) + guard-brakeman (0.8.3) + brakeman (>= 2.1.1) + guard (>= 2.0.0) guard-compat (1.2.1) guard-livereload (2.5.2) em-websocket (~> 0.5) guard (~> 2.8) guard-compat (~> 1.0) multi_json (~> 1.8) - guard-minitest (2.4.5) + guard-minitest (2.4.6) guard-compat (~> 1.2) minitest (>= 3.0) guard-rubocop (1.2.0) @@ -87,12 +105,17 @@ GEM guard-shell (0.7.1) guard (>= 2.0.0) guard-compat (~> 1.0) + htmlentities (4.3.4) + http-cookie (1.0.2) + domain_name (~> 0.5) http_parser.rb (0.6.0) i18n (0.7.0) + inky-rb (1.3.6.1) + foundation_emails (~> 2) jbuilder (2.6.0) activesupport (>= 3.0.0, < 5.1) multi_json (~> 1.2) - jquery-rails (4.1.1) + jquery-rails (4.2.1) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) @@ -106,31 +129,48 @@ GEM lumberjack (1.0.10) mail (2.6.4) mime-types (>= 1.16, < 4) + mailjet (1.3.8) + activesupport (>= 3.1.0) + rack (>= 1.4.0) + rest-client method_source (0.8.2) mime-types (3.1) mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) mini_portile2 (2.1.0) minitest (5.9.0) - minitest-reporters (1.1.10) + minitest-reporters (1.1.11) ansi builder minitest (>= 5.0) ruby-progressbar multi_json (1.12.1) mysql2 (0.4.4) + neat (1.8.0) + sass (>= 3.3) + thor (~> 0.19) nenv (0.3.0) + netrc (0.11.0) nio4r (1.2.1) nokogiri (1.6.8) mini_portile2 (~> 2.1.0) pkg-config (~> 1.1.7) - notiffany (0.1.0) + notiffany (0.1.1) nenv (~> 0.1) shellany (~> 0.0) parser (2.3.1.2) ast (~> 2.2) pkg-config (1.1.7) + policy-assertions (0.0.3) + activesupport (>= 3.0.0) + pundit (>= 1.0.0) powerpack (0.1.1) + premailer (1.8.7) + css_parser (>= 1.4.5) + htmlentities (>= 4.0.0) + premailer-rails (1.9.4) + actionmailer (>= 3, < 6) + premailer (~> 1.7, >= 1.7.9) pry (0.10.4) coderay (~> 1.1.0) method_source (~> 0.8.1) @@ -141,36 +181,42 @@ GEM pry-rails (0.3.4) pry (>= 0.9.10) puma (3.6.0) + pundit (1.1.0) + activesupport (>= 3.0.0) rack (2.0.1) rack-livereload (0.3.16) rack rack-test (0.6.3) rack (>= 1.0) - rails (5.0.0) - actioncable (= 5.0.0) - actionmailer (= 5.0.0) - actionpack (= 5.0.0) - actionview (= 5.0.0) - activejob (= 5.0.0) - activemodel (= 5.0.0) - activerecord (= 5.0.0) - activesupport (= 5.0.0) + rails (5.0.0.1) + actioncable (= 5.0.0.1) + actionmailer (= 5.0.0.1) + actionpack (= 5.0.0.1) + actionview (= 5.0.0.1) + activejob (= 5.0.0.1) + activemodel (= 5.0.0.1) + activerecord (= 5.0.0.1) + activesupport (= 5.0.0.1) bundler (>= 1.3.0, < 2.0) - railties (= 5.0.0) + railties (= 5.0.0.1) sprockets-rails (>= 2.0.0) + rails-controller-testing (1.0.1) + actionpack (~> 5.x) + actionview (~> 5.x) + activesupport (~> 5.x) rails-dom-testing (2.0.1) activesupport (>= 4.2.0, < 6.0) nokogiri (~> 1.6.0) - rails-erd (1.4.7) + rails-erd (1.5.0) activerecord (>= 3.2) activesupport (>= 3.2) choice (~> 0.2.0) ruby-graphviz (~> 1.2) rails-html-sanitizer (1.0.3) loofah (~> 2.0) - railties (5.0.0) - actionpack (= 5.0.0) - activesupport (= 5.0.0) + railties (5.0.0.1) + actionpack (= 5.0.0.1) + activesupport (= 5.0.0.1) method_source rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) @@ -179,6 +225,10 @@ GEM rb-fsevent (0.9.7) rb-inotify (0.9.7) ffi (>= 0.5.0) + rest-client (2.0.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) rubocop (0.42.0) parser (>= 2.3.1.1, < 3.0) powerpack (~> 0.1) @@ -187,7 +237,7 @@ GEM unicode-display_width (~> 1.0, >= 1.0.1) ruby-graphviz (1.2.2) ruby-progressbar (1.8.1) - ruby_dep (1.3.1) + ruby_dep (1.4.0) sass (3.4.22) sass-rails (5.0.6) railties (>= 4.0.0, < 6) @@ -195,7 +245,13 @@ GEM sprockets (>= 2.8, < 4.0) sprockets-rails (>= 2.0, < 4.0) tilt (>= 1.1, < 3) + settingslogic (2.0.9) shellany (0.0.1) + simplecov (0.12.0) + docile (~> 1.1.0) + json (>= 1.8, < 3) + simplecov-html (~> 0.10.0) + simplecov-html (0.10.0) slop (3.6.0) spring (1.7.2) spring-watcher-listen (2.0.0) @@ -211,14 +267,17 @@ GEM thor (0.19.1) thread_safe (0.3.5) tilt (2.0.5) - turbolinks (5.0.0) + turbolinks (5.0.1) turbolinks-source (~> 5) turbolinks-source (5.0.0) tzinfo (1.2.2) thread_safe (~> 0.1) - uglifier (3.0.0) + uglifier (3.0.2) execjs (>= 0.3.0, < 3) - unicode-display_width (1.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.2) + unicode-display_width (1.1.1) web-console (3.3.1) actionview (>= 5.0) activemodel (>= 5.0) @@ -234,28 +293,41 @@ PLATFORMS DEPENDENCIES awesome_print bcrypt (~> 3.1.7) + better_errors binding_of_caller + bourbon + brakeman byebug figaro (~> 1.1.1) guard + guard-brakeman guard-livereload guard-minitest guard-rubocop guard-shell + inky-rb jbuilder (~> 2.6) jquery-rails json (~> 2.0.2) - listen (~> 3.0) + listen + mailjet (~> 1.3.8) minitest-reporters mysql2 (>= 0.3.18, < 0.5) + neat + policy-assertions + premailer-rails pry-byebug pry-rails puma (~> 3.0) + pundit rack-livereload - rails (~> 5.0.0) + rails (~> 5.0, >= 5.0.0.1) + rails-controller-testing rails-erd rubocop (~> 0.42.0) sass-rails (~> 5.0) + settingslogic (~> 2.0.9) + simplecov spring spring-watcher-listen (~> 2.0.0) turbolinks (~> 5) diff --git a/Guardfile b/Guardfile index 738d098..7a847c9 100644 --- a/Guardfile +++ b/Guardfile @@ -1,3 +1,4 @@ +# frozen_string_literal: true # A sample Guardfile # More info at https://github.com/guard/guard#readme @@ -15,29 +16,82 @@ # # and, you'll have to watch "config/Guardfile" instead of "Guardfile" -guard :minitest, spring: true, all_after_pass: true do - watch(%r{^test/test_helper\.rb$}) { 'test' } - watch(%r{^test/(.*)\/?(.*)_test\.rb$}) - watch(%r{^app/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}#{m[2]}_test.rb" } - watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/lib/#{m[1]}#{m[2]}_test.rb" } +guard 'livereload' do + extensions = { + css: :css, + scss: :css, + sass: :css, + js: :js, + coffee: :js, + html: :html, + png: :png, + gif: :gif, + jpg: :jpg, + jpeg: :jpeg, + # less: :less, # uncomment if you want LESS stylesheets done in browser + } + + rails_view_exts = %w(erb haml slim) + + # file types LiveReload may optimize refresh for + compiled_exts = extensions.values.uniq + watch(%r{public/.+\.(#{compiled_exts * '|'})}) + + extensions.each do |ext, type| + watch(%r{ + (?:app|vendor) + (?:/assets/\w+/(?[^.]+) # path+base without extension + (?\.#{ext})) # matching extension (must be first encountered) + (?:\.\w+|$) # other extensions + }x) do |m| + path = m[1] + "/assets/#{path}.#{type}" + end + end + + # file needing a full reload of the page anyway + watch(%r{app/views/.+\.(#{rails_view_exts * '|'})$}) + watch(%r{app/(helpers|controllers)/.+\.rb}) + watch(%r{config/locales/.+\.yml}) end -guard 'livereload' do - watch(%r{app/assets/.+\.(scss|css|js)}) - watch(%r{app/views/.+\.(erb|haml|slim)$}) - watch(%r{app/controllers/.+\.rb}) - watch(%r{app/helpers/.+\.rb}) - watch(%r{public/.+\.(css|js|html)}) - watch(%r{config/locales/.+\.yml}) +guard :minitest, spring: "bin/rails test", all_after_pass: true do + watch(%r{^app/(.+)\.rb$}) { |m| ["test/#{m[1]}", "test/#{m[1]}_test.rb"] } + watch(%r{^app/controllers/(admin|application)_controller\.rb$}) { 'test/controllers' } + watch(%r{^app/controllers/(.+)_controller\.rb$}) { |m| "test/integration/#{m[1]}_test.rb" } + watch(%r{^app/views/(.+)_mailer/.+}) { |m| "test/mailers/#{m[1]}_mailer_test.rb" } + watch(%r{^lib/(.+)\.rb$}) { |m| "test/lib/#{m[1]}_test.rb" } + watch(%r{^test/test_helper\.rb$}) { 'test' } + watch(%r{^test/.+_test\.rb$}) + # run controller/integration test when touching the router or erb files + watch(%r{^app/views/((?!_mailer).)*([^/]+)\.erb$}) { ["test/controllers", "test/integration"] } + watch(%r{^config/routes.rb}) { ["test/controllers", "test/integration"] } + # run mailers/integration test when touching mailer erb files + watch(%r{^app/views/(.*_mailer/)?([^/]+)\.erb$}) { ["test/mailers", "test/integration"] } +end - # Rails Assets Pipeline - watch(%r{(app|vendor)(/assets/\w+/(.+\.(scss|css|js|html|png|jpg))).*}) do |m| - "/assets/#{m[3]}" +# ESLint +guard :shell, all_on_start: true do + # TODO: Annoyingly, all files are linted twice on start/full runs. Why? + watch %r{app/assets/javascripts/*/.*} do |file| + system %(echo "ESLint:\033[32m #{file[0]}\033[0m") + system %(eslint #{file[0]}) end end -guard :rubocop do +guard :rubocop, cli: %w(-D -S) do + watch(/.rubocop.yml/) watch(/.+\.rb$/) watch(/Rakefile/) watch(%r{(?:.+/)?\.rubocop\.yml$}) { |m| File.dirname(m[0]) } end + +guard 'brakeman', run_on_start: true, quiet: true do + ## Lets not watch files for brakeman, + ## just scan on guard start, and full runs. + # + # watch(%r{^app/.+\.(erb|haml|rhtml|rb)$}) + # watch(%r{^config/.+\.rb$}) + # watch(%r{^lib/.+\.rb$}) + # watch('Gemfile') +end diff --git a/README.md b/README.md index 99dad57..76b03dc 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,85 @@ This application manages quizzes intended to be used as pre-interview skill assessments. +## Development Guidelines + +Please follow these guidelines as close as possible. Discuss and question them as needed. + +* Run Guard while developing. + * `bundle exec guard` + * Always execute a full run before a push (simply hit return in your guard session). +* Honor RuboCop warnings. +* Write tests, And keep them green. + * Review the coverage report, keeping coverate > 90%. + * coverage/index.html + * It is OK to delete tests which are duplicated, or no longer relevant. + * If you find a bug, write a test to recreate it, so it never comes back unnoticed. +* Make sure to keep the fixture files current. + * `test/fixtures/*.yml` + * Include any new development data needs. +* Protect Gemfile. + * Does your new gem bring in a ton of unnecessary or outdated gems? + * Are you bringing in a gem that solves a problem already addressed by an existing gem? + * Do you really need this gem, or just a simple service object? + * Place your new gem appropriately. Some gem blocks are order specific, but most are alphabetized. +* Keep git comment subjects short, and use git comment bodies for more details. +* Pull with --rebase. + * `git config --global pull.rebase true` +* Feature branches are nice. + * Rebase feature branches onto develop before merging. + * `push -f` is ok on feature branches. Just communicate with others using that branch. + * Never merge develop _down_ to a feature branch. + * Merge with `--no-ff` when appropriate, `--squash` "WIP" commits into a complete thought. + * Clean up your remote branches after merge. +* Keep code comments to a minimum. + * We use git. Write a good commit message instead. + * Remove/Update code comments when changes are made. +* Leave the code better than you found it. +* Have fun. + +## Setup +* clone +* checkout develop +* bundle +* npm install +* bower install +* cp config/application.yml.sample config/application.yml +* edit and update proper values to application.yml +* rake db:setup +* rake db:migrate +* rake db:fixtures:load + * `./rebuild-dev-db.sh` is a convenience script to rebuild and refresh your dev db with the fixture data. + +## Docker + +You can develop in this app with a native rails install, if you prefer. +However, there is also a docker image setup if you do not want to install all the supporting gems and libraries. +Please note: The guard watch session does not run all watches correctly in the docker container. The only issue this causes is all the automation is not 100%. Simply manually kick off the runners occasionally by hitting return in the session. + +To use the docker container, you need to install and launch docker, which can be found here: +https://www.docker.com/products/docker + +Once the container is started, you can still edit files from your host project directory. + +visit http://localhost:3000 like normal + +There are some convenience scripts included to make starting the container and rails app easier. + +#### `./start-docker.sh` +* Execute from terminal, on the host machine, in the project directory +- This will pull the image from dockerhub, if needed +- Create and start up container + +#### Once in the container: +* `./start-dev.sh` + * will spin up a dev session with tmux +* `./start-server.sh` + - starts up just rails server for viewing application -# dev -* you can dev native, or in docker -* use guard - * honor RuboCop - * keep tests green -* pull --rebase !always `git config --global pull.rebase true` -* cd vendor/assets/ && bitters install && cd ../.. -* place all required dev seed data in fixtures for rails db:fixtures:load - -# Docker - -* ./start-docker.sh - - will build source image, it not exist - - created contaier and starts -* ./start-dev.sh - - after connected to container, run this to spin up a dev session - - edit files from host in favorite editor -* ./start-server.sh - - starts up simple server for viewing/demo sans guard - -visit http://localhost:3000 +## TODOs and notes +* Question attachment path: http://dev.perficientxd.com/skill_assets/ +* clean code + * [Confident Ruby](http://www.confidentruby.com/) + * [POODR](http://www.poodr.com/) diff --git a/Rakefile b/Rakefile index e85f913..84f2bc3 100644 --- a/Rakefile +++ b/Rakefile @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Add your own tasks in files placed in lib/tasks ending in .rake, # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. diff --git a/app/assets/fonts/HalisR-Black.otf b/app/assets/fonts/HalisR-Black.otf new file mode 100644 index 0000000..e0ffc6b Binary files /dev/null and b/app/assets/fonts/HalisR-Black.otf differ diff --git a/app/assets/fonts/HalisR-Black.woff b/app/assets/fonts/HalisR-Black.woff new file mode 100644 index 0000000..09ca91d Binary files /dev/null and b/app/assets/fonts/HalisR-Black.woff differ diff --git a/app/assets/fonts/HalisR-Black.woff2 b/app/assets/fonts/HalisR-Black.woff2 new file mode 100644 index 0000000..3e8fdb5 Binary files /dev/null and b/app/assets/fonts/HalisR-Black.woff2 differ diff --git a/app/assets/fonts/HalisR-Bold.otf b/app/assets/fonts/HalisR-Bold.otf new file mode 100644 index 0000000..1ec9690 Binary files /dev/null and b/app/assets/fonts/HalisR-Bold.otf differ diff --git a/app/assets/fonts/HalisR-Bold.woff b/app/assets/fonts/HalisR-Bold.woff new file mode 100644 index 0000000..d3b23f9 Binary files /dev/null and b/app/assets/fonts/HalisR-Bold.woff differ diff --git a/app/assets/fonts/HalisR-Bold.woff2 b/app/assets/fonts/HalisR-Bold.woff2 new file mode 100644 index 0000000..8b0ec36 Binary files /dev/null and b/app/assets/fonts/HalisR-Bold.woff2 differ diff --git a/app/assets/fonts/HalisR-Book.otf b/app/assets/fonts/HalisR-Book.otf new file mode 100644 index 0000000..ee5dfd2 Binary files /dev/null and b/app/assets/fonts/HalisR-Book.otf differ diff --git a/app/assets/fonts/HalisR-Book.woff b/app/assets/fonts/HalisR-Book.woff new file mode 100644 index 0000000..c73c8e7 Binary files /dev/null and b/app/assets/fonts/HalisR-Book.woff differ diff --git a/app/assets/fonts/HalisR-Book.woff2 b/app/assets/fonts/HalisR-Book.woff2 new file mode 100644 index 0000000..127d0f3 Binary files /dev/null and b/app/assets/fonts/HalisR-Book.woff2 differ diff --git a/app/assets/fonts/HalisR-ExtraLight.otf b/app/assets/fonts/HalisR-ExtraLight.otf new file mode 100644 index 0000000..ec54a42 Binary files /dev/null and b/app/assets/fonts/HalisR-ExtraLight.otf differ diff --git a/app/assets/fonts/HalisR-ExtraLight.woff b/app/assets/fonts/HalisR-ExtraLight.woff new file mode 100644 index 0000000..5b679aa Binary files /dev/null and b/app/assets/fonts/HalisR-ExtraLight.woff differ diff --git a/app/assets/fonts/HalisR-ExtraLight.woff2 b/app/assets/fonts/HalisR-ExtraLight.woff2 new file mode 100644 index 0000000..ba09afc Binary files /dev/null and b/app/assets/fonts/HalisR-ExtraLight.woff2 differ diff --git a/app/assets/fonts/HalisR-Light.otf b/app/assets/fonts/HalisR-Light.otf new file mode 100644 index 0000000..1f7ef11 Binary files /dev/null and b/app/assets/fonts/HalisR-Light.otf differ diff --git a/app/assets/fonts/HalisR-Light.woff b/app/assets/fonts/HalisR-Light.woff new file mode 100644 index 0000000..9979f36 Binary files /dev/null and b/app/assets/fonts/HalisR-Light.woff differ diff --git a/app/assets/fonts/HalisR-Light.woff2 b/app/assets/fonts/HalisR-Light.woff2 new file mode 100644 index 0000000..26607e3 Binary files /dev/null and b/app/assets/fonts/HalisR-Light.woff2 differ diff --git a/app/assets/fonts/HalisR-Medium.otf b/app/assets/fonts/HalisR-Medium.otf new file mode 100644 index 0000000..f1577d1 Binary files /dev/null and b/app/assets/fonts/HalisR-Medium.otf differ diff --git a/app/assets/fonts/HalisR-Medium.woff b/app/assets/fonts/HalisR-Medium.woff new file mode 100644 index 0000000..5fe12d1 Binary files /dev/null and b/app/assets/fonts/HalisR-Medium.woff differ diff --git a/app/assets/fonts/HalisR-Medium.woff2 b/app/assets/fonts/HalisR-Medium.woff2 new file mode 100644 index 0000000..9e0bdf6 Binary files /dev/null and b/app/assets/fonts/HalisR-Medium.woff2 differ diff --git a/app/assets/fonts/HalisR-Regular.otf b/app/assets/fonts/HalisR-Regular.otf new file mode 100644 index 0000000..9593f6e Binary files /dev/null and b/app/assets/fonts/HalisR-Regular.otf differ diff --git a/app/assets/fonts/HalisR-Regular.woff b/app/assets/fonts/HalisR-Regular.woff new file mode 100644 index 0000000..24b6ce0 Binary files /dev/null and b/app/assets/fonts/HalisR-Regular.woff differ diff --git a/app/assets/fonts/HalisR-Regular.woff2 b/app/assets/fonts/HalisR-Regular.woff2 new file mode 100644 index 0000000..ea11217 Binary files /dev/null and b/app/assets/fonts/HalisR-Regular.woff2 differ diff --git a/app/assets/fonts/HalisR-Thin.otf b/app/assets/fonts/HalisR-Thin.otf new file mode 100644 index 0000000..89471b0 Binary files /dev/null and b/app/assets/fonts/HalisR-Thin.otf differ diff --git a/app/assets/fonts/HalisR-Thin.woff b/app/assets/fonts/HalisR-Thin.woff new file mode 100644 index 0000000..53452b3 Binary files /dev/null and b/app/assets/fonts/HalisR-Thin.woff differ diff --git a/app/assets/fonts/HalisR-Thin.woff2 b/app/assets/fonts/HalisR-Thin.woff2 new file mode 100644 index 0000000..a83f334 Binary files /dev/null and b/app/assets/fonts/HalisR-Thin.woff2 differ diff --git a/app/assets/fonts/Lato-Black.ttf b/app/assets/fonts/Lato-Black.ttf new file mode 100644 index 0000000..6848db0 Binary files /dev/null and b/app/assets/fonts/Lato-Black.ttf differ diff --git a/app/assets/fonts/Lato-Black.woff b/app/assets/fonts/Lato-Black.woff new file mode 100644 index 0000000..ed879e2 Binary files /dev/null and b/app/assets/fonts/Lato-Black.woff differ diff --git a/app/assets/fonts/Lato-Black.woff2 b/app/assets/fonts/Lato-Black.woff2 new file mode 100644 index 0000000..47ee6f1 Binary files /dev/null and b/app/assets/fonts/Lato-Black.woff2 differ diff --git a/app/assets/fonts/Lato-BlackItalic.ttf b/app/assets/fonts/Lato-BlackItalic.ttf new file mode 100644 index 0000000..5decf12 Binary files /dev/null and b/app/assets/fonts/Lato-BlackItalic.ttf differ diff --git a/app/assets/fonts/Lato-BlackItalic.woff b/app/assets/fonts/Lato-BlackItalic.woff new file mode 100644 index 0000000..027da16 Binary files /dev/null and b/app/assets/fonts/Lato-BlackItalic.woff differ diff --git a/app/assets/fonts/Lato-BlackItalic.woff2 b/app/assets/fonts/Lato-BlackItalic.woff2 new file mode 100644 index 0000000..1f80c0d Binary files /dev/null and b/app/assets/fonts/Lato-BlackItalic.woff2 differ diff --git a/app/assets/fonts/Lato-Bold.ttf b/app/assets/fonts/Lato-Bold.ttf new file mode 100644 index 0000000..7434369 Binary files /dev/null and b/app/assets/fonts/Lato-Bold.ttf differ diff --git a/app/assets/fonts/Lato-Bold.woff b/app/assets/fonts/Lato-Bold.woff new file mode 100644 index 0000000..488b826 Binary files /dev/null and b/app/assets/fonts/Lato-Bold.woff differ diff --git a/app/assets/fonts/Lato-Bold.woff2 b/app/assets/fonts/Lato-Bold.woff2 new file mode 100644 index 0000000..3f1ecfc Binary files /dev/null and b/app/assets/fonts/Lato-Bold.woff2 differ diff --git a/app/assets/fonts/Lato-BoldItalic.ttf b/app/assets/fonts/Lato-BoldItalic.ttf new file mode 100644 index 0000000..684aacf Binary files /dev/null and b/app/assets/fonts/Lato-BoldItalic.ttf differ diff --git a/app/assets/fonts/Lato-BoldItalic.woff b/app/assets/fonts/Lato-BoldItalic.woff new file mode 100644 index 0000000..44cc98c Binary files /dev/null and b/app/assets/fonts/Lato-BoldItalic.woff differ diff --git a/app/assets/fonts/Lato-BoldItalic.woff2 b/app/assets/fonts/Lato-BoldItalic.woff2 new file mode 100644 index 0000000..9754736 Binary files /dev/null and b/app/assets/fonts/Lato-BoldItalic.woff2 differ diff --git a/app/assets/fonts/Lato-Hairline.ttf b/app/assets/fonts/Lato-Hairline.ttf new file mode 100644 index 0000000..288be29 Binary files /dev/null and b/app/assets/fonts/Lato-Hairline.ttf differ diff --git a/app/assets/fonts/Lato-Hairline.woff b/app/assets/fonts/Lato-Hairline.woff new file mode 100644 index 0000000..78d22de Binary files /dev/null and b/app/assets/fonts/Lato-Hairline.woff differ diff --git a/app/assets/fonts/Lato-Hairline.woff2 b/app/assets/fonts/Lato-Hairline.woff2 new file mode 100644 index 0000000..d45c6cb Binary files /dev/null and b/app/assets/fonts/Lato-Hairline.woff2 differ diff --git a/app/assets/fonts/Lato-HairlineItalic.ttf b/app/assets/fonts/Lato-HairlineItalic.ttf new file mode 100644 index 0000000..c2bfd33 Binary files /dev/null and b/app/assets/fonts/Lato-HairlineItalic.ttf differ diff --git a/app/assets/fonts/Lato-HairlineItalic.woff b/app/assets/fonts/Lato-HairlineItalic.woff new file mode 100644 index 0000000..3bc24e9 Binary files /dev/null and b/app/assets/fonts/Lato-HairlineItalic.woff differ diff --git a/app/assets/fonts/Lato-HairlineItalic.woff2 b/app/assets/fonts/Lato-HairlineItalic.woff2 new file mode 100644 index 0000000..87d1ac1 Binary files /dev/null and b/app/assets/fonts/Lato-HairlineItalic.woff2 differ diff --git a/app/assets/fonts/Lato-Italic.ttf b/app/assets/fonts/Lato-Italic.ttf new file mode 100644 index 0000000..3d3b7a2 Binary files /dev/null and b/app/assets/fonts/Lato-Italic.ttf differ diff --git a/app/assets/fonts/Lato-Italic.woff b/app/assets/fonts/Lato-Italic.woff new file mode 100644 index 0000000..a4758e6 Binary files /dev/null and b/app/assets/fonts/Lato-Italic.woff differ diff --git a/app/assets/fonts/Lato-Italic.woff2 b/app/assets/fonts/Lato-Italic.woff2 new file mode 100644 index 0000000..7781147 Binary files /dev/null and b/app/assets/fonts/Lato-Italic.woff2 differ diff --git a/app/assets/fonts/Lato-Light.ttf b/app/assets/fonts/Lato-Light.ttf new file mode 100644 index 0000000..a958067 Binary files /dev/null and b/app/assets/fonts/Lato-Light.ttf differ diff --git a/app/assets/fonts/Lato-Light.woff b/app/assets/fonts/Lato-Light.woff new file mode 100644 index 0000000..67bd8f4 Binary files /dev/null and b/app/assets/fonts/Lato-Light.woff differ diff --git a/app/assets/fonts/Lato-Light.woff2 b/app/assets/fonts/Lato-Light.woff2 new file mode 100644 index 0000000..dca95ff Binary files /dev/null and b/app/assets/fonts/Lato-Light.woff2 differ diff --git a/app/assets/fonts/Lato-LightItalic.ttf b/app/assets/fonts/Lato-LightItalic.ttf new file mode 100644 index 0000000..5e45ad9 Binary files /dev/null and b/app/assets/fonts/Lato-LightItalic.ttf differ diff --git a/app/assets/fonts/Lato-LightItalic.woff b/app/assets/fonts/Lato-LightItalic.woff new file mode 100644 index 0000000..4ed3d25 Binary files /dev/null and b/app/assets/fonts/Lato-LightItalic.woff differ diff --git a/app/assets/fonts/Lato-LightItalic.woff2 b/app/assets/fonts/Lato-LightItalic.woff2 new file mode 100644 index 0000000..fa7bdd8 Binary files /dev/null and b/app/assets/fonts/Lato-LightItalic.woff2 differ diff --git a/app/assets/fonts/Lato-Regular.ttf b/app/assets/fonts/Lato-Regular.ttf new file mode 100644 index 0000000..04ea8ef Binary files /dev/null and b/app/assets/fonts/Lato-Regular.ttf differ diff --git a/app/assets/fonts/Lato-Regular.woff b/app/assets/fonts/Lato-Regular.woff new file mode 100644 index 0000000..a7ec9d1 Binary files /dev/null and b/app/assets/fonts/Lato-Regular.woff differ diff --git a/app/assets/fonts/Lato-Regular.woff2 b/app/assets/fonts/Lato-Regular.woff2 new file mode 100644 index 0000000..45c20d8 Binary files /dev/null and b/app/assets/fonts/Lato-Regular.woff2 differ diff --git a/app/assets/images/icon-dropdownlist.png b/app/assets/images/icon-dropdownlist.png new file mode 100644 index 0000000..759e862 Binary files /dev/null and b/app/assets/images/icon-dropdownlist.png differ diff --git a/app/assets/images/icon-unorderedlistbullet.png b/app/assets/images/icon-unorderedlistbullet.png new file mode 100644 index 0000000..7cbd331 Binary files /dev/null and b/app/assets/images/icon-unorderedlistbullet.png differ diff --git a/app/assets/images/perficientdigital-logo.jpg b/app/assets/images/perficientdigital-logo.jpg new file mode 100644 index 0000000..a11f67e Binary files /dev/null and b/app/assets/images/perficientdigital-logo.jpg differ diff --git a/app/assets/images/perficientdigital.png b/app/assets/images/perficientdigital.png new file mode 100644 index 0000000..cf0c8ac Binary files /dev/null and b/app/assets/images/perficientdigital.png differ diff --git a/app/assets/images/yellowslant-left.jpg b/app/assets/images/yellowslant-left.jpg new file mode 100644 index 0000000..df14cd0 Binary files /dev/null and b/app/assets/images/yellowslant-left.jpg differ diff --git a/app/assets/images/yellowslant-right.jpg b/app/assets/images/yellowslant-right.jpg new file mode 100644 index 0000000..de838e0 Binary files /dev/null and b/app/assets/images/yellowslant-right.jpg differ diff --git a/app/assets/javascripts/admin.js b/app/assets/javascripts/admin.js new file mode 100644 index 0000000..bd610a5 --- /dev/null +++ b/app/assets/javascripts/admin.js @@ -0,0 +1,18 @@ +$(function(){ + + $("form").on('click', "[data-id=input_option_adder]", function(){ + var $new_li = $(this).siblings('li').clone(); + $new_li.attr('style', ''); + $("[data-id=input_option_list]").append($new_li); + $new_li.find('input').focus(); + }); + + $("#question_input_type").on('change', function(){ + var qid = $(this).attr('data-qid') === undefined ? '' : "/" + $(this).attr('data-qid'); + // /admin/question(/:question_id)/options/:input_type + $("[data-id=input-options-wrapper]").load("/admin/question" + qid + "/options/" + $(this).val(), function(){ + $(".code-input textarea").linedtextarea(); + }); + }); + +}); diff --git a/app/assets/javascripts/ajax-links.js b/app/assets/javascripts/ajax-links.js new file mode 100644 index 0000000..1dd5fac --- /dev/null +++ b/app/assets/javascripts/ajax-links.js @@ -0,0 +1,16 @@ +function handleAjaxResponse($el) { + var $header = $('header'); + $el.on("ajax:success", function(e, data){ + $header.after('
' + data.message + '
'); + }).on("ajax:error", function(e, xhr) { + if (xhr.status === 400){ + $header.after('
' + xhr.responseJSON.join('
') + '
'); + } else { + $header.after('
Oops! There was an error processing your request. Please try again.
'); + } + }); +} + +$(document).ready(function() { + $('[data-id=ajax-action]').each(function(){ handleAjaxResponse($(this)); }); +}); diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index b12018d..199376b 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -13,4 +13,10 @@ //= require jquery //= require jquery_ujs //= require turbolinks -//= require_tree . +//= require modernizr-lite/modernizr + +//= require ajax-links + +//= require forms/button-group +//= require forms/animations +//= require forms/textarea-limit diff --git a/app/assets/javascripts/cable.js b/app/assets/javascripts/cable.js index 71ee1e6..24e7047 100644 --- a/app/assets/javascripts/cable.js +++ b/app/assets/javascripts/cable.js @@ -5,9 +5,9 @@ //= require_self //= require_tree ./channels -(function() { - this.App || (this.App = {}); - - App.cable = ActionCable.createConsumer(); - -}).call(this); +// (function() { +// this.App || (this.App = {}); +// +// App.cable = ActionCable.createConsumer(); +// +// }).call(this); diff --git a/app/assets/javascripts/forms/animations.js b/app/assets/javascripts/forms/animations.js new file mode 100644 index 0000000..a920c94 --- /dev/null +++ b/app/assets/javascripts/forms/animations.js @@ -0,0 +1,30 @@ +var $textInput = $('[type="color"], [type="date"], [type="datetime"], [type="datetime-local"], [type="email"], [type="month"], [type="number"], [type="password"], [type="search"], [type="tel"], [type="text"], [type="time"], [type="url"], [type="week"], input:not([type]), textarea'); + +// Text Input Label Animation +$textInput.prev('label').addClass('loaded'); +$textInput.each(function() { + if( $(this).val() ) { + $(this).prev('label').addClass('animate'); + } +}); + +$textInput.on('focus', function() { + $(this).prev('label').addClass('animate'); +}).on('focusout', function() { + if( !$(this).val() ) { + $(this).prev('label').removeClass('animate'); + } +}); + +// form error resolutions +$('form').has('.error').each(function(){ + var $form = $(this); + + $form.on('keyup', $textInput, function(){ + $form.find(".error").addClass('resolve-error'); + }); + + $form.on('change', $("[type=radio], [type=checkbox]"), function(){ + $form.find(".error").addClass('resolve-error'); + }); +}); diff --git a/app/assets/javascripts/forms/button-group.js b/app/assets/javascripts/forms/button-group.js new file mode 100644 index 0000000..bea798c --- /dev/null +++ b/app/assets/javascripts/forms/button-group.js @@ -0,0 +1,8 @@ +/** + * Button Group Functionality + */ + +$('.btn-group button').click(function() { + $(this).siblings().removeClass('selected'); + $(this).addClass('selected'); +}); diff --git a/app/assets/javascripts/forms/textarea-limit.js b/app/assets/javascripts/forms/textarea-limit.js new file mode 100644 index 0000000..b325c60 --- /dev/null +++ b/app/assets/javascripts/forms/textarea-limit.js @@ -0,0 +1,25 @@ +$.fn.extend({ + characterLimiter: function(limit, label) { + this.on("keyup focus show", function() { + setCount(this, label); + }); + + // TODO: append label container after $this, instead of hard HTML + function setCount(src, label) { + if(src !== undefined) { + var chars = src.value.length; + if (chars >= limit) { + src.value = src.value.substr(0, limit); + chars = limit; + } + label.html(limit - chars); + } + } + + setCount(this[0], label); + } +}); + +$(document).ready(function() { + $('textarea').characterLimiter(1000, $(".chars span")); +}); diff --git a/app/assets/javascripts/ie9.js b/app/assets/javascripts/ie9.js new file mode 100644 index 0000000..a351371 --- /dev/null +++ b/app/assets/javascripts/ie9.js @@ -0,0 +1 @@ +//= require html5shiv/dist/html5shiv.min diff --git a/app/assets/javascripts/live-coder.js b/app/assets/javascripts/live-coder.js new file mode 100644 index 0000000..d641253 --- /dev/null +++ b/app/assets/javascripts/live-coder.js @@ -0,0 +1,2 @@ +//= require live-coder/linedtextarea +//= require live-coder/editor diff --git a/app/assets/javascripts/live-coder/editor.js.erb b/app/assets/javascripts/live-coder/editor.js.erb new file mode 100644 index 0000000..3f9652d --- /dev/null +++ b/app/assets/javascripts/live-coder/editor.js.erb @@ -0,0 +1,117 @@ +function updateResults(elem) { + if ($(elem).length ===0){return false;} + + var resultsContainer = $(elem).find('[data-id="results"]')[0]; + var codeHtml = $(elem).find('.code-html')[0].value.trim(); + var codeCss = $(elem).find('.code-css')[0].value.trim(); + var codeJs = $(elem).find('.code-js')[0].value.trim(); + + resultsContainer.innerHTML = ""; + var iDoc = document.createElement('html'); + var iHead = document.createElement('head'); + var iBody = document.createElement('body'); + + var codeFrame = document.createElement('iframe'); + codeFrame.setAttribute("width", "100%"); + codeFrame.setAttribute("height", "100%"); + resultsContainer.appendChild(codeFrame); + + var jqueryNode = document.createElement("script"); + jqueryNode.setAttribute("type", "text/javascript"); + jqueryNode.setAttribute("src", "<%= "//#{ENV['full_app_url']}#{javascript_path "jquery"}" %>"); + iHead.appendChild(jqueryNode); + + var codeStyle = document.createElement("style"); + codeStyle.setAttribute("type", "text/css"); + var rulesNode = document.createTextNode(codeCss); + codeStyle.appendChild(rulesNode); + iHead.appendChild(codeStyle); + + iDoc.appendChild(iHead); + iBody.innerHTML = codeHtml; + iDoc.appendChild(iBody); + + var codeScript = document.createElement("script"); + codeScript.setAttribute("type", "text/javascript"); + var scriptNode = document.createTextNode("setTimeout(function(){ " + codeJs + "}, 800);"); + codeScript.appendChild(scriptNode); + iDoc.appendChild(codeScript); + + codeFrame.contentWindow.document.open(); + codeFrame.contentWindow.document.appendChild(iDoc); + codeFrame.contentWindow.document.close(); +} + +function indentSelection(e){ + if(e.keyCode === 9){ + e.preventDefault(); + + var indent = " "; + var cursor = e.target.selectionStart; + var val = e.target.value; + var valStart = val.substring(0, e.target.selectionStart); + var valEnd = val.substring(e.target.selectionEnd, val.length); + var selected = val.substring(e.target.selectionStart, e.target.selectionEnd); + + var resetCursor = function(start, end){ + e.target.selectionStart = start; + e.target.selectionEnd = end; + }; + + var indented; + if(e.shiftKey){ //de-indent + if(selected.length > 0 && (/\n/.test(selected))){ //multi line + indented = selected.split(/\n/).map(function(line){ + if(line.length > 0 && line.substring(0, indent.length) === indent){ + line = line.substring(indent.length, line.length); + } + return line; + }).join("\n"); + e.target.value = valStart + indented + val.substring(e.target.selectionEnd, val.length); + resetCursor(cursor, cursor + indented.length); + } else { + if(valStart.substring(valStart.length - indent.length) === indent){ + e.target.value = valStart.substring(0, valStart.length - indent.length) + valEnd; + resetCursor(cursor, cursor - indent.length); + } else if(valEnd.substring(0, indent.length) === indent) { + e.target.value = valStart + valEnd.substring(indent.length, valEnd.length); + resetCursor(cursor, cursor); + } + } + } else { //indent + if(selected.length > 0 && (/\n/.test(selected))){ //multi line + indented = selected.split(/\n/).map(function(line){ + if(line.length > 0){ line = indent + line; } + return line; + }).join("\n"); + e.target.value = valStart + indented + val.substring(e.target.selectionEnd, val.length); + resetCursor(cursor, cursor + indented.length); + } else { + e.target.value = valStart + indent + selected + valEnd; + resetCursor(cursor + indent.length, cursor + indent.length); + } + } + } +} + + +var timer = 0; +$(function(){ + // wait a half second before updating results + // restart the timer if they resume typing + $('html').on('keyup', '.code-input textarea', function(){ + var elem = $(this).closest("[data-id=live-coder-answer]"); + if (timer) { clearTimeout(timer); } + timer = setTimeout(updateResults(elem), 500); + }); + + $("[data-id=live-coder-answer]").each(function(){ + updateResults(this); + }); + + $("html").on('keydown', "textarea[data-id^=code-]", function(e){ + indentSelection(e); + }); + + $(".code-input textarea").linedtextarea(); +}); diff --git a/app/assets/javascripts/live-coder/linedtextarea.js b/app/assets/javascripts/live-coder/linedtextarea.js new file mode 100644 index 0000000..e1059ae --- /dev/null +++ b/app/assets/javascripts/live-coder/linedtextarea.js @@ -0,0 +1,114 @@ +/** + * NOTE: MARK MOSER EDITED COPY. DO NOT USE BOWER. + * + * jQuery Lined Textarea Plugin + * http://alan.blog-city.com/jquerylinedtextarea.htm + * + * Copyright (c) 2010 Alan Williamson + * + * Version: + * $Id: jquery-linedtextarea.js 464 2010-01-08 10:36:33Z alan $ + * + * Released under the MIT License: + * http://www.opensource.org/licenses/mit-license.php + * + * Usage: + * Displays a line number count column to the left of the textarea + * + * Class up your textarea with a given class, or target it directly + * with JQuery Selectors + * + * $(".lined").linedtextarea({ + * selectedLine: 10, + * selectedClass: 'lineselect' + * }); + * + * History: + * - 2010.01.08: Fixed a Google Chrome layout problem + * - 2010.01.07: Refactored code for speed/readability; Fixed horizontal sizing + * - 2010.01.06: Initial Release + * + */ +(function($) { + $.fn.linedtextarea = function(options) { + + // Get the Options + var opts = $.extend({}, $.fn.linedtextarea.defaults, options); + + /* + * Helper function to make sure the line numbers are always + * kept up to the current system + */ + var fillOutLines = function(codeLines, h, lineNo){ + while ( (codeLines.height() - h ) <= 0 ){ + if ( lineNo == opts.selectedLine ) + codeLines.append("
" + lineNo + "
"); + else + codeLines.append("
" + lineNo + "
"); + + lineNo++; + } + return lineNo; + }; + + /* + * Iterate through each of the elements are to be applied to + */ + return this.each(function() { + var lineNo = 1; + var textarea = $(this); + + /* Turn off the wrapping of as we don't want to screw up the line numbers */ + textarea.attr("wrap", "off"); + textarea.css({resize:'none'}); + // var originalTextAreaWidth = textarea.outerWidth(); + + /* Wrap the text area in the elements we need */ + textarea.wrap("
"); + var linedTextAreaDiv = textarea.parent().wrap("
"); + var linedWrapDiv = linedTextAreaDiv.parent(); + + linedWrapDiv.prepend("
"); + + var linesDiv = linedWrapDiv.find(".lines"); + linesDiv.height( textarea.height() + 4 ); + + + /* Draw the number bar; filling it out where necessary */ + linesDiv.append( "
" ); + var codeLinesDiv = linesDiv.find(".codelines"); + lineNo = fillOutLines( codeLinesDiv, linesDiv.height(), 1 ); + + /* Move the textarea to the selected line */ + if ( opts.selectedLine != -1 && !isNaN(opts.selectedLine) ){ + var fontSize = parseInt( textarea.height() / (lineNo-2) ); + var position = parseInt( fontSize * opts.selectedLine ) - (textarea.height()/2); + textarea[0].scrollTop = position; + } + + + /* React to the scroll event */ + textarea.scroll( function(){ + var domTextArea = $(this)[0]; + var scrollTop = domTextArea.scrollTop; + var clientHeight = domTextArea.clientHeight; + codeLinesDiv.css( {'margin-top': (-1*scrollTop) + "px"} ); + lineNo = fillOutLines( codeLinesDiv, scrollTop + clientHeight, lineNo ); + }); + + + /* Should the textarea get resized outside of our control */ + textarea.resize( function(){ + var domTextArea = $(this)[0]; + linesDiv.height( domTextArea.clientHeight + 4 ); + }); + + }); + }; + + // default options + $.fn.linedtextarea.defaults = { + selectedLine: -1, + selectedClass: 'lineselect' + }; +})(jQuery); diff --git a/app/assets/javascripts/summary-edit.js b/app/assets/javascripts/summary-edit.js new file mode 100644 index 0000000..22ea2c2 --- /dev/null +++ b/app/assets/javascripts/summary-edit.js @@ -0,0 +1,95 @@ +/* global updateResults */ +// TODO: remove global ^ once live-coder is properly name spaced +/** + * Summary Page Answer Editor + */ +function disableForm($form){ + $form.find('fieldset').prop('disabled', true); + $form.find('textarea').prop('disabled', true); + $form.find('.button-save, .button-cancel').hide(); + $form.find('.button-edit').show(); + $form.find('.editable').removeClass('editable'); + $('.button-edit, .submit-button').removeClass('disabled-button'); +} + +function restoreValues($form){ + $form.find('[type=radio][data-last], [type=checkbox][data-last]').each(function(){ + $(this).prop('checked', $(this).attr('data-last')); + }); + + $form.find('textarea[data-last]').each(function(){ + $(this).val($(this).attr('data-last')); + }); +} + +function updateLocalValues($form){ + $form.find('[type=radio][data-last], [type=checkbox][data-last]').each(function(){ + $(this).attr('data-last', $(this).prop('checked') ? 'checked' : ''); + }); + + $form.find('textarea[data-last]').each(function(){ + $(this).attr('data-last', $(this).val()); + }); +} + +function updateProgress(data) { + $(".progress-bar").attr('aria-valuenow', data.progress) + .attr('style','width: '+ data.progress +'%;') + .find('span').text(data.progress + '%'); + if(data.can_submit === true){ + $('#summary-submit').find('.error').remove(); + $('#summary-submit').find('.submit-button').prop('disabled', false); + } +} + +function prepareAjax($form) { + $form.on("ajax:success", function(e, data){ + $form.prepend('
' + data.message + '
'); + disableForm($form); + updateLocalValues($form); + updateProgress(data); + }).on("ajax:error", function(e, xhr) { + if (xhr.status === 400){ + $form.prepend('
' + xhr.responseJSON.join('
') + '
'); + } else { + $form.prepend('
Oops! There was an error processing your request. Please try again.
'); + } + }); +} + +function editClickHandler(e) { + e.preventDefault(); + $('.button-edit, .submit-button').addClass('disabled-button'); + var $form = $(e.delegateTarget).closest('form'); + $(e.delegateTarget).addClass('editable'); + $form.find('fieldset').prop('disabled', false); + $form.find('textarea').prop('disabled', false); + $form.find('textarea').focus(); + $form.find('.button-edit').hide().delay(); + $form.find('.button-save, .button-cancel').show().delay(); +} + +function cancelClickHandler(e) { + e.preventDefault(); + var $form = $(e.delegateTarget).closest('form'); + $form.find('.error, .success').remove(); + disableForm($form); + restoreValues($form); + updateResults($form.find("[data-id=live-coder-answer]")); +} + +function saveClickHandler(e) { + e.preventDefault(); + var $form = $(e.delegateTarget).closest('form'); + $form.find('.error, .success').remove(); + $form.submit(); +} + +$('.summary_tpl fieldset').prop('disabled', true); +$('.summary_tpl textarea').prop('disabled', true); +$('.summary_tpl form').each(function(){ prepareAjax($(this)); }); +$('.summary_tpl .answer-sec') + .find('.button-cancel, .button-save').hide().end() + .on('click', '.button-edit', editClickHandler) + .on('click', '.button-cancel', cancelClickHandler) + .on('click', '.button-save', saveClickHandler); diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.scss similarity index 74% rename from app/assets/stylesheets/application.css rename to app/assets/stylesheets/application.scss index 0ebd7fe..02a4988 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.scss @@ -10,6 +10,17 @@ * files in this directory. Styles in this file should be added after the last require_* statement. * It is generally better to create a new file per style scope. * - *= require_tree . - *= require_self */ + +@import 'bourbon'; +@import 'neat'; + +@import 'core/fonts'; +@import 'core/variables'; +@import 'core/animations'; + +@import 'atoms/**/*'; +@import 'molecules/**/*'; +// @import 'organisms/**/*'; +@import 'templates/**/*'; +@import 'pages/**/*'; diff --git a/app/assets/stylesheets/atoms/_alerts.scss b/app/assets/stylesheets/atoms/_alerts.scss new file mode 100644 index 0000000..a5db852 --- /dev/null +++ b/app/assets/stylesheets/atoms/_alerts.scss @@ -0,0 +1,8 @@ +.alert { + padding: 20px; + border-radius: 5px; + &.alert-success { + background-color: $accent-color-3; + color: $white; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/atoms/_layout.scss b/app/assets/stylesheets/atoms/_layout.scss new file mode 100644 index 0000000..1571202 --- /dev/null +++ b/app/assets/stylesheets/atoms/_layout.scss @@ -0,0 +1,61 @@ +html { + box-sizing: border-box; + height: 100%; +} + +body { + margin: 0 1rem; + height: 100%; + .ignore-margin + { + margin-left: -1rem; + margin-right: -1rem; + } +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +*:focus { + outline: 0; +} + +main { + display: block; + padding-top: 8vw; +} + +.layout { + min-height: calc(100% - 112px); + &:after { + content:""; + display:block; + } +} + +@media only screen and (min-width: 480px) { + .layout { + min-height: calc(100% - 116px); + } +} + +@media only screen and (min-width: $tablet) { + html { + // font-size: 2vw; + } + body { + margin: 0 2rem + } +} + +@media only screen and (min-width: $desktop) { + html { + font-size: 16px; + } + main { + padding-top: 55px; + } +} diff --git a/app/assets/stylesheets/atoms/_media.scss b/app/assets/stylesheets/atoms/_media.scss new file mode 100644 index 0000000..0c6699d --- /dev/null +++ b/app/assets/stylesheets/atoms/_media.scss @@ -0,0 +1,10 @@ +figure { + margin: 0; +} + +img, +picture { + margin: 0; + max-width: 100%; +} + diff --git a/app/assets/stylesheets/atoms/_pd-slant.scss b/app/assets/stylesheets/atoms/_pd-slant.scss new file mode 100644 index 0000000..3c62962 --- /dev/null +++ b/app/assets/stylesheets/atoms/_pd-slant.scss @@ -0,0 +1,75 @@ +// Slash Logo + +.slash-left { + position: relative; + + &:before { + content: ""; + background-color: $accent-color-1; + width:3px; + display: block; + position: absolute; + top:0; + bottom: -1rem; + left:0; + transform: skewX(-16.5deg); + transform-origin: bottom; + } +} + + + +// HTML/CSS only Slanted Border + +$prftangle: 90deg - 73.5deg; +$prftangle-negative: 73.5deg - 90deg; + +@mixin slantmix ($lmargin, $rmargin, $slantht) { + + position: relative; + display:block; + z-index: 1; + height: $slantht; + min-width: tan($prftangle) + 1 / 3; + overflow: wrap; + display:inline-block; + + &.slantright { + margin-right:tan($prftangle) + $rmargin; + &:after { + transform: skewX($prftangle-negative); + transform-origin: 0% 100%; + min-width:auto; + } + } + + &.slantleft { + margin-left:tan($prftangle) + $lmargin; + &:before { + transform: skewX($prftangle-negative); + transform-origin: 10% 0%; + min-width:auto; + } + } +} + +.slantleft:before, +.slantright:after { + background: inherit; + bottom: 0; + top:0; + content: '\00a0'; + display: block; + position: absolute; + right: 0; + left:0; + z-index: -1; +} + +.slantleft:before { + right: 50%; +} + +.slantright:after { + left: 50%; +} diff --git a/app/assets/stylesheets/atoms/_typography.scss b/app/assets/stylesheets/atoms/_typography.scss new file mode 100644 index 0000000..5b06f15 --- /dev/null +++ b/app/assets/stylesheets/atoms/_typography.scss @@ -0,0 +1,78 @@ +body { + color: $base-font-color; + font-family: $primary-font-face; + font-size: 1rem; + line-height: $base-line-height; + font-weight:300; + color: $primary-color; +} + +.hidden { + display: none !important; +} + +h1, h2, h3, +h4, h5, h6 { + font-family: $heading-font-face; + line-height: $heading-line-height; + margin: 0 0 $small-spacing; + color: $black; + font-weight: bold; +} + +h1 { font-size: modular-scale(6); } +h2 { font-size: modular-scale(5); } +h3 { font-size: modular-scale(4); } +h4 { font-size: modular-scale(3); } +h5 { font-size: modular-scale(2); } +h6 { font-size: modular-scale(1); } + +p { margin: 0 0 $small-spacing; } + +a { + color: $primary-color; + transition: color $base-duration $base-timing; + + &:active, + &:focus, + &:hover { + color: lighten($primary-color, 25%); + } +} + +hr { + border-bottom: $base-border; + border-left: 0; + border-right: 0; + border-top: 0; + margin: $base-spacing 0; +} + +// .prft-heading +h1 { + text-transform: none; +} + +.question-text { + margin-bottom: 1.5rem; +} + +// .prft-heading, +h1, +.question-text { + font-size: 6vw; + font-weight: 100; +} + +h1 > a { + font-size: 1rem; + font-weight: 300; +} + +@media screen and (min-width: $screen-sm) { + // .prft-heading, + h1, + .question-text { + font-size: 2.45rem; + } +} diff --git a/app/assets/stylesheets/core/_animations.scss b/app/assets/stylesheets/core/_animations.scss new file mode 100644 index 0000000..2796ef2 --- /dev/null +++ b/app/assets/stylesheets/core/_animations.scss @@ -0,0 +1,28 @@ +@keyframes success-fadeout { + 0% { + max-height: 40px; + opacity: 1; + } + + 85% { + margin-bottom: .5rem; + max-height: 40px; + opacity: 0; + padding: .5rem 0; + } + + 96% { + margin-bottom: 0; + max-height: 0; + opacity: 0; + padding: 0; + } + + 100% { + height: 0; + left: -10px; + position: absolute; + top: -10px; + width: 0; + } +} diff --git a/app/assets/stylesheets/core/_fonts.scss b/app/assets/stylesheets/core/_fonts.scss new file mode 100644 index 0000000..4d2b0c4 --- /dev/null +++ b/app/assets/stylesheets/core/_fonts.scss @@ -0,0 +1,163 @@ +//HALISR + +@font-face { + font-family: 'HalisR'; + src:local('Halis R Thin'), + font_url('HalisR-Thin.woff2') format('woff2'), + font_url('HalisR-Thin.woff') format('woff'), + font_url('HalisR-Thin.otf') format('opentype'); + font-weight:100; +} + +@font-face { + font-family: 'HalisR'; + src:local('Halis R Light'), + font_url('HalisR-Light.woff2') format('woff2'), + font_url('HalisR-Light.woff') format('woff'), + font_url('HalisR-Light.otf') format('opentype'); + font-weight:200; +} + +@font-face { + font-family: 'HalisR'; + src:local('Halis R Book'), + font_url('HalisR-Book.woff2') format('woff2'), + font_url('HalisR-Book.woff') format('woff'), + font_url('HalisR-Book.otf') format('opentype'); + font-weight:300; +} + +@font-face { + font-family: 'HalisR'; + src:local('Halis R Regular'), + font_url('HalisR-Regular.woff2') format('woff2'), + font_url('HalisR-Regular.woff') format('woff'), + font_url('HalisR-Regular.otf') format('opentype'); + font-weight:500; +} + +@font-face { + font-family: 'HalisR'; + src:local('Halis R Medium'), + font_url('HalisR-Medium.woff2') format('woff2'), + font_url('HalisR-Medium.woff') format('woff'), + font_url('HalisR-Medium.otf') format('opentype'); + font-weight:600; +} + +@font-face { + font-family: 'HalisR'; + src:local('Halis R Bold'), + font_url('HalisR-Bold.woff2') format('woff2'), + font_url('HalisR-Bold.woff') format('woff'), + font_url('HalisR-Bold.otf') format('opentype'); + font-weight:700; +} + +@font-face { + font-family: 'HalisR'; + src:local('Halis R Black'), + font_url('HalisR-Black.woff2') format('woff2'), + font_url('HalisR-Black.woff') format('woff'), + font_url('HalisR-Black.otf') format('opentype'); + font-weight:900; +} + +//LATO + + //regular + +@font-face { + font-family: 'Lato'; + src:local('Lato Hairline'), + font_url('Lato-Hairline.woff2') format('woff2'), + font_url('Lato-Hairline.woff') format('woff'), + font_url('Lato-Hairline.ttf') format('truetype'); + font-weight:100; +} +@font-face { + font-family: 'Lato'; + src:local('Lato Light'), + font_url('Lato-Light.woff2') format('woff2'), + font_url('Lato-Light.woff') format('woff'), + font_url('Lato-Light.ttf') format('truetype'); + font-weight:300; +} + +@font-face { + font-family: 'Lato'; + src:local('Lato Regular'), + font_url('Lato-Regular.woff2') format('woff2'), + font_url('Lato-Regular.woff') format('woff'), + font_url('Lato-Regular.ttf') format('truetype'); + font-weight:500; +} + +@font-face { + font-family: 'Lato'; + src:local('Lato Bold'), + font_url('Lato-Bold.woff2') format('woff2'), + font_url('Lato-Bold.woff') format('woff'), + font_url('Lato-Bold.ttf') format('truetype'); + font-weight:700; +} + +@font-face { + font-family: 'Lato'; + src:local('Lato Black'), + font_url('Lato-Black.woff2') format('woff2'), + font_url('Lato-Black.woff') format('woff'), + font_url('Lato-Black.ttf') format('truetype'); + font-weight:900; +} + + //italicized + +@font-face { + font-family: 'Lato'; + src:local('Lato Hairline Italic'), + font_url('Lato-HairlineItalic.woff2') format('woff2'), + font_url('Lato-HairlineItalic.woff') format('woff'), + font_url('Lato-HairlineItalic.ttf') format('truetype'); + font-weight:100; + font-style: italic; +} +@font-face { + font-family: 'Lato'; + src:local('Lato Light Italic'), + font_url('Lato-LightItalic.woff2') format('woff2'), + font_url('Lato-LightItalic.woff') format('woff'), + font_url('Lato-LightItalic.ttf') format('truetype'); + font-weight:300; + font-style: italic; +} + +@font-face { + font-family: 'Lato'; + src:local('Lato Italic'), + font_url('Lato-Italic.woff2') format('woff2'), + font_url('Lato-Italic.woff') format('woff'), + font_url('Lato-Italic.ttf') format('truetype'); + font-weight:500; + font-style: italic; +} + +@font-face { + font-family: 'Lato'; + src:local('Lato Bold Italic'), + font_url('Lato-BoldItalic.woff2') format('woff2'), + font_url('Lato-BoldItalic.woff') format('woff'), + font_url('Lato-BoldItalic.ttf') format('truetype'); + font-weight:700; + font-style: italic; +} + +@font-face { + font-family: 'Lato'; + src:local('Lato Black Italic'), + font_url('Lato-BlackItalic.woff2') format('woff2'), + font_url('Lato-BlackItalic.woff') format('woff'), + font_url('Lato-BlackItalic.ttf') format('truetype'); + font-weight:900; + font-style: italic; +} diff --git a/app/assets/stylesheets/core/_variables.scss b/app/assets/stylesheets/core/_variables.scss new file mode 100644 index 0000000..1d4e424 --- /dev/null +++ b/app/assets/stylesheets/core/_variables.scss @@ -0,0 +1,948 @@ +$bootstrap-sass-asset-helper: false !default; +// +// Variables +// -------------------------------------------------- + + +//== Colors +// +//## Gray and brand colors for use across Bootstrap. + +$gray-base: #000 !default; +$gray-darker: lighten($gray-base, 13.5%) !default; // #222 +$gray-dark: lighten($gray-base, 20%) !default; // #333 +$gray: lighten($gray-base, 33.5%) !default; // #555 +$gray-light: lighten($gray-base, 46.7%) !default; // #777 +$gray-lighter: lighten($gray-base, 93.5%) !default; // #eee + +$brand-primary: darken(#428bca, 6.5%) !default; // #337ab7 +$brand-success: #5cb85c !default; +$brand-info: #5bc0de !default; +$brand-warning: #f0ad4e !default; +$brand-danger: #d9534f !default; + + +//== Scaffolding +// +//## Settings for some of the most global styles. + +//** Background color for ``. +$body-bg: #fff !default; +//** Global text color on ``. +$text-color: $gray-dark !default; + +//** Global textual link color. +$link-color: $brand-primary !default; +//** Link hover color set via `darken()` function. +$link-hover-color: darken($link-color, 15%) !default; +//** Link hover decoration. +$link-hover-decoration: underline !default; + + +//== Typography +// +//## Font, line-height, and color for body text, headings, and more. + +$font-family-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif !default; +$font-family-serif: Georgia, "Times New Roman", Times, serif !default; +//** Default monospace fonts for ``, ``, and `
`.
+$font-family-monospace:   Menlo, Monaco, Consolas, "Courier New", monospace !default;
+$font-family-base:        $font-family-sans-serif !default;
+
+$font-size-base:          14px !default;
+$font-size-large:         ceil(($font-size-base * 1.25)) !default; // ~18px
+$font-size-small:         ceil(($font-size-base * 0.85)) !default; // ~12px
+
+$font-size-h1:            floor(($font-size-base * 2.6)) !default; // ~36px
+$font-size-h2:            floor(($font-size-base * 2.15)) !default; // ~30px
+$font-size-h3:            ceil(($font-size-base * 1.7)) !default; // ~24px
+$font-size-h4:            ceil(($font-size-base * 1.25)) !default; // ~18px
+$font-size-h5:            $font-size-base !default;
+$font-size-h6:            ceil(($font-size-base * 0.85)) !default; // ~12px
+
+//** Unit-less `line-height` for use in components like buttons.
+$line-height-base:        1.428571429 !default; // 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)) !default; // ~20px
+
+//** By default, this inherits from the ``.
+$headings-font-family:    inherit !default;
+$headings-font-weight:    500 !default;
+$headings-line-height:    1.1 !default;
+$headings-color:          inherit !default;
+
+
+//== Iconography
+//
+//## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
+
+//** Load fonts from this directory.
+
+// [converter] If $bootstrap-sass-asset-helper if used, provide path relative to the assets load path.
+// [converter] This is because some asset helpers, such as Sprockets, do not work with file-relative paths.
+$icon-font-path: if($bootstrap-sass-asset-helper, "bootstrap/", "../fonts/bootstrap/") !default;
+
+//** File name for all font files.
+$icon-font-name:          "glyphicons-halflings-regular" !default;
+//** Element ID within SVG icon file.
+$icon-font-svg-id:        "glyphicons_halflingsregular" !default;
+
+
+//== 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 !default;
+$padding-base-horizontal:   12px !default;
+
+$padding-large-vertical:    10px !default;
+$padding-large-horizontal:  16px !default;
+
+$padding-small-vertical:    5px !default;
+$padding-small-horizontal:  10px !default;
+
+$padding-xs-vertical:       1px !default;
+$padding-xs-horizontal:     5px !default;
+
+$line-height-large:         1.3333333 !default; // extra decimals for Win 8.1 Chrome
+$line-height-small:         1.5 !default;
+
+$border-radius-base:        4px !default;
+$border-radius-large:       6px !default;
+$border-radius-small:       3px !default;
+
+//** Global color for active items (e.g., navs or dropdowns).
+$component-active-color:    #fff !default;
+//** Global background color for active items (e.g., navs or dropdowns).
+$component-active-bg:       $brand-primary !default;
+
+//** Width of the `border` for generating carets that indicator dropdowns.
+$caret-width-base:          4px !default;
+//** Carets increase slightly in size for larger components.
+$caret-width-large:         5px !default;
+
+
+//== Tables
+//
+//## Customizes the `.table` component with basic values, each used across all table variations.
+
+//** Padding for ``s and ``s.
+$table-cell-padding:            8px !default;
+//** Padding for cells in `.table-condensed`.
+$table-condensed-cell-padding:  5px !default;
+
+//** Default background color used for all tables.
+$table-bg:                      transparent !default;
+//** Background color used for `.table-striped`.
+$table-bg-accent:               #f9f9f9 !default;
+//** Background color used for `.table-hover`.
+$table-bg-hover:                #f5f5f5 !default;
+$table-bg-active:               $table-bg-hover !default;
+
+//** Border color for table and cell borders.
+$table-border-color:            #ddd !default;
+
+
+//== Buttons
+//
+//## For each of Bootstrap's buttons, define text, background and border color.
+
+$btn-font-weight:                normal !default;
+
+$btn-default-color:              #333 !default;
+$btn-default-bg:                 #fff !default;
+$btn-default-border:             #ccc !default;
+
+$btn-primary-color:              #fff !default;
+$btn-primary-bg:                 $brand-primary !default;
+$btn-primary-border:             darken($btn-primary-bg, 5%) !default;
+
+$btn-success-color:              #fff !default;
+$btn-success-bg:                 $brand-success !default;
+$btn-success-border:             darken($btn-success-bg, 5%) !default;
+
+$btn-info-color:                 #fff !default;
+$btn-info-bg:                    $brand-info !default;
+$btn-info-border:                darken($btn-info-bg, 5%) !default;
+
+$btn-warning-color:              #fff !default;
+$btn-warning-bg:                 $brand-warning !default;
+$btn-warning-border:             darken($btn-warning-bg, 5%) !default;
+
+$btn-danger-color:               #fff !default;
+$btn-danger-bg:                  $brand-danger !default;
+$btn-danger-border:              darken($btn-danger-bg, 5%) !default;
+
+$btn-link-disabled-color:        $gray-light !default;
+
+// Allows for customizing button radius independently from global border radius
+$btn-border-radius-base:         $border-radius-base !default;
+$btn-border-radius-large:        $border-radius-large !default;
+$btn-border-radius-small:        $border-radius-small !default;
+
+
+//== Forms
+//
+//##
+
+//** `` background color
+$input-bg:                       #fff !default;
+//** `` background color
+$input-bg-disabled:              $gray-lighter !default;
+
+//** Text color for ``s
+$input-color:                    $gray !default;
+//** `` border color
+$input-border:                   #ccc !default;
+
+// 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 !default;
+//** Large `.form-control` border radius
+$input-border-radius-large:      $border-radius-large !default;
+//** Small `.form-control` border radius
+$input-border-radius-small:      $border-radius-small !default;
+
+//** Border color for inputs on focus
+$input-border-focus:             #66afe9 !default;
+
+//** Placeholder text color
+$input-color-placeholder:        #999 !default;
+
+//** Default `.form-control` height
+$input-height-base:              ($line-height-computed + ($padding-base-vertical * 2) + 2) !default;
+//** Large `.form-control` height
+$input-height-large:             (ceil($font-size-large * $line-height-large) + ($padding-large-vertical * 2) + 2) !default;
+//** Small `.form-control` height
+$input-height-small:             (floor($font-size-small * $line-height-small) + ($padding-small-vertical * 2) + 2) !default;
+
+//** `.form-group` margin
+$form-group-margin-bottom:       15px !default;
+
+$legend-color:                   $gray-dark !default;
+$legend-border-color:            #e5e5e5 !default;
+
+//** Background color for textual input addons
+$input-group-addon-bg:           $gray-lighter !default;
+//** Border color for textual input addons
+$input-group-addon-border-color: $input-border !default;
+
+//** Disabled cursor for form controls and buttons.
+$cursor-disabled:                not-allowed !default;
+
+
+//== Dropdowns
+//
+//## Dropdown menu container and contents.
+
+//** Background for the dropdown menu.
+$dropdown-bg:                    #fff !default;
+//** Dropdown menu `border-color`.
+$dropdown-border:                rgba(0,0,0,.15) !default;
+//** Dropdown menu `border-color` **for IE8**.
+$dropdown-fallback-border:       #ccc !default;
+//** Divider color for between dropdown items.
+$dropdown-divider-bg:            #e5e5e5 !default;
+
+//** Dropdown link text color.
+$dropdown-link-color:            $gray-dark !default;
+//** Hover color for dropdown links.
+$dropdown-link-hover-color:      darken($gray-dark, 5%) !default;
+//** Hover background for dropdown links.
+$dropdown-link-hover-bg:         #f5f5f5 !default;
+
+//** Active dropdown menu item text color.
+$dropdown-link-active-color:     $component-active-color !default;
+//** Active dropdown menu item background color.
+$dropdown-link-active-bg:        $component-active-bg !default;
+
+//** Disabled dropdown menu item background color.
+$dropdown-link-disabled-color:   $gray-light !default;
+
+//** Text color for headers within dropdown menus.
+$dropdown-header-color:          $gray-light !default;
+
+//** Deprecated `$dropdown-caret-color` as of v3.1.0
+$dropdown-caret-color:           #000 !default;
+
+
+//-- 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 !default;
+$zindex-dropdown:          1000 !default;
+$zindex-popover:           1060 !default;
+$zindex-tooltip:           1070 !default;
+$zindex-navbar-fixed:      1030 !default;
+$zindex-modal-background:  1040 !default;
+$zindex-modal:             1050 !default;
+
+
+//== 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 !default;
+//** Deprecated `$screen-xs-min` as of v3.2.0
+$screen-xs-min:              $screen-xs !default;
+//** Deprecated `$screen-phone` as of v3.0.1
+$screen-phone:               $screen-xs-min !default;
+
+// Small screen / tablet
+//** Deprecated `$screen-sm` as of v3.0.1
+$screen-sm:                  768px !default;
+$screen-sm-min:              $screen-sm !default;
+//** Deprecated `$screen-tablet` as of v3.0.1
+$screen-tablet:              $screen-sm-min !default;
+
+// Medium screen / desktop
+//** Deprecated `$screen-md` as of v3.0.1
+$screen-md:                  992px !default;
+$screen-md-min:              $screen-md !default;
+//** Deprecated `$screen-desktop` as of v3.0.1
+$screen-desktop:             $screen-md-min !default;
+
+// Large screen / wide desktop
+//** Deprecated `$screen-lg` as of v3.0.1
+$screen-lg:                  1200px !default;
+$screen-lg-min:              $screen-lg !default;
+//** Deprecated `$screen-lg-desktop` as of v3.0.1
+$screen-lg-desktop:          $screen-lg-min !default;
+
+// So media queries don't overlap when required, provide a maximum
+$screen-xs-max:              ($screen-sm-min - 1) !default;
+$screen-sm-max:              ($screen-md-min - 1) !default;
+$screen-md-max:              ($screen-lg-min - 1) !default;
+
+
+//== Grid system
+//
+//## Define your custom responsive grid.
+
+//** Number of columns in the grid.
+$grid-columns:              12 !default;
+//** Padding between columns. Gets divided in half for the left and right.
+$grid-gutter-width:         30px !default;
+// Navbar collapse
+//** Point at which the navbar becomes uncollapsed.
+$grid-float-breakpoint:     $screen-sm-min !default;
+//** Point at which the navbar begins collapsing.
+$grid-float-breakpoint-max: ($grid-float-breakpoint - 1) !default;
+
+
+//== Container sizes
+//
+//## Define the maximum width of `.container` for different screen sizes.
+
+// Small screen / tablet
+$container-tablet:             (720px + $grid-gutter-width) !default;
+//** For `$screen-sm-min` and up.
+$container-sm:                 $container-tablet !default;
+
+// Medium screen / desktop
+$container-desktop:            (940px + $grid-gutter-width) !default;
+//** For `$screen-md-min` and up.
+$container-md:                 $container-desktop !default;
+
+// Large screen / wide desktop
+$container-large-desktop:      (1140px + $grid-gutter-width) !default;
+//** For `$screen-lg-min` and up.
+$container-lg:                 $container-large-desktop !default;
+
+
+//== Navbar
+//
+//##
+
+// Basics of a navbar
+$navbar-height:                    50px !default;
+$navbar-margin-bottom:             $line-height-computed !default;
+$navbar-border-radius:             $border-radius-base !default;
+$navbar-padding-horizontal:        floor(($grid-gutter-width / 2)) !default;
+$navbar-padding-vertical:          (($navbar-height - $line-height-computed) / 2) !default;
+$navbar-collapse-max-height:       340px !default;
+
+$navbar-default-color:             #777 !default;
+$navbar-default-bg:                #f8f8f8 !default;
+$navbar-default-border:            darken($navbar-default-bg, 6.5%) !default;
+
+// Navbar links
+$navbar-default-link-color:                #777 !default;
+$navbar-default-link-hover-color:          #333 !default;
+$navbar-default-link-hover-bg:             transparent !default;
+$navbar-default-link-active-color:         #555 !default;
+$navbar-default-link-active-bg:            darken($navbar-default-bg, 6.5%) !default;
+$navbar-default-link-disabled-color:       #ccc !default;
+$navbar-default-link-disabled-bg:          transparent !default;
+
+// Navbar brand label
+$navbar-default-brand-color:               $navbar-default-link-color !default;
+$navbar-default-brand-hover-color:         darken($navbar-default-brand-color, 10%) !default;
+$navbar-default-brand-hover-bg:            transparent !default;
+
+// Navbar toggle
+$navbar-default-toggle-hover-bg:           #ddd !default;
+$navbar-default-toggle-icon-bar-bg:        #888 !default;
+$navbar-default-toggle-border-color:       #ddd !default;
+
+
+//=== Inverted navbar
+// Reset inverted navbar basics
+$navbar-inverse-color:                      lighten($gray-light, 15%) !default;
+$navbar-inverse-bg:                         #222 !default;
+$navbar-inverse-border:                     darken($navbar-inverse-bg, 10%) !default;
+
+// Inverted navbar links
+$navbar-inverse-link-color:                 lighten($gray-light, 15%) !default;
+$navbar-inverse-link-hover-color:           #fff !default;
+$navbar-inverse-link-hover-bg:              transparent !default;
+$navbar-inverse-link-active-color:          $navbar-inverse-link-hover-color !default;
+$navbar-inverse-link-active-bg:             darken($navbar-inverse-bg, 10%) !default;
+$navbar-inverse-link-disabled-color:        #444 !default;
+$navbar-inverse-link-disabled-bg:           transparent !default;
+
+// Inverted navbar brand label
+$navbar-inverse-brand-color:                $navbar-inverse-link-color !default;
+$navbar-inverse-brand-hover-color:          #fff !default;
+$navbar-inverse-brand-hover-bg:             transparent !default;
+
+// Inverted navbar toggle
+$navbar-inverse-toggle-hover-bg:            #333 !default;
+$navbar-inverse-toggle-icon-bar-bg:         #fff !default;
+$navbar-inverse-toggle-border-color:        #333 !default;
+
+
+//== Navs
+//
+//##
+
+//=== Shared nav styles
+$nav-link-padding:                          10px 15px !default;
+$nav-link-hover-bg:                         $gray-lighter !default;
+
+$nav-disabled-link-color:                   $gray-light !default;
+$nav-disabled-link-hover-color:             $gray-light !default;
+
+//== Tabs
+$nav-tabs-border-color:                     #ddd !default;
+
+$nav-tabs-link-hover-border-color:          $gray-lighter !default;
+
+$nav-tabs-active-link-hover-bg:             $body-bg !default;
+$nav-tabs-active-link-hover-color:          $gray !default;
+$nav-tabs-active-link-hover-border-color:   #ddd !default;
+
+$nav-tabs-justified-link-border-color:            #ddd !default;
+$nav-tabs-justified-active-link-border-color:     $body-bg !default;
+
+//== Pills
+$nav-pills-border-radius:                   $border-radius-base !default;
+$nav-pills-active-link-hover-bg:            $component-active-bg !default;
+$nav-pills-active-link-hover-color:         $component-active-color !default;
+
+
+//== Pagination
+//
+//##
+
+$pagination-color:                     $link-color !default;
+$pagination-bg:                        #fff !default;
+$pagination-border:                    #ddd !default;
+
+$pagination-hover-color:               $link-hover-color !default;
+$pagination-hover-bg:                  $gray-lighter !default;
+$pagination-hover-border:              #ddd !default;
+
+$pagination-active-color:              #fff !default;
+$pagination-active-bg:                 $brand-primary !default;
+$pagination-active-border:             $brand-primary !default;
+
+$pagination-disabled-color:            $gray-light !default;
+$pagination-disabled-bg:               #fff !default;
+$pagination-disabled-border:           #ddd !default;
+
+
+//== Pager
+//
+//##
+
+$pager-bg:                             $pagination-bg !default;
+$pager-border:                         $pagination-border !default;
+$pager-border-radius:                  15px !default;
+
+$pager-hover-bg:                       $pagination-hover-bg !default;
+
+$pager-active-bg:                      $pagination-active-bg !default;
+$pager-active-color:                   $pagination-active-color !default;
+
+$pager-disabled-color:                 $pagination-disabled-color !default;
+
+
+//== Jumbotron
+//
+//##
+
+$jumbotron-padding:              30px !default;
+$jumbotron-color:                inherit !default;
+$jumbotron-bg:                   $gray-lighter !default;
+$jumbotron-heading-color:        inherit !default;
+$jumbotron-font-size:            ceil(($font-size-base * 1.5)) !default;
+$jumbotron-heading-font-size:    ceil(($font-size-base * 4.5)) !default;
+
+
+//== Form states and alerts
+//
+//## Define colors for form feedback states and, by default, alerts.
+
+$state-success-text:             #3c763d !default;
+$state-success-bg:               #dff0d8 !default;
+$state-success-border:           darken(adjust-hue($state-success-bg, -10), 5%) !default;
+
+$state-info-text:                #31708f !default;
+$state-info-bg:                  #d9edf7 !default;
+$state-info-border:              darken(adjust-hue($state-info-bg, -10), 7%) !default;
+
+$state-warning-text:             #8a6d3b !default;
+$state-warning-bg:               #fcf8e3 !default;
+$state-warning-border:           darken(adjust-hue($state-warning-bg, -10), 5%) !default;
+
+$state-danger-text:              #a94442 !default;
+$state-danger-bg:                #f2dede !default;
+$state-danger-border:            darken(adjust-hue($state-danger-bg, -10), 5%) !default;
+
+
+//== Tooltips
+//
+//##
+
+//** Tooltip max width
+$tooltip-max-width:           200px !default;
+//** Tooltip text color
+$tooltip-color:               #fff !default;
+//** Tooltip background color
+$tooltip-bg:                  #000 !default;
+$tooltip-opacity:             .9 !default;
+
+//** Tooltip arrow width
+$tooltip-arrow-width:         5px !default;
+//** Tooltip arrow color
+$tooltip-arrow-color:         $tooltip-bg !default;
+
+
+//== Popovers
+//
+//##
+
+//** Popover body background color
+$popover-bg:                          #fff !default;
+//** Popover maximum width
+$popover-max-width:                   276px !default;
+//** Popover border color
+$popover-border-color:                rgba(0,0,0,.2) !default;
+//** Popover fallback border color
+$popover-fallback-border-color:       #ccc !default;
+
+//** Popover title background color
+$popover-title-bg:                    darken($popover-bg, 3%) !default;
+
+//** Popover arrow width
+$popover-arrow-width:                 10px !default;
+//** Popover arrow color
+$popover-arrow-color:                 $popover-bg !default;
+
+//** Popover outer arrow width
+$popover-arrow-outer-width:           ($popover-arrow-width + 1) !default;
+//** Popover outer arrow color
+$popover-arrow-outer-color:           fade_in($popover-border-color, 0.05) !default;
+//** Popover outer arrow fallback color
+$popover-arrow-outer-fallback-color:  darken($popover-fallback-border-color, 20%) !default;
+
+
+//== Labels
+//
+//##
+
+//** Default label background color
+$label-default-bg:            $gray-light !default;
+//** Primary label background color
+$label-primary-bg:            $brand-primary !default;
+//** Success label background color
+$label-success-bg:            $brand-success !default;
+//** Info label background color
+$label-info-bg:               $brand-info !default;
+//** Warning label background color
+$label-warning-bg:            $brand-warning !default;
+//** Danger label background color
+$label-danger-bg:             $brand-danger !default;
+
+//** Default label text color
+$label-color:                 #fff !default;
+//** Default text color of a linked label
+$label-link-hover-color:      #fff !default;
+
+
+//== Modals
+//
+//##
+
+//** Padding applied to the modal body
+$modal-inner-padding:         15px !default;
+
+//** Padding applied to the modal title
+$modal-title-padding:         15px !default;
+//** Modal title line-height
+$modal-title-line-height:     $line-height-base !default;
+
+//** Background color of modal content area
+$modal-content-bg:                             #fff !default;
+//** Modal content border color
+$modal-content-border-color:                   rgba(0,0,0,.2) !default;
+//** Modal content border color **for IE8**
+$modal-content-fallback-border-color:          #999 !default;
+
+//** Modal backdrop background color
+$modal-backdrop-bg:           #000 !default;
+//** Modal backdrop opacity
+$modal-backdrop-opacity:      .5 !default;
+//** Modal header border color
+$modal-header-border-color:   #e5e5e5 !default;
+//** Modal footer border color
+$modal-footer-border-color:   $modal-header-border-color !default;
+
+$modal-lg:                    900px !default;
+$modal-md:                    600px !default;
+$modal-sm:                    300px !default;
+
+
+//== Alerts
+//
+//## Define alert colors, border radius, and padding.
+
+$alert-padding:               15px !default;
+$alert-border-radius:         $border-radius-base !default;
+$alert-link-font-weight:      bold !default;
+
+$alert-success-bg:            $state-success-bg !default;
+$alert-success-text:          $state-success-text !default;
+$alert-success-border:        $state-success-border !default;
+
+$alert-info-bg:               $state-info-bg !default;
+$alert-info-text:             $state-info-text !default;
+$alert-info-border:           $state-info-border !default;
+
+$alert-warning-bg:            $state-warning-bg !default;
+$alert-warning-text:          $state-warning-text !default;
+$alert-warning-border:        $state-warning-border !default;
+
+$alert-danger-bg:             $state-danger-bg !default;
+$alert-danger-text:           $state-danger-text !default;
+$alert-danger-border:         $state-danger-border !default;
+
+
+//== Progress bars
+//
+//##
+
+//** Background color of the whole progress component
+$progress-bg:                 #f5f5f5 !default;
+//** Progress bar text color
+$progress-bar-color:          #fff !default;
+//** Variable for setting rounded corners on progress bar.
+$progress-border-radius:      $border-radius-base !default;
+
+//** Default progress bar color
+$progress-bar-bg:             $brand-primary !default;
+//** Success progress bar color
+$progress-bar-success-bg:     $brand-success !default;
+//** Warning progress bar color
+$progress-bar-warning-bg:     $brand-warning !default;
+//** Danger progress bar color
+$progress-bar-danger-bg:      $brand-danger !default;
+//** Info progress bar color
+$progress-bar-info-bg:        $brand-info !default;
+
+
+//== List group
+//
+//##
+
+//** Background color on `.list-group-item`
+$list-group-bg:                 #fff !default;
+//** `.list-group-item` border color
+$list-group-border:             #ddd !default;
+//** List group border radius
+$list-group-border-radius:      $border-radius-base !default;
+
+//** Background color of single list items on hover
+$list-group-hover-bg:           #f5f5f5 !default;
+//** Text color of active list items
+$list-group-active-color:       $component-active-color !default;
+//** Background color of active list items
+$list-group-active-bg:          $component-active-bg !default;
+//** Border color of active list elements
+$list-group-active-border:      $list-group-active-bg !default;
+//** Text color for content within active list items
+$list-group-active-text-color:  lighten($list-group-active-bg, 40%) !default;
+
+//** Text color of disabled list items
+$list-group-disabled-color:      $gray-light !default;
+//** Background color of disabled list items
+$list-group-disabled-bg:         $gray-lighter !default;
+//** Text color for content within disabled list items
+$list-group-disabled-text-color: $list-group-disabled-color !default;
+
+$list-group-link-color:         #555 !default;
+$list-group-link-hover-color:   $list-group-link-color !default;
+$list-group-link-heading-color: #333 !default;
+
+
+//== Panels
+//
+//##
+
+$panel-bg:                    #fff !default;
+$panel-body-padding:          15px !default;
+$panel-heading-padding:       10px 15px !default;
+$panel-footer-padding:        $panel-heading-padding !default;
+$panel-border-radius:         $border-radius-base !default;
+
+//** Border color for elements within panels
+$panel-inner-border:          #ddd !default;
+$panel-footer-bg:             #f5f5f5 !default;
+
+$panel-default-text:          $gray-dark !default;
+$panel-default-border:        #ddd !default;
+$panel-default-heading-bg:    #f5f5f5 !default;
+
+$panel-primary-text:          #fff !default;
+$panel-primary-border:        $brand-primary !default;
+$panel-primary-heading-bg:    $brand-primary !default;
+
+$panel-success-text:          $state-success-text !default;
+$panel-success-border:        $state-success-border !default;
+$panel-success-heading-bg:    $state-success-bg !default;
+
+$panel-info-text:             $state-info-text !default;
+$panel-info-border:           $state-info-border !default;
+$panel-info-heading-bg:       $state-info-bg !default;
+
+$panel-warning-text:          $state-warning-text !default;
+$panel-warning-border:        $state-warning-border !default;
+$panel-warning-heading-bg:    $state-warning-bg !default;
+
+$panel-danger-text:           $state-danger-text !default;
+$panel-danger-border:         $state-danger-border !default;
+$panel-danger-heading-bg:     $state-danger-bg !default;
+
+
+//== Thumbnails
+//
+//##
+
+//** Padding around the thumbnail image
+$thumbnail-padding:           4px !default;
+//** Thumbnail background color
+$thumbnail-bg:                $body-bg !default;
+//** Thumbnail border color
+$thumbnail-border:            #ddd !default;
+//** Thumbnail border radius
+$thumbnail-border-radius:     $border-radius-base !default;
+
+//** Custom text color for thumbnail captions
+$thumbnail-caption-color:     $text-color !default;
+//** Padding around the thumbnail caption
+$thumbnail-caption-padding:   9px !default;
+
+
+//== Wells
+//
+//##
+
+$well-bg:                     #f5f5f5 !default;
+$well-border:                 darken($well-bg, 7%) !default;
+
+
+//== Badges
+//
+//##
+
+$badge-color:                 #fff !default;
+//** Linked badge text color on hover
+$badge-link-hover-color:      #fff !default;
+$badge-bg:                    $gray-light !default;
+
+//** Badge text color in active nav link
+$badge-active-color:          $link-color !default;
+//** Badge background color in active nav link
+$badge-active-bg:             #fff !default;
+
+$badge-font-weight:           bold !default;
+$badge-line-height:           1 !default;
+$badge-border-radius:         10px !default;
+
+
+//== Breadcrumbs
+//
+//##
+
+$breadcrumb-padding-vertical:   8px !default;
+$breadcrumb-padding-horizontal: 15px !default;
+//** Breadcrumb background color
+$breadcrumb-bg:                 #f5f5f5 !default;
+//** Breadcrumb text color
+$breadcrumb-color:              #ccc !default;
+//** Text color of current page in the breadcrumb
+$breadcrumb-active-color:       $gray-light !default;
+//** Textual separator for between breadcrumb elements
+$breadcrumb-separator:          "/" !default;
+
+
+//== Carousel
+//
+//##
+
+$carousel-text-shadow:                        0 1px 2px rgba(0,0,0,.6) !default;
+
+$carousel-control-color:                      #fff !default;
+$carousel-control-width:                      15% !default;
+$carousel-control-opacity:                    .5 !default;
+$carousel-control-font-size:                  20px !default;
+
+$carousel-indicator-active-bg:                #fff !default;
+$carousel-indicator-border-color:             #fff !default;
+
+$carousel-caption-color:                      #fff !default;
+
+
+//== Close
+//
+//##
+
+$close-font-weight:           bold !default;
+$close-color:                 #000 !default;
+$close-text-shadow:           0 1px 0 #fff !default;
+
+
+//== Code
+//
+//##
+
+$code-color:                  #c7254e !default;
+$code-bg:                     #f9f2f4 !default;
+
+$kbd-color:                   #fff !default;
+$kbd-bg:                      #333 !default;
+
+$pre-bg:                      #f5f5f5 !default;
+$pre-color:                   $gray-dark !default;
+$pre-border-color:            #ccc !default;
+$pre-scrollable-max-height:   340px !default;
+
+
+//== Type
+//
+//##
+
+//** Horizontal offset for forms and lists.
+$component-offset-horizontal: 180px !default;
+//** Text muted color
+$text-muted:                  $gray-light !default;
+//** Abbreviations and acronyms border color
+$abbr-border-color:           $gray-light !default;
+//** Headings small color
+$headings-small-color:        $gray-light !default;
+//** Blockquote small color
+$blockquote-small-color:      $gray-light !default;
+//** Blockquote font size
+$blockquote-font-size:        ($font-size-base * 1.25) !default;
+//** Blockquote border color
+$blockquote-border-color:     $gray-lighter !default;
+//** Page header border color
+$page-header-border-color:    $gray-lighter !default;
+//** Width of horizontal description list titles
+$dl-horizontal-offset:        $component-offset-horizontal !default;
+//** Point at which .dl-horizontal becomes horizontal
+$dl-horizontal-breakpoint:    $grid-float-breakpoint !default;
+//** Horizontal line color.
+$hr-border:                   $gray-lighter !default;
+
+// BITTERS VARIABLES
+
+// Breakpoints
+$medium-screen: 600px;
+$large-screen: 900px;
+
+// Typography
+$base-font-family: $helvetica;
+$heading-font-family: $base-font-family;
+
+// Font Sizes
+$base-font-size: 1em;
+
+// Line height
+$base-line-height: 1.5;
+$heading-line-height: 1.2;
+
+// Other Sizes
+$base-border-radius: 0px;
+$base-spacing: $base-line-height * 1em;
+$small-spacing: $base-spacing / 2;
+$base-z-index: 0;
+
+// Colors
+$blue: #1565c0;
+$dark-gray: #333;
+$medium-gray: #999;
+$light-gray: #ddd;
+
+// Font Colors
+$base-font-color: $dark-gray;
+$action-color: $blue;
+
+// Border
+$base-border-color: $light-gray;
+$base-border: 1px solid $base-border-color;
+
+// Background Colors
+$base-background-color: #fff;
+$secondary-background-color: tint($base-border-color, 75%);
+
+// Forms
+$form-box-shadow: inset 0 1px 3px rgba(#000, 0.06);
+$form-box-shadow-focus: $form-box-shadow, 0 0 5px adjust-color($action-color, $lightness: -5%, $alpha: -0.3);
+
+// Animations
+$base-duration: 150ms;
+$base-timing: ease;
+
+// Perficient Digital Variables
+$heading-font-face: 'HalisR', sans-serif;
+$primary-font-face: 'Lato', sans-serif;
+
+$black: #000000;
+$primary-color: #202526;                        // Dark Gray
+$secondary-color: lighten($primary-color, 31%); // Gray
+$accent-color-1: #EF0734;                       // Perficient Digital Red
+$accent-color-2: #FFF200;                       // Perficient Digital Yellow
+$accent-color-3: #2AB68F;                       // Perficient Digital Green
+$white: #FFFFFF;
+
+$primary-padding:1.5rem 3rem 1rem;
+
+// Breakpoints Minimum Resolution
+$tablet: 48em;  // tablet
+$desktop: 64em; // desktop
+
+// Bourbon Omega Reset
+
+@mixin omega-reset($nth) {
+  &:nth-child(#{$nth}) { margin-right: flex-gutter(); }
+  &:nth-child(#{$nth}+1) { clear: none }
+}
diff --git a/app/assets/stylesheets/mailers/custom-mailer-styles.scss b/app/assets/stylesheets/mailers/custom-mailer-styles.scss
new file mode 100644
index 0000000..c4f1c9f
--- /dev/null
+++ b/app/assets/stylesheets/mailers/custom-mailer-styles.scss
@@ -0,0 +1,47 @@
+a {
+  text-decoration: underline;
+  color: $success-color;
+}
+
+.email-container {
+  .email-heading th {
+    font-family: 'Lato', Helvetica Neue, Helvetica, Ariel, sans-serif;
+    .prft-slash {
+      font-size: 100px;
+      font-weight: 200;
+      color: $success-color;
+      vertical-align: middle;
+    }
+    .email-title {
+      text-transform: uppercase;
+      font-weight: 700;
+      font-size:18px;
+      vertical-align: sub;
+    }
+  }
+  .email-body * {
+    font-family: 'Lato', Helvetica Neue, Helvetica, Ariel, sans-serif;
+    font-size: 16px;
+    line-height: 1.5em;
+    padding-bottom: 20px;
+    font-weight:300;
+    word-break: break-word;
+    strong {
+      font-weight:bold;
+    }
+  }
+  .email-copyright {
+    font-family: 'Lato', Helvetica Neue, Helvetica, Ariel, sans-serif;
+    font-size: 10px;
+    color: #909090;
+    vertical-align: bottom;
+  }
+  .email-logo {
+    text-align: right;
+    vertical-align: bottom;
+  }
+  .email-bg {
+    background:#fef035 !important;
+    background-color:#fef035 !important;
+  }
+}
diff --git a/app/assets/stylesheets/mailers/foundation_global_overrides.scss b/app/assets/stylesheets/mailers/foundation_global_overrides.scss
new file mode 100644
index 0000000..de181b3
--- /dev/null
+++ b/app/assets/stylesheets/mailers/foundation_global_overrides.scss
@@ -0,0 +1,6 @@
+// foundation overrides
+
+$success-color: #EF0734;
+$global-width: 600px;
+$body-font-family: inherit;
+$body-background: #ffffff;
diff --git a/app/assets/stylesheets/mailers/foundation_vendor_manifest.scss b/app/assets/stylesheets/mailers/foundation_vendor_manifest.scss
new file mode 100644
index 0000000..3667674
--- /dev/null
+++ b/app/assets/stylesheets/mailers/foundation_vendor_manifest.scss
@@ -0,0 +1,18 @@
+@import 'foundation-emails/scss/util/util',
+        'foundation-emails/scss/global',
+
+        'foundation_global_overrides',
+
+        'foundation-emails/scss/components/normalize',
+        'foundation-emails/scss/grid/grid',
+        'foundation-emails/scss/grid/block-grid',
+        'foundation-emails/scss/components/alignment',
+        'foundation-emails/scss/components/visibility',
+        'foundation-emails/scss/components/typography',
+        'foundation-emails/scss/components/button',
+        'foundation-emails/scss/components/callout',
+        'foundation-emails/scss/components/thumbnail',
+        'foundation-emails/scss/components/menu',
+        'foundation-emails/scss/components/outlook-first',
+        'foundation-emails/scss/components/media-query';
+@import 'custom-mailer-styles';
diff --git a/app/assets/stylesheets/molecules/_admin.scss b/app/assets/stylesheets/molecules/_admin.scss
new file mode 100644
index 0000000..3225489
--- /dev/null
+++ b/app/assets/stylesheets/molecules/_admin.scss
@@ -0,0 +1,31 @@
+main {
+  @include outer-container;
+  .saveadd {
+    @include span-columns(12);
+  }
+  .savecancel {
+    @include span-columns(12);
+  }
+  label.error {
+    text-align: right;
+    padding-top: 0;
+    font-size: 0.8rem;
+  }
+}
+
+@media only screen and (min-width: $tablet) {
+  main {
+    @include outer-container;
+    .viewall {
+      text-align: right;
+    }
+    .saveadd {
+      @include span-columns(6);
+    }
+    .savecancel {
+      @include span-columns(6);
+      @include omega();
+      text-align: right;
+    }
+  }
+}
diff --git a/app/assets/stylesheets/molecules/_buttongroups.scss b/app/assets/stylesheets/molecules/_buttongroups.scss
new file mode 100644
index 0000000..3b73009
--- /dev/null
+++ b/app/assets/stylesheets/molecules/_buttongroups.scss
@@ -0,0 +1,26 @@
+.btn-group {
+  > button {
+    float: left;
+    background-color: $white;
+    color: $secondary-color;
+    border-width: 1px 0;
+    border-style: solid;
+    border-color: $secondary-color;
+    margin: 0;
+    &:first-child {
+      border-radius: 999px 0 0 999px;
+      border-width:1px 0 1px 1px;
+    }
+    &:last-child {
+      border-radius: 0 999px 999px 0;
+      border-width:1px 1px 1px 0;
+    }
+    &.selected {
+      background-color: $primary-color;
+      color:$white;
+    }
+  }
+  &:after {
+    @include clearfix;
+  }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/molecules/_buttons.scss b/app/assets/stylesheets/molecules/_buttons.scss
new file mode 100644
index 0000000..5ef730e
--- /dev/null
+++ b/app/assets/stylesheets/molecules/_buttons.scss
@@ -0,0 +1,110 @@
+#{$all-buttons}, .btn {
+  appearance: none;
+  background-color: $primary-color;
+  border: 0;
+  border-radius: $base-border-radius;
+  color: #fff;
+  cursor: pointer;
+  display: inline-block;
+  font-family: $primary-font-face;
+  text-transform: uppercase;
+  font-size: 0.875rem;
+  letter-spacing: 0.2rem;
+  -webkit-font-smoothing: antialiased;
+  font-weight: 600;
+  line-height: 1;
+  padding: 1.2em 2em;
+  text-decoration: none;
+  transition: background-color $base-duration $base-timing;
+  user-select: none;
+  vertical-align: middle;
+  white-space: nowrap;
+
+
+
+  &:hover,
+  &:focus {
+    &:not([disabled]) {
+      background-color: $secondary-color;
+      color: #fff;
+    }
+  }
+
+  &:disabled {
+    cursor: not-allowed;
+    opacity: 0.3;
+  }
+}
+.secondary-btn,
+input[type="submit"].secondary-btn,
+button.secondary-btn {
+  background-color: $secondary-color;
+}
+
+.tertiary-btn,
+input[type="submit"].tertiary-btn,
+button.tertiary-btn {
+  background-color: transparent;
+  color: $primary-color;
+  border: 1px solid $primary-color;
+}
+
+.button-save {
+  display: none;
+}
+
+.button-cancel,
+.tertiary-btn.button-cancel {
+  color: #ef0734;
+  border: 1px solid #ef0734;
+  display: none;
+
+  &:hover {
+    &:not([disabled]) {
+      color: #fff;
+      background-color: #ef0734;
+    }
+  }
+}
+
+.answer-buttons {
+  .button-cancel {
+    margin-left: .5rem;
+  }
+}
+
+.disabled-button {
+  pointer-events: none;
+  opacity: 0.5;
+  cursor: default;
+}
+
+.button-wrap {
+  margin-top: 3rem;
+}
+
+// JS-enabled styles
+html.no-js {
+  .tertiary-btn.button-edit {
+    border: none;
+    text-decoration: underline;
+  }
+  .button-save,
+  .button-cancel {
+    display: none;
+  }
+}
+html.js {
+  .button-edit {
+    @extend .btn;
+  }
+}
+
+@media screen and (min-width: $screen-sm) {
+  html.no-js {
+    .tertiary-btn.button-edit {
+      display: inline-block;
+      padding-top: .75rem;
+    }
+  }
+}
diff --git a/app/assets/stylesheets/molecules/_forms.scss b/app/assets/stylesheets/molecules/_forms.scss
new file mode 100644
index 0000000..2b2883e
--- /dev/null
+++ b/app/assets/stylesheets/molecules/_forms.scss
@@ -0,0 +1,303 @@
+// TODO: Align colors with variables.scss
+fieldset {
+  background-color: transparent;
+  border: none;
+  padding: 0;
+  margin: 0;
+
+  &:disabled {
+    label {
+      display: none;
+    }
+    .form-group-multiples {
+      label {
+        display: block;
+      }
+    }
+  }
+}
+
+legend {
+  font-weight: 300;
+  margin-bottom: $small-spacing / 2;
+  padding: 0;
+}
+
+label {
+  display: block;
+  font-weight: 300;
+}
+
+form.btn-center {
+  text-align: center;
+}
+
+#{$all-text-inputs} {
+  display: block;
+  font-size: $base-font-size;
+  border: none;
+  border-bottom: 1px solid $secondary-color;
+  box-shadow: none;
+  border-radius: $base-border-radius;
+  box-sizing: border-box;
+  margin-bottom: 1.4em;
+  padding: $base-spacing / 3;
+  width: 100%;
+  font-weight: 300;
+  font-family: $primary-font-face;
+  line-height: 1.5em;
+
+  &:focus {
+    outline: none;
+    box-shadow: none;
+  }
+
+  &:disabled {
+    background-color: shade($base-background-color, 5%);
+    cursor: default;
+    border-bottom-color: #bbb;
+  }
+}
+button:disabled,
+input[type='submit']:disabled {
+  opacity: .3;
+  cursor: default;
+}
+
+textarea {
+  resize: vertical;
+  background: transparent;
+  padding: 0 0 3rem;
+}
+
+.summary_tpl textarea {
+  padding: 1rem 1rem 3rem;
+}
+
+[type="search"] {
+  appearance: none;
+}
+
+%multiple-choice {
+  display: inline;
+  margin-right: $small-spacing / 2;
+  &:not(:checked),
+  &:checked {
+    position: absolute;
+    left: -9999px;
+    + label {
+      position: relative;
+      padding-left: 25px;
+      cursor: pointer;
+      &:after {
+        position: absolute;
+        line-height: 0.8;
+        color: $primary-color;
+        transition: all .2s;
+      }
+    }
+    &:disabled {
+      + label {
+        &:before {
+          box-shadow: none;
+          border-color: #bbb;
+          background-color: #ddd;
+        }
+      }
+    }
+  }
+  &:not(:checked) {
+    + label {
+      &:after {
+        opacity: 0;
+        transform: scale(0);
+      }
+    }
+  }
+  &:checked {
+    + label {
+      &:before {
+        border: 1px solid $gray;
+        background: transparent;
+      }
+      &:after {
+        opacity: 1;
+        transform: scale(1);
+      }
+    }
+    &:disabled {
+      + label {
+        &:after {
+          color: #999;
+        }
+      }
+    }
+  }
+  &:hover:not(:disabled) {
+    + label {
+      &:before {
+        border: 2px solid $primary-color;
+      }
+    }
+  }
+  &:disabled {
+    + label {
+      color: #aaa;
+    }
+  }
+  + label {
+    &:before {
+      content: '';
+      position: absolute;
+      left:0;
+      top: 2px;
+      width: 20px;
+      height: 20px;
+      box-shadow: 0;
+      border: 1px solid $primary-color;
+      background: transparent;
+      box-sizing: border-box;
+    }
+  }
+}
+
+[type="radio"] {
+  @extend %multiple-choice;
+  &:not(:checked),
+  &:checked {
+    + label {
+      &:after {
+        content: '';
+        position: absolute;
+        width: 8px;
+        height: 8px;
+        top: 8px;
+        left: 6px;
+        background-color: $primary-color;
+        border-radius: 100%;
+      }
+    }
+  }
+  + label {
+    &:before {
+      border-radius: 50%;
+    }
+  }
+  &:not(:disabled) + label:before {
+    background-color: white;
+  }
+  &:disabled {
+    + label {
+      &:after {
+        background-color: #888;
+      }
+    }
+  }
+}
+
+[type="checkbox"] {
+  @extend %multiple-choice;
+  &:not(:checked),
+  &:checked {
+    + label {
+      &:after {
+        font-family: 'Zapf Dingbats', 'Menlo';
+        content: '\2714';
+        font-size: 13px;
+        top: 7px;
+        left: 5px;
+      }
+    }
+  }
+  &:not(:disabled) + label:before {
+    background-color: white;
+  }
+}
+
+[type="file"] {
+  margin-bottom: $small-spacing;
+  width: 100%;
+}
+
+select {
+  margin-bottom: 0.75em;
+  max-width: 100%;
+  width: auto; // needed?
+  background-color: $white;
+  border: 1px solid #aaa;
+  border-radius: 0px;
+  font-weight: 300;
+  font-family: $primary-font-face;
+  font-size: 1rem;
+  &::-ms-expand {
+    display: none;
+  }
+  &:not([multiple]) {
+    background-image: asset_data_url("icon-dropdownlist.png");
+    background-repeat: no-repeat;
+    background-position: right 10px bottom 50%;
+    -webkit-appearance: none;
+    -moz-appearance: none;
+    appearance: none;
+    padding: 10px 40px 10px 10px;
+  }
+  option {
+    font-weight: 300;
+    font-family: $primary-font-face;
+    font-size: 1rem;
+  }
+}
+
+.form-group {
+  position: relative;
+  margin-bottom: 1.2rem;
+
+  label {
+    transition: 0.2s ease;
+    + #{$all-text-inputs} {
+      background: transparent;
+    }
+    &.loaded {
+      left: 0;
+      top: 5px;
+      font-size: 1em;
+      position: absolute;
+      pointer-events: none;
+    }
+    &.animate {
+      font-size: 0.8em;
+      top: -10px;
+    }
+  }
+}
+
+label[for="answer_text"],
+label[for="answer_live_code"] {
+  font-weight: 400;
+  font-style: italic;
+}
+
+.form-group-multiples { // radios, checks wrappers
+  margin-bottom: .5rem;
+}
+
+.button-group {
+  margin-top: 2rem;
+}
+
+.skills-app-form {
+  margin-top: 2rem;
+}
+
+.resolve-error {
+  animation-name: success-fadeout;
+  animation-duration: 1.5s;
+  animation-delay: 0;
+  animation-fill-mode: forwards;
+}
+
+html.no-js {
+  .chars {
+    display: none;
+  }
+}
diff --git a/app/assets/stylesheets/molecules/_lists.scss b/app/assets/stylesheets/molecules/_lists.scss
new file mode 100644
index 0000000..2e663ab
--- /dev/null
+++ b/app/assets/stylesheets/molecules/_lists.scss
@@ -0,0 +1,35 @@
+ul, ol {
+  margin: 0;
+  padding: 0 0 0 1em;
+  li {
+    line-height: 2em;
+  }
+}
+
+dl {
+  margin: 0;
+}
+
+dt {
+  font-weight: 600;
+  margin: 0;
+}
+
+dd {
+  margin: 0;
+}
+
+ul {
+  list-style-type: none;
+  list-style-position: inside;
+  li {
+    position:relative;
+    padding-left:1em;
+    &:before {
+      position: absolute;
+      top: 3px;
+      left:0;
+      content: asset_data_url('icon-unorderedlistbullet.png');
+    }
+  }
+}
diff --git a/app/assets/stylesheets/molecules/_livecoder.scss b/app/assets/stylesheets/molecules/_livecoder.scss
new file mode 100644
index 0000000..5ca90d2
--- /dev/null
+++ b/app/assets/stylesheets/molecules/_livecoder.scss
@@ -0,0 +1,132 @@
+.code-input {
+  margin: 10px 0;
+
+  textarea {
+    font-family: "Lucida Console", Monaco, monospace;
+    font-size: 10px;
+    // line-height: 1.6em;
+    margin-bottom: 0;
+    min-height: 205px;
+    width: 100%;
+  }
+}
+
+.code-input textarea,
+.questions_tpl .code-input textarea {
+  padding: 4px 0.2rem 0 2rem;
+}
+
+.code-input ~ button {
+  margin-left: 0;
+  clear: both;
+  display: block;
+}
+
+.results {
+  border: 1px solid $secondary-color;
+  clear: both;
+  margin: 10px 0;
+  min-height: 200px;
+  width: 100%;
+  background-color: #fff;
+}
+
+fieldset:disabled .results {
+  border-color: #bbb;
+}
+
+iframe {
+  border: 0;
+  height: 100%;
+  min-height: 190px;
+  width: 100%;
+}
+
+@media only screen and (min-width: $desktop) {
+  .code-input {
+    float: left;
+    margin: 10px 1%;
+    width: 32%;
+
+    &:nth-of-type(1) {
+      margin-left: 0;
+    }
+
+    &:nth-of-type(3) {
+      margin-right: 0;
+    }
+  }
+}
+
+
+
+// jQuery Lined Textarea Plugin
+//   http://alan.blog-city.com/jquerylinedtextarea.htm
+//
+// Copyright (c) 2010 Alan Williamson
+//
+// Released under the MIT License:
+// http://www.opensource.org/licenses/mit-license.php
+//
+// Usage:
+//   Displays a line number count column to the left of the textarea
+//
+//   Class up your textarea with a given class, or target it directly
+//   with JQuery Selectors
+//
+//   $(".lined").linedtextarea({
+//   	selectedLine: 10,
+//    selectedClass: 'lineselect'
+//   });
+
+.linedwrap {
+	border: 1px solid $secondary-color;
+	padding: 0;
+  position: relative;
+}
+
+fieldset:disabled .linedwrap {
+  border-color: #bbb;
+}
+
+.linedtextarea {
+	padding: 0;
+	margin: 0;
+}
+
+.linedtextarea textarea, .linedwrap .codelines .lineno {
+	font-size: 10pt;
+  font-family: "Lucida Console", Monaco, monospace;
+	line-height: normal !important;
+}
+
+.linedtextarea textarea {
+	padding-right: 0.3em;
+	padding-top: 0.3em;
+	border: 0 !important;
+}
+
+.linedwrap .lines {
+	margin-top: 0px;
+	overflow: hidden;
+	border-right: 1px solid #c0c0c0;
+	margin-right: 10px;
+  position: absolute;
+  left: 0.3rem;
+}
+
+.linedwrap .codelines {
+	padding-top: 5px;
+}
+
+.linedwrap .codelines .lineno {
+	color:#AAAAAA;
+	padding-right: 0.5em;
+	padding-top: 0.0em;
+	text-align: right;
+	white-space: nowrap;
+}
+
+.linedwrap .codelines .lineselect {
+	color: red;
+}
diff --git a/app/assets/stylesheets/molecules/_nav.scss b/app/assets/stylesheets/molecules/_nav.scss
new file mode 100644
index 0000000..f27eb2f
--- /dev/null
+++ b/app/assets/stylesheets/molecules/_nav.scss
@@ -0,0 +1,17 @@
+nav {
+  margin: 15px 0;
+  padding: 0;
+  text-align: right;
+
+  a,
+  a:visited {
+    text-decoration: none;
+    padding: 15px;
+    margin: 0;
+    text-transform: uppercase;
+
+    &:hover {
+      background-color: $gray-lighter;
+    }
+  }
+}
diff --git a/app/assets/stylesheets/molecules/_progressbar.scss b/app/assets/stylesheets/molecules/_progressbar.scss
new file mode 100644
index 0000000..d4e9eff
--- /dev/null
+++ b/app/assets/stylesheets/molecules/_progressbar.scss
@@ -0,0 +1,41 @@
+.progress {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  max-height: 84px;
+}
+
+.progress-bar {
+  // background-color: $primary-color;
+  background-color: #39bd9a;
+  color:white;
+  text-align: right;
+  line-height: 8vw;
+  font-weight: 900;
+  min-width: 50px;
+  position: relative;
+  transform: skewX(-16.5deg);
+
+  span {
+    display: inline-block;
+    transform: skewX(16.5deg);
+    margin-right: .85rem;
+    font-size: 3vw;
+    line-height: 8vw;
+  }
+}
+
+@media screen and (min-width: 500px) {
+  .progress-bar span {
+    font-size: 16px;
+  }
+}
+
+@media only screen and (min-width: $desktop) {
+  .progress-bar,
+  .progress-bar span {
+    line-height: 84px;
+    max-height: 84px;
+  }
+}
diff --git a/app/assets/stylesheets/molecules/_tables.scss b/app/assets/stylesheets/molecules/_tables.scss
new file mode 100644
index 0000000..2f23953
--- /dev/null
+++ b/app/assets/stylesheets/molecules/_tables.scss
@@ -0,0 +1,22 @@
+table {
+  border-collapse: collapse;
+  margin: $small-spacing 0;
+  width: 100%;
+}
+
+th {
+  border-bottom: 1px solid shade($base-border-color, 25%);
+  font-weight: 600;
+  padding: $small-spacing 0;
+  text-align: left;
+}
+
+td {
+  padding: $small-spacing 0;
+}
+
+tr,
+td,
+th {
+  vertical-align: middle;
+}
diff --git a/app/controllers/concerns/.keep b/app/assets/stylesheets/organisms/.keep
similarity index 100%
rename from app/controllers/concerns/.keep
rename to app/assets/stylesheets/organisms/.keep
diff --git a/app/assets/stylesheets/pages/_error.scss b/app/assets/stylesheets/pages/_error.scss
new file mode 100644
index 0000000..2915ced
--- /dev/null
+++ b/app/assets/stylesheets/pages/_error.scss
@@ -0,0 +1,58 @@
+.error {
+    text-align: center;
+    background-color: $accent-color-1;
+    color: #fff;
+    margin: 2rem 0 .5rem;
+    padding: .5rem 0;
+}
+
+.error-header {
+  @include outer-container;
+  .page-title {
+    display: inline-block;
+    h1 {
+      font-family: $heading-font-face;
+      font-size: 1.5rem;
+    }
+  }
+  .secondary-btn {
+    margin-top: 1rem;
+  }
+}
+
+.warning {
+  @extend .error;
+  background-color: #f39c12;
+}
+
+.success {
+  @extend .error;
+  background-color: $brand-success;
+}
+
+.notice {
+  @extend .error;
+  background-color: $brand-info;
+}
+
+[data-id="live-coder-finish-later"] {
+  .warning {
+    margin-top: 0;
+    margin-bottom: 2rem;
+    text-align: left;
+    padding: .5rem 1rem;
+  }
+}
+
+@media only screen and (min-width: $desktop) {
+  .error-header {
+    .page-title {
+      h1 {
+        font-size: 1.5rem;
+      }
+    }
+    .secondary-btn {
+      float:right;
+    }
+  }
+}
diff --git a/app/assets/stylesheets/pages/_styleguide.scss b/app/assets/stylesheets/pages/_styleguide.scss
new file mode 100644
index 0000000..3ad2ef6
--- /dev/null
+++ b/app/assets/stylesheets/pages/_styleguide.scss
@@ -0,0 +1,32 @@
+.styleguide_tpl {
+  @include outer-container;
+}
+
+#halisr {
+  font-family: $heading-font-face;
+  font-size:2rem;
+  #primary-100 { font-weight: 100; }
+  #primary-200 { font-weight: 200; }
+  #primary-300 { font-weight: 300; }
+  #primary-400 { font-weight: 400; }
+  #primary-500 { font-weight: 500; }
+  #primary-600 { font-weight: 600; }
+  #primary-700 { font-weight: 700; }
+  #primary-800 { font-weight: 800; }
+  #primary-900 { font-weight: 900; }
+}
+
+#lato {
+  font-family: $primary-font-face;
+  font-size:2rem;
+  #secondary-100 { font-weight: 100; }
+  #secondary-300 { font-weight: 300; }
+  #secondary-500 { font-weight: 500; }
+  #secondary-700 { font-weight: 700; }
+  #secondary-900 { font-weight: 900; }
+  #secondary-100-i { font-weight: 100; font-style: italic; }
+  #secondary-300-i { font-weight: 300; font-style: italic; }
+  #secondary-500-i { font-weight: 500; font-style: italic; }
+  #secondary-700-i { font-weight: 700; font-style: italic; }
+  #secondary-900-i { font-weight: 900; font-style: italic; }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/templates/_footer.scss b/app/assets/stylesheets/templates/_footer.scss
new file mode 100644
index 0000000..f30502b
--- /dev/null
+++ b/app/assets/stylesheets/templates/_footer.scss
@@ -0,0 +1,44 @@
+footer {
+  width: 100%;
+  height: 112px;
+  @include outer-container;
+}
+.footer_title {
+  @include span-columns(6);
+  padding:2.5rem 0 1rem;
+  h2 {
+    font-family: $primary-font-face;
+    text-transform: uppercase;
+    font-weight: 700;
+    margin: 0 0 0 0;
+    font-size: 3vw;
+  }
+}
+.pd_logo {
+  @include span-columns(6);
+  @include omega();
+  text-align: right;
+  padding:1.5rem 0 1rem;
+  img {
+    max-height: 45px;
+  }
+}
+.footer_yellow-bar {
+  @include span-columns(12);
+  @include omega();
+  @include slantmix(0, 0, 1.5rem);
+  background-color:$accent-color-2;
+}
+
+@media only screen and (min-width: 480px) {
+  footer {
+    height: 116px;
+  }
+}
+
+
+@media screen and (min-width: 700px) {
+  .footer_title h2 {
+    font-size: 1.25rem;
+  }
+}
diff --git a/app/assets/stylesheets/templates/_header.scss b/app/assets/stylesheets/templates/_header.scss
new file mode 100644
index 0000000..c12384f
--- /dev/null
+++ b/app/assets/stylesheets/templates/_header.scss
@@ -0,0 +1,30 @@
+header {
+  @include outer-container;
+  padding-top: 13vw;
+
+  &.no-progressbar {
+    padding-top: 52px;
+  }
+
+  &.no-progressbar.admin {
+    padding-top: 0;
+  }
+}
+.page-title {
+  @include omega();
+  text-transform: uppercase;
+  padding: 1rem 3rem .5rem;
+
+  div {
+    font: bold 1.25rem $primary-font-face;
+  }
+}
+
+@media screen and (min-width: $desktop) {
+  header {
+    padding-top: 135px;
+  }
+  .page-title {
+    padding:$primary-padding;
+  }
+}
diff --git a/app/assets/stylesheets/templates/_intro.scss b/app/assets/stylesheets/templates/_intro.scss
new file mode 100644
index 0000000..650c064
--- /dev/null
+++ b/app/assets/stylesheets/templates/_intro.scss
@@ -0,0 +1,26 @@
+.intro_tpl {
+  @include outer-container;
+  padding:$primary-padding;
+  form {
+    margin-top: 3rem;
+    @include span-columns(12);
+    @include omega();
+    button[type='submit']
+    {
+      display: block;
+      width:100%;
+      margin-top: 3rem;
+    }
+  }
+}
+
+@media only screen and (min-width: $desktop) {
+  .intro_tpl {
+    padding:$primary-padding;
+    form {
+      @include span-columns(6);
+      @include shift(3);
+      @include omega();
+    }
+  }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/templates/_questions.scss b/app/assets/stylesheets/templates/_questions.scss
new file mode 100644
index 0000000..6385440
--- /dev/null
+++ b/app/assets/stylesheets/templates/_questions.scss
@@ -0,0 +1,70 @@
+.questions_tpl {
+  @include outer-container;
+  .prft-heading,
+  .content-well {
+    padding: 1rem 0;
+    @include outer-container;
+    .column-left,
+    .column-right {
+      @include span-columns(12);
+      @include omega();
+    }
+  }
+  .form-group {
+    position: relative;
+    textarea {
+      + label {
+        position: absolute;
+        left: 3rem;
+        top: 1rem;
+        pointer-events: none;
+      }
+      &:focus,
+      &:valid {
+        + label {
+          top: -10px;
+          font-size: 0.8em;
+        }
+      }
+    }
+  }
+  textarea {
+    padding: 1rem 0;
+  }
+}
+
+.btn-container-left,
+.btn-container-right {
+  padding-top: 2rem;
+}
+
+.btn-container-left {
+  @include span-columns(5);
+}
+.btn-container-right {
+  @include span-columns(7);
+  @include omega();
+  text-align: right;
+}
+
+.btn-container-summary {
+  text-align: center;
+}
+
+@media only screen and (min-width: $desktop) {
+  .questions_tpl {
+    @include outer-container;
+    .prft-heading,
+    .content-well {
+
+      .column-left {
+        @include omega-reset(6n);
+        @include span-columns(6);
+      }
+      .column-right {
+        @include span-columns(6);
+        @include omega();
+      }
+    }
+  }
+}
diff --git a/app/assets/stylesheets/templates/_summary.scss b/app/assets/stylesheets/templates/_summary.scss
new file mode 100644
index 0000000..4a326ad
--- /dev/null
+++ b/app/assets/stylesheets/templates/_summary.scss
@@ -0,0 +1,100 @@
+.summary_tpl {
+  @include outer-container;
+  padding: 2rem 0;
+  h2 {
+    font-size:1.875rem;
+    font-weight: 400;
+  }
+  .answer-sec {
+    // padding-top:2rem;
+    margin-bottom: 2rem;
+    transition: 0.3s ease;
+    // &.editable {
+    //   background-color: #f2f2f2;
+    // }
+    .question-heading {
+      @include outer-container;
+    }
+    .question-title {
+      @include span-columns(12);
+      @include omega();
+      padding-top: .75rem;
+    }
+    h3 {
+      font-size: 1.25rem;
+      font-weight: 400;
+    }
+    .answer-buttons {
+      @include span-columns(12);
+      @include omega();
+      white-space: nowrap;
+    }
+    .answer-container {
+      padding:2rem 0;
+
+      textarea:not(:disabled) {
+        background-color: white;
+      }
+    }
+  }
+}
+
+.editable {
+  background-color: #f2f2f2;
+  padding: 2rem;
+  margin: 0 -2rem;
+}
+
+#summary-form {
+  margin-top: 3rem;
+}
+
+.success {
+    box-sizing: border-box;
+    text-align: center;
+    background-color: $accent-color-3;
+    color: #fff;
+    padding-top: .5rem;
+    padding-bottom: .5rem;
+    margin-bottom: .5rem;
+    animation-name: success-fadeout;
+    animation-duration: 1.5s;
+    animation-delay: 2s;
+    animation-fill-mode: forwards;
+}
+
+.text-answer.answer-container {
+  white-space: pre-line;
+}
+
+@media only screen and (min-width: $tablet) {
+  .summary_tpl {
+    .answer-sec {
+      .question-heading {
+        .question-title {
+          @include span-columns(8);
+        }
+        .answer-buttons {
+          @include span-columns(4);
+          @include omega();
+          text-align: right;
+        }
+      }
+    }
+  }
+}
+
+@media only screen and (min-width: $desktop) {
+  .summary_tpl {
+    .answer-sec {
+      .question-heading {
+        .question-title {
+          @include span-columns(9);
+        }
+        .answer-buttons {
+          @include span-columns(3);
+        }
+      }
+    }
+  }
+}
diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb
index d672697..51e3e93 100644
--- a/app/channels/application_cable/channel.rb
+++ b/app/channels/application_cable/channel.rb
@@ -1,3 +1,4 @@
+# frozen_string_literal: true
 module ApplicationCable
   class Channel < ActionCable::Channel::Base
   end
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
index 0ff5442..fa70319 100644
--- a/app/channels/application_cable/connection.rb
+++ b/app/channels/application_cable/connection.rb
@@ -1,3 +1,4 @@
+# frozen_string_literal: true
 module ApplicationCable
   class Connection < ActionCable::Connection::Base
   end
diff --git a/app/controllers/admin/auth_controller.rb b/app/controllers/admin/auth_controller.rb
new file mode 100644
index 0000000..afc039f
--- /dev/null
+++ b/app/controllers/admin/auth_controller.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+module Admin
+  class AuthController < AdminController
+    skip_before_action :authorize_user
+
+    # bypass pundit lockdowns for auth requests.
+    after_action :skip_policy_scope
+    after_action :skip_authorization
+
+    def login
+    end
+
+    def auth
+      user = User.find_by(email: auth_params[:email])
+
+      if user && user.authenticate(auth_params[:password])
+        session[:user] = user.to_i
+        redirect_to admin_path
+      else
+        redirect_to admin_login_path,
+                    flash: { error: "Sorry, incorrect email or password. Please try again." }
+      end
+    end
+
+    def logout
+      reset_session
+      redirect_to admin_login_path
+    end
+
+    def reset_request
+    end
+
+    def send_reset
+      user = User.find_by(email: request_params[:email])
+      redirect_to(admin_reset_request_path) and return if user.nil?
+
+      user.setup_reset
+      UserMailer.password_reset(user).deliver_later
+      redirect_to admin_reset_request_path,
+                  success: "Reset request sent! Please check your email for instructions."
+    end
+
+    def reset
+      user = User.find_by(reset_token: params[:reset_token])
+      redirect_to(admin_reset_request_path) and return if user.nil?
+    end
+
+    def reset_password
+      user = User.find_by(reset_token: params[:reset_token])
+      redirect_to(admin_reset_request_path) and return if user.nil?
+
+      if user.update(reset_params)
+        redirect_to admin_login_path, success: "Password has been reset. Please log in."
+      else
+        redirect_to admin_reset_request_path, flash: { error: "Password was not updated." }
+      end
+    end
+
+    private
+
+      def request_params
+        params.require(:auth).permit(:email)
+      end
+
+      def reset_params
+        params.require(:auth).permit(:password, :password_confirmation)
+      end
+  end
+end
diff --git a/app/controllers/admin/candidate_controller.rb b/app/controllers/admin/candidate_controller.rb
new file mode 100644
index 0000000..5a95b68
--- /dev/null
+++ b/app/controllers/admin/candidate_controller.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+module Admin
+  class CandidateController < AdminController
+    before_action :collect_quizzes, except: [:login, :auth]
+
+    def index
+      @candidates = policy_scope Candidate.order(:name)
+    end
+
+    def new
+      authorize Candidate
+      @candidate = Candidate.new
+      render :new
+    end
+
+    def create
+      authorize Candidate
+      @candidate = Candidate.create(candidate_params.merge(recruiter_id: current_user.id))
+
+      if @candidate.persisted?
+        send_notifications @candidate
+        redirect_to admin_candidates_path,
+                    flash: { success: "Sucessfully created candidate #{@candidate.name}" }
+      else
+        flash[:error] = "Failed to save candidate."
+        render :new
+      end
+    end
+
+    def edit
+      authorize Candidate
+      @candidate = Candidate.find_by(id: params[:id])
+    end
+
+    def update
+      authorize Candidate
+      @candidate = Candidate.find_by(id: params[:id])
+      @candidate.update(candidate_params)
+
+      if @candidate.save
+        redirect_to admin_candidates_path, flash: { success: "#{@candidate.name} updated!" }
+      else
+        flash[:error] = "Failed to save candidate."
+        render :edit
+      end
+    end
+
+    def resend_welcome
+      authorize Candidate
+      candidate = Candidate.find_by(id: params[:id])
+      CandidateMailer.welcome(candidate).deliver_later
+      render json: { message: "Email queued!" }.to_json
+    end
+
+    private
+
+      def candidate_params
+        params.require(:candidate).permit(:name, :email, :experience, :quiz_id)
+      end
+
+      def collect_quizzes
+        @quizzes ||= Quiz.order(:name)
+      end
+
+      def send_notifications candidate
+        CandidateMailer.welcome(candidate).deliver_later
+        RecruiterMailer.candidate_created(candidate).deliver_later
+      end
+  end
+end
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
new file mode 100644
index 0000000..ea5492f
--- /dev/null
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+module Admin
+  class DashboardController < AdminController
+    def show
+      authorize :dashboard
+      send "redirect_for_#{current_user.role}"
+    end
+
+    private
+
+      def redirect_for_admin
+        redirect_to admin_users_url
+      end
+
+      def redirect_for_manager
+        redirect_to admin_quizzes_url
+      end
+
+      def redirect_for_reviewer
+        redirect_to admin_results_url
+      end
+
+      def redirect_for_recruiter
+        redirect_to admin_candidates_url
+      end
+  end
+end
diff --git a/app/controllers/admin/profile_controller.rb b/app/controllers/admin/profile_controller.rb
new file mode 100644
index 0000000..b176ac2
--- /dev/null
+++ b/app/controllers/admin/profile_controller.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+module Admin
+  class ProfileController < AdminController
+    def view
+      authorize current_user
+    end
+
+    def edit
+      @user = current_user
+      authorize @user
+    end
+
+    def update
+      @user = current_user
+      authorize @user
+
+      if @user.update_attributes(user_params)
+        redirect_to admin_profile_path,
+                    flash: { success: "Sucessfully updated profile" }
+      else
+        flash[:error] = "Failed to update profile."
+        render :edit
+      end
+    end
+
+    private
+
+      def user_params
+        params.require(:user).permit(policy(User).permitted_attributes)
+      end
+  end
+end
diff --git a/app/controllers/admin/question_controller.rb b/app/controllers/admin/question_controller.rb
new file mode 100644
index 0000000..1dfbb84
--- /dev/null
+++ b/app/controllers/admin/question_controller.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+module Admin
+  class QuestionController < AdminController
+    def index
+      @questions = policy_scope Question.includes(:quiz).order("quizzes.name", { active: :desc }, :sort)
+    end
+
+    def new
+      authorize Question
+
+      @question = Question.new(active: true)
+      @quizzes = policy_scope Quiz.all
+    end
+
+    def create
+      authorize Quiz
+
+      @quizzes = policy_scope Quiz.all
+      @question = Question.create(process_question_params)
+
+      if @question.persisted?
+        redirect_to admin_questions_path, flash: { success: "Sucessfully created question" }
+      else
+        flash[:error] = "Failed to save question."
+        render :new
+      end
+    end
+
+    def view
+      @question = Question.includes(:quiz).find(params[:question_id])
+      authorize @question
+    end
+
+    def edit
+      @quizzes = policy_scope Quiz.all
+      @question = Question.includes(:quiz).find(params[:question_id])
+
+      authorize @question
+    end
+
+    def update
+      @quizzes = policy_scope Quiz.all
+      @question = Question.find(params[:question_id])
+      authorize @question
+
+      if @question.update_attributes(process_question_params)
+        redirect_to admin_question_path(@question.to_i),
+                    flash: { success: "Sucessfully updated question" }
+      else
+        flash[:error] = "Failed to update question."
+        render :edit
+      end
+    end
+
+    def options
+      @question = params[:question_id].present? ? Question.find(params[:question_id]) : Question.new
+      authorize @question
+      render layout: false
+    end
+
+    private
+
+      def question_params
+        params.require(:question).permit(
+          :quiz_id, :question, :category, :input_type, :sort, :active, :input_options,
+          multi_choice: [], live_code: [:later, :html, :css, :js, :text]
+        )
+      end
+
+      def process_question_params
+        question = question_params
+        question[:input_options] = question_params[:multi_choice] unless question_params[:multi_choice].nil?
+        question[:input_options] = question_params[:live_code] unless question_params[:live_code].nil?
+        question.delete(:multi_choice)
+        question.delete(:live_code)
+        question
+      end
+  end
+end
diff --git a/app/controllers/admin/quiz_controller.rb b/app/controllers/admin/quiz_controller.rb
new file mode 100644
index 0000000..9d1d5ef
--- /dev/null
+++ b/app/controllers/admin/quiz_controller.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+module Admin
+  class QuizController < AdminController
+    def index
+      @quizzes = policy_scope Quiz.all
+    end
+
+    def new
+      authorize Quiz
+      @quiz = Quiz.new
+    end
+
+    def create
+      authorize Quiz
+      @quiz = Quiz.create(quiz_params)
+
+      if @quiz.persisted?
+        redirect_to admin_quizzes_path, flash: { notice: "Sucessfully created quiz" }
+      else
+        flash[:error] = "Failed to save quiz."
+        render :new
+      end
+    end
+
+    def view
+      @quiz = Quiz.find(params[:quiz_id])
+      authorize @quiz
+    end
+
+    def edit
+      @quiz = Quiz.find(params[:quiz_id])
+      authorize @quiz
+    end
+
+    def update
+      @quiz = Quiz.find(params[:quiz_id])
+      authorize @quiz
+
+      if @quiz.update_attributes(quiz_params)
+        redirect_to admin_quiz_path(@quiz.to_i),
+                    flash: { notice: "Sucessfully updated quiz" }
+      else
+        flash[:error] = "Failed to update quiz."
+        render :edit
+      end
+    end
+
+    private
+
+      def quiz_params
+        params.require(:quiz).permit(:name, :dept, :unit)
+      end
+  end
+end
diff --git a/app/controllers/admin/result_controller.rb b/app/controllers/admin/result_controller.rb
new file mode 100644
index 0000000..e5c9564
--- /dev/null
+++ b/app/controllers/admin/result_controller.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+module Admin
+  class ResultController < AdminController
+    #
+    # TODO: change context from Candidate to Quiz
+    # bypass pundit lockdowns until completed
+    after_action :skip_policy_scope
+    after_action :skip_authorization
+    #
+
+    # TODO: Limit results to the quizzes current_user has access to
+    def index
+      @candidates = Candidate.where(completed: true).includes(:recruiter)
+    end
+
+    def view
+      @candidate = Candidate.find_by(test_hash: params[:test_hash])
+      @quiz = @candidate.my_quiz
+      @status = QuizStatus.new(@candidate)
+    end
+  end
+end
diff --git a/app/controllers/admin/user_controller.rb b/app/controllers/admin/user_controller.rb
new file mode 100644
index 0000000..d8ab69a
--- /dev/null
+++ b/app/controllers/admin/user_controller.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+module Admin
+  class UserController < AdminController
+    def index
+      @users = policy_scope User.order(:name)
+    end
+
+    def new
+      @user = User.new
+      authorize @user
+    end
+
+    def create
+      authorize User
+      default_passwd = SecureRandom.urlsafe_base64(12)
+      @user = User.create({ password: default_passwd }.merge(user_params.to_h))
+
+      if @user.persisted?
+        UserMailer.welcome(@user, default_passwd).deliver_later
+        redirect_to admin_users_path, flash: { success: "Sucessfully created user #{@user.name}" }
+      else
+        flash[:error] = "Failed to save user."
+        render :new
+      end
+    end
+
+    def view
+      @user = User.find(params[:user_id])
+      authorize @user
+    end
+
+    def edit
+      @user = User.find(params[:user_id])
+      authorize @user
+    end
+
+    def update
+      @user = User.find(params[:user_id])
+      authorize @user
+
+      if @user.update_attributes(user_params)
+        redirect_to admin_user_path(@user.to_i),
+                    flash: { success: "Sucessfully updated #{@user.name}" }
+      else
+        flash[:error] = "Failed to update user."
+        render :edit
+      end
+    end
+
+    private
+
+      def user_params
+        params.require(:user).permit(policy(User).permitted_attributes)
+      end
+  end
+end
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
new file mode 100644
index 0000000..2c2daf6
--- /dev/null
+++ b/app/controllers/admin_controller.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+class AdminController < ApplicationController
+  include Pundit
+  layout 'admin'
+  before_action :authorize_user
+
+  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
+
+  after_action :verify_authorized, except: :index
+  after_action :verify_policy_scoped, only: :index
+
+  def current_user
+    @current_user ||= User.find_by(id: session[:user]) if session[:user]
+  end
+  helper_method :current_user
+
+  private
+
+    def authorize_user
+      redirect_to admin_login_path unless current_user
+    end
+
+    def user_not_authorized
+      flash[:error] = "You are not authorized to perform this action."
+      redirect_to(request.referer || admin_login_path)
+    end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 1c07694..c9b89a9 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,3 +1,25 @@
+# frozen_string_literal: true
 class ApplicationController < ActionController::Base
   protect_from_forgery with: :exception
+
+  add_flash_types :warning, :success
+
+  def current_candidate
+    @current_candidate ||= Candidate.find_by(test_hash: session[:test_id]) if session[:test_id]
+  end
+  helper_method :current_candidate
+
+  def styleguide
+    render '/styleguide'
+  end
+
+  private
+
+    def auth_params
+      params.require(:auth).permit(:email, :password)
+    end
+
+    def authorize_candidate
+      redirect_to login_path unless current_candidate
+    end
 end
diff --git a/app/controllers/candidate_controller.rb b/app/controllers/candidate_controller.rb
new file mode 100644
index 0000000..0cd4f4c
--- /dev/null
+++ b/app/controllers/candidate_controller.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+class CandidateController < ApplicationController
+  before_action :authorize_candidate, except: [:login, :validate, :live_coder]
+  before_action :send_to_oops, only: [:login]
+
+  def login
+    login_candidate
+    redirect_to :thankyou and return if current_candidate && current_candidate.completed?
+    redirect_to :welcome if current_candidate
+
+    flash[:error] = "Sorry, incorrect test id" if params[:test_hash].present?
+  end
+
+  def welcome
+    render :welcome_back if current_candidate.answers.count.positive?
+  end
+
+  def saved
+  end
+
+  def oops
+  end
+
+  def thankyou
+    redirect_to root_path if session[:test_id].nil?
+    reset_session
+  end
+
+  def validate
+    candidate = Candidate.find_by(test_hash: params['test_id'])
+    redirect_to(root_path, flash: { error: "Sorry, incorrect test id" }) and return if candidate.nil?
+
+    session[:test_id] = candidate.test_hash
+    redirect_to :thankyou and return if candidate.completed?
+    redirect_to :welcome
+  end
+
+  private
+
+    def login_candidate
+      candidate = Candidate.find_by(test_hash: params['test_id'])
+      return false if candidate.nil?
+
+      session[:test_id] = candidate.test_hash
+    end
+
+    def send_to_oops
+      redirect_to oops_path if current_candidate
+    end
+end
diff --git a/app/controllers/quiz_controller.rb b/app/controllers/quiz_controller.rb
new file mode 100644
index 0000000..4523f10
--- /dev/null
+++ b/app/controllers/quiz_controller.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+class QuizController < ApplicationController
+  before_action :authorize_candidate
+
+  def question
+    qid = prep_status.current_question_id || params[:question_id]
+    redirect_to :summary and return if qid.nil?
+    prep_question qid
+    @answer = prep_answer qid
+    prep_instance_answer @question
+  end
+
+  def update_answer
+    @answer = prep_answer answer_params[:question_id]
+    prep_status
+    prep_question(answer_params[:question_id])
+    @answer.update(process_answer_params)
+    route_answer_xhr and return if request.xhr?
+    route_answer_html
+  end
+
+  def summary
+    @quiz = current_candidate.my_quiz
+    redirect_to :question and return unless prep_status.current_question_id.nil?
+  end
+
+  def submit_summary
+    not_completed_error = 'You must complete all questions to submit your test.'
+    redirect_to :summary, flash: { error: not_completed_error } and return unless prep_status.can_submit
+
+    complete_and_email
+    redirect_to :thankyou
+  end
+
+  private
+
+    def complete_and_email
+      if current_candidate.update_attributes(completed: true)
+        CandidateMailer.submitted(current_candidate).deliver_later
+        RecruiterMailer.candidate_submitted(current_candidate).deliver_later
+        ReviewerMailer.candidate_submission(current_candidate).deliver_later
+        return true
+      end
+    end
+
+    def prep_question qid
+      @question = current_candidate.fetch_question(qid)
+    end
+
+    def prep_status
+      @status ||= QuizStatus.new(current_candidate)
+    end
+
+    def prep_instance_answer question
+      @answer = question.answer.nil? ? Answer.new : Answer.find(question.answer_id)
+    end
+
+    def answer_params
+      params.require(:answer).permit(
+        :question_id,
+        :answer,
+        :answer_id,
+        answer_array: [],
+        answer_hash: [:later, :html, :css, :js, :text, :other, options: []]
+      )
+    end
+
+    def process_answer_params
+      answer = answer_params
+      answer[:saved] = params.key?(:save)
+      answer[:submitted] = params.key?(:submit)
+      answer[:answer] = answer_params[:answer_array] unless answer_params[:answer_array].nil?
+      answer[:answer] = answer_params[:answer_hash].to_h unless answer_params[:answer_hash].nil?
+      answer
+    end
+
+    def prep_answer qid = answer_params[:question_id]
+      answer_ids = { question_id: qid, candidate_id: current_candidate.to_i }
+      answer = Answer.find_or_create_by(answer_ids)
+      answer
+    end
+
+    def route_answer_html
+      if @answer.errors.present?
+        prep_question answer_params[:question_id]
+        flash[:error] = answer_params[:question_id].to_i
+        render :question
+      else
+        redirect_to :saved and return if params.key?(:save)
+        redirect_to :question
+      end
+    end
+
+    def route_answer_xhr
+      if @answer.errors.present?
+        render json: @answer.errors["answer"].to_json, status: 400
+      else
+        results = {
+          message: "Your answer has been updated successfully!",
+          can_submit: prep_status.can_submit,
+          progress: prep_status.progress
+        }
+        render json: results.to_json
+      end
+    end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index de6be79..11f160f 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,2 +1,47 @@
+# frozen_string_literal: true
 module ApplicationHelper
+  def experience_options val
+    options_for_select([
+                         ["Please select", ""],
+                         ["0-3 Years", "0-3"],
+                         ["4-6 Years", "4-6"],
+                         ["7-9 Years", "7-9"],
+                         ["10-14 Years", "10-14"],
+                         ["15+ Years", "15+"]
+                       ], disabled: "-", selected: (val.blank? ? '' : val))
+  end
+
+  def quiz_options quizzes, selected_val
+    options_from_collection_for_select(quizzes, 'id', 'name', selected_val)
+  end
+
+  def admin_role_options val
+    options_for_select([
+                         %w(Reviewer reviewer),
+                         %w(Recruiter recruiter),
+                         %w(Manager manager),
+                         %w(Admin admin)
+                       ], disabled: "-", selected: (val.blank? ? '' : val))
+  end
+
+  def question_type_options val
+    options_for_select([
+                         %w(Text text),
+                         %w(Radio radio),
+                         ['Radio with other', 'radio_other'],
+                         %w(Checkbox checkbox),
+                         ['Checkbox with other', 'checkbox_other'],
+                         %w(Coder live_code)
+                       ], selected: (val.blank? ? '' : val))
+  end
+
+  # include javascript only once
+  # Allows the safe loading of js dependencies in partials multiple times.
+  def content_for_javascript_once code_label, &block
+    @js_blocks ||= []
+    return if @js_blocks.include? code_label
+
+    @js_blocks << code_label
+    content_for :custom_javascipt, &block
+  end
 end
diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb
index a009ace..32fe70b 100644
--- a/app/jobs/application_job.rb
+++ b/app/jobs/application_job.rb
@@ -1,2 +1,3 @@
+# frozen_string_literal: true
 class ApplicationJob < ActiveJob::Base
 end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index 286b223..ec06647 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -1,4 +1,20 @@
+# frozen_string_literal: true
 class ApplicationMailer < ActionMailer::Base
-  default from: 'from@example.com'
+  default from: ENV['default_mail_from']
   layout 'mailer'
+
+  before_action :inline_layout_images
+
+  private
+
+    def inline_layout_images
+      # inline images requested in default mailer layout
+      attachments.inline['perficientdigital-logo.jpg'] = image_file('perficientdigital-logo.jpg')
+      attachments.inline['yellowslant-left.jpg'] = image_file('yellowslant-left.jpg')
+      attachments.inline['yellowslant-right.jpg'] = image_file('yellowslant-right.jpg')
+    end
+
+    def image_file image
+      File.read(Rails.root.to_s + "/app/assets/images/#{image}")
+    end
 end
diff --git a/app/mailers/candidate_mailer.rb b/app/mailers/candidate_mailer.rb
new file mode 100644
index 0000000..f3611bc
--- /dev/null
+++ b/app/mailers/candidate_mailer.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+class CandidateMailer < ApplicationMailer
+  def welcome candidate
+    @candidate = candidate
+
+    mail to: @candidate.email, subject: "Perficient Digital - Skills Assessment Test"
+  end
+
+  def reminder candidate
+    @candidate = candidate
+
+    mail to: @candidate.email, subject: "Perficient Digital - Skills Assessment Test"
+  end
+
+  def submitted candidate
+    @candidate = candidate
+
+    mail to: @candidate.email, subject: "Perficient Digital - Skills Assessment Test"
+  end
+end
diff --git a/app/mailers/recruiter_mailer.rb b/app/mailers/recruiter_mailer.rb
new file mode 100644
index 0000000..8ef985c
--- /dev/null
+++ b/app/mailers/recruiter_mailer.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+class RecruiterMailer < ApplicationMailer
+  def candidate_created candidate
+    @candidate = candidate
+
+    mail to: @candidate.recruiter.email, subject: "Skills Assessment Test - #{candidate.name}"
+  end
+
+  def candidate_submitted candidate
+    @candidate = candidate
+
+    mail to: @candidate.recruiter.email, subject: "Skills Assessment Test - #{candidate.name}"
+  end
+end
diff --git a/app/mailers/reviewer_mailer.rb b/app/mailers/reviewer_mailer.rb
new file mode 100644
index 0000000..0f06024
--- /dev/null
+++ b/app/mailers/reviewer_mailer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+class ReviewerMailer < ApplicationMailer
+  def candidate_submission candidate
+    @candidate = candidate
+    recipients = candidate.quiz.reviewers.map(&:email)
+
+    mail to: recipients, subject: "Skills Assessment Results"
+  end
+end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
new file mode 100644
index 0000000..b345d1b
--- /dev/null
+++ b/app/mailers/user_mailer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+class UserMailer < ApplicationMailer
+  def password_reset user
+    @user = user
+    mail to: user.email, subject: 'Password Reset'
+  end
+
+  def welcome user, default_password
+    @user = user
+    @password = default_password
+    mail to: user.email, subject: "Skill Assesment Acount"
+  end
+end
diff --git a/app/models/answer.rb b/app/models/answer.rb
index 1d6ebf2..6983ff4 100644
--- a/app/models/answer.rb
+++ b/app/models/answer.rb
@@ -1,6 +1,19 @@
+# frozen_string_literal: true
 class Answer < ApplicationRecord
   serialize :answer
 
   belongs_to :question
   belongs_to :candidate
+
+  validates :candidate_id, presence: true
+  validates :question_id, presence: true
+  validates :answer, answer_format: true
+
+  # Throw away attributes
+  #   convenience for form manipulation
+  def answer_array=(val); end
+
+  def answer_hash=(val); end
+
+  def answer_id=(val); end
 end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 141a300..736c87f 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -1,3 +1,4 @@
+# frozen_string_literal: true
 class ApplicationRecord < ActiveRecord::Base
   self.abstract_class = true
 
diff --git a/app/models/candidate.rb b/app/models/candidate.rb
index e2850f6..a216c37 100644
--- a/app/models/candidate.rb
+++ b/app/models/candidate.rb
@@ -1,14 +1,19 @@
+# frozen_string_literal: true
 class Candidate < ApplicationRecord
   belongs_to :quiz
-  has_many :questions, through: :quiz
+  has_many :questions, -> { order("sort") }, through: :quiz
   has_many :answers
   belongs_to :recruiter, class_name: "User"
 
-  before_create :generate_test_hash
+  serialize :email, CryptSerializer
 
-  validates_presence_of :recruiter_id
-  validates_presence_of :test_hash
-  validates_uniqueness_of :test_hash
+  before_validation(:generate_test_hash, on: :create)
+
+  validates :recruiter_id, presence: true
+  validates :name, presence: true
+  validates :experience, presence: true
+  validates :email, uniqueness: true, presence: true, email_format: true
+  validates :test_hash, uniqueness: true, presence: true
 
   def submitted_answers
     answers.where(submitted: true)
@@ -16,6 +21,23 @@ class Candidate < ApplicationRecord
 
   def answered_questions
     answers.where.not(answer: nil)
+           .where("answers.answer not like '%later:%'")
+  end
+
+  def fetch_question qid
+    CandidateQuiz.new(id).fetch_question(qid)
+  end
+
+  def my_quiz
+    @candidate_quiz ||= CandidateQuiz.new(id).build_my_quiz
+  end
+
+  def my_status
+    @candidate_status ||= QuizStatus.new(self)
+  end
+
+  def status
+    "#{my_status.progress}%"
   end
 
   private
diff --git a/app/models/question.rb b/app/models/question.rb
index 3abd437..6d2a993 100644
--- a/app/models/question.rb
+++ b/app/models/question.rb
@@ -1,6 +1,27 @@
+# frozen_string_literal: true
 class Question < ApplicationRecord
-  serialize :input_options, Array
+  serialize :input_options
 
   has_many :answers
   belongs_to :quiz
+
+  after_initialize :prepare_input_options
+  before_validation :compact_input_options
+
+  validates :quiz_id, presence: true
+  validates :question, presence: true
+  validates :category, presence: true
+  validates :input_type, presence: true
+  validates :input_options, input_options_presence: true
+
+  private
+
+    def compact_input_options
+      self.input_options = input_options.reject { |_k, v| v.blank? } if input_options.class == Hash
+      self.input_options = input_options.reject(&:blank?) if input_options.class == Array
+    end
+
+    def prepare_input_options
+      self.input_options = input_options || {}
+    end
 end
diff --git a/app/models/quiz.rb b/app/models/quiz.rb
index e1195de..c1ed37b 100644
--- a/app/models/quiz.rb
+++ b/app/models/quiz.rb
@@ -1,4 +1,11 @@
+# frozen_string_literal: true
 class Quiz < ApplicationRecord
-  has_many :questions
+  has_many :questions, -> { order(:sort) }
   has_many :candidates
+  has_many :reviewer_to_quizzes
+  has_many :reviewers, through: :reviewer_to_quizzes, source: :user
+
+  validates :name, presence: true, uniqueness: true
+  validates :dept, presence: true
+  validates :unit, presence: true
 end
diff --git a/app/models/reviewer_to_quiz.rb b/app/models/reviewer_to_quiz.rb
new file mode 100644
index 0000000..27634ad
--- /dev/null
+++ b/app/models/reviewer_to_quiz.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+class ReviewerToQuiz < ApplicationRecord
+  belongs_to :user
+  belongs_to :quiz
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index e1b1ee7..35ed228 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,4 +1,60 @@
+# frozen_string_literal: true
 class User < ApplicationRecord
   has_secure_password
-  has_many :candidates, foreign_key: "recruiter_id"
+  has_many :candidates, foreign_key: :recruiter_id
+  has_many :reviewer_to_quizzes
+  has_many :quizzes, through: :reviewer_to_quizzes
+
+  validates :email, presence: true, uniqueness: true
+  validates :name, presence: true
+  validates :role, presence: true
+  validates :reset_token, uniqueness: true, allow_nil: true
+
+  def setup_reset
+    gen_reset_token
+    save
+  end
+
+  # Roles
+  def admin?
+    'admin' == role
+  end
+
+  def acts_as_admin?
+    'admin' == role
+  end
+
+  def manager?
+    'manager' == role
+  end
+
+  def acts_as_manager?
+    %w(admin manager).include? role
+  end
+
+  def recruiter?
+    'recruiter' == role
+  end
+
+  def acts_as_recruiter?
+    %w(admin recruiter).include? role
+  end
+
+  def reviewer?
+    'reviewer' == role
+  end
+
+  def acts_as_reviewer?
+    %w(admin reviewer).include? role
+  end
+
+  private
+
+    def gen_reset_token
+      loop do
+        self[:reset_token] = SecureRandom.urlsafe_base64(10)
+        self[:reset_timestamp] = DateTime.current
+        break unless User.exists?(reset_token: self[:reset_token])
+      end
+    end
 end
diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb
new file mode 100644
index 0000000..5e0c857
--- /dev/null
+++ b/app/policies/application_policy.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+class ApplicationPolicy
+  attr_reader :user, :record
+
+  def initialize(user, record)
+    raise Pundit::NotAuthorizedError, "Must be logged in." unless user
+    @user = user
+    @record = record
+  end
+
+  def index?
+    false
+  end
+
+  def show?
+    scope.where(id: record.id).exists?
+  end
+
+  def view?
+    show?
+  end
+
+  def create?
+    false
+  end
+
+  def new?
+    create?
+  end
+
+  def update?
+    false
+  end
+
+  def edit?
+    update?
+  end
+
+  def destroy?
+    false
+  end
+
+  def scope
+    Pundit.policy_scope!(user, record.class)
+  end
+
+  class Scope
+    attr_reader :user, :scope
+
+    def initialize(user, scope)
+      @user = user
+      @scope = scope
+    end
+
+    def resolve
+      # This is a closed system.
+      raise Pundit::NotAuthorizedError, "No access to resource."
+    end
+  end
+end
diff --git a/app/policies/candidate_policy.rb b/app/policies/candidate_policy.rb
new file mode 100644
index 0000000..4e6418f
--- /dev/null
+++ b/app/policies/candidate_policy.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+class CandidatePolicy < ApplicationPolicy
+  # Candidate Access Policy
+  #
+  # Only Recruiters and Admins can view, create, or update, candidates
+
+  def index?
+    user.acts_as_recruiter?
+  end
+
+  def view?
+    user.acts_as_recruiter?
+  end
+
+  def create?
+    user.acts_as_recruiter?
+  end
+
+  def update?
+    user.acts_as_recruiter?
+  end
+
+  def resend_welcome?
+    user.acts_as_recruiter?
+  end
+
+  class Scope < Scope
+    def resolve
+      return scope if user.acts_as_recruiter?
+      raise Pundit::NotAuthorizedError, "No Access to Resource"
+    end
+  end
+end
diff --git a/app/policies/dashboard_policy.rb b/app/policies/dashboard_policy.rb
new file mode 100644
index 0000000..1ba609e
--- /dev/null
+++ b/app/policies/dashboard_policy.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+class DashboardPolicy < Struct.new(:user, :dashboard)
+  attr_reader :user, :record
+
+  def initialize(user, record)
+    raise Pundit::NotAuthorizedError, "Must be logged in." unless user
+    @user = user
+    @record = record
+  end
+
+  def show?
+    true
+  end
+end
diff --git a/app/policies/question_policy.rb b/app/policies/question_policy.rb
new file mode 100644
index 0000000..f578844
--- /dev/null
+++ b/app/policies/question_policy.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+class QuestionPolicy < ApplicationPolicy
+  # Question Access Policy
+  #
+  # Only Admins and Managers can create or update a quiz (and its questions)
+  # Reviewers can view any quiz they are linked to
+  # Recruiters can NOT list or view questions
+
+  def view?
+    return false if user.recruiter?
+    return true if user.acts_as_manager?
+    user.quizzes.include? record.quiz
+  end
+
+  def options?
+    view?
+  end
+
+  def create?
+    user.acts_as_manager?
+  end
+
+  def update?
+    user.acts_as_manager?
+  end
+
+  class Scope < Scope
+    def resolve
+      raise(Pundit::NotAuthorizedError, 'No Access to resource.') if user.recruiter?
+
+      if user.acts_as_manager?
+        scope
+      else
+        scope.where(quiz_id: user.quizzes.map(&:id))
+      end
+    end
+  end
+end
diff --git a/app/policies/quiz_policy.rb b/app/policies/quiz_policy.rb
new file mode 100644
index 0000000..6f80113
--- /dev/null
+++ b/app/policies/quiz_policy.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+class QuizPolicy < ApplicationPolicy
+  # Quiz Access Policy
+  #
+  # Only Admins and Managers can create or update a quiz (and its questions)
+  # Reviewers can view any quiz they are linked to
+  # Recruiters can only list quiz names (for candidate assignments)
+
+  def index?
+    true
+  end
+
+  def view?
+    return true if user.acts_as_manager?
+    user.quizzes.include? record
+  end
+
+  def create?
+    user.acts_as_manager?
+  end
+
+  def update?
+    user.acts_as_manager?
+  end
+
+  class Scope < Scope
+    def resolve
+      if user.reviewer?
+        scope.joins(:reviewers).where('reviewer_to_quizzes.user_id = ?', user.id)
+      else
+        scope
+      end
+    end
+  end
+end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
new file mode 100644
index 0000000..00a81f5
--- /dev/null
+++ b/app/policies/user_policy.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+class UserPolicy < ApplicationPolicy
+  # User Access Policy
+  #
+  # Only Admins can view, create, or update, users
+  # All other users can only access themselves (profile interface)
+
+  def index?
+    user.acts_as_admin?
+  end
+
+  def view?
+    user.acts_as_admin? || user == record
+  end
+
+  def create?
+    user.acts_as_admin?
+  end
+
+  def update?
+    user.acts_as_admin? || user == record
+  end
+
+  def permitted_attributes
+    return [:name, :email, :role, :password, quiz_ids: []] if user.acts_as_admin?
+    [:name, :email, :password, :password_confirmation]
+  end
+
+  class Scope < Scope
+    def resolve
+      return scope if user.acts_as_admin?
+      scope.where(id: user.id)
+    end
+  end
+end
diff --git a/app/services/crypt_serializer.rb b/app/services/crypt_serializer.rb
new file mode 100644
index 0000000..07ff30e
--- /dev/null
+++ b/app/services/crypt_serializer.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+require 'openssl'
+require 'base64'
+
+class CryptSerializer
+  attr_reader :cipher
+
+  class << self
+    # pulling from DB - return plain value
+    def load value
+      new.decrypt value
+    end
+
+    # saving to DB - return encrypted value
+    def dump value
+      new.encrypt value
+    end
+  end
+
+  def initialize
+    @cipher = OpenSSL::Cipher::AES.new(128, :CBC)
+  end
+
+  def encrypt(value)
+    unless value.is_a?(String)
+      raise "Attribute was supposed to be a `String`, but was instead a `#{value.class}`"
+    end
+
+    return nil if value.blank?
+
+    cipher.encrypt
+    parts = [cipher.random_key, cipher.random_iv, cipher.update(value) + cipher.final]
+
+    Base64.urlsafe_encode64 Marshal.dump(parts)
+  end
+
+  def decrypt(value)
+    return value if value.nil?
+
+    parts = Marshal.load Base64.urlsafe_decode64(value)
+    cipher.decrypt
+    cipher.key = parts[0]
+    cipher.iv = parts[1]
+
+    cipher.update(parts[2]) + cipher.final
+  end
+end
diff --git a/app/services/skill_config.rb b/app/services/skill_config.rb
new file mode 100644
index 0000000..f4d5075
--- /dev/null
+++ b/app/services/skill_config.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+class SkillConfig < Settingslogic
+  source "#{Rails.root}/config/application.yml"
+  namespace Rails.env
+  suppress_errors Rails.env.production?
+  load!
+end
diff --git a/app/validators/answer_format_validator.rb b/app/validators/answer_format_validator.rb
new file mode 100644
index 0000000..6994631
--- /dev/null
+++ b/app/validators/answer_format_validator.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+class AnswerFormatValidator < ActiveModel::EachValidator
+  def validate_each(record, attribute, value)
+    send(record.question.input_type, record, attribute, value)
+  end
+
+  private
+
+    def text record, attribute, value
+      clean_val = value.to_s.strip.delete("\r")
+      return if clean_val.length.between?(1, 1000)
+
+      if clean_val.blank?
+        record.errors[attribute] << (options[:message] || "Please enter an answer.")
+      end
+
+      if clean_val.length > 1000
+        record.errors[attribute] << (options[:message] || "The character limit for this answer is 1000.")
+      end
+    end
+
+    def radio record, attribute, value
+      return unless value.to_s.strip.blank?
+
+      record.errors[attribute] << (options[:message] || "Please select an answer.")
+    end
+
+    def checkbox record, attribute, value
+      return unless value.nil? || Array(value).join.blank?
+
+      record.errors[attribute] << (options[:message] || "Please select an answer.")
+    end
+
+    def live_code record, attribute, value
+      seed = (Question.find_by(id: record.question_id) || Question.new).input_options
+
+      return unless value.nil? || value.values.join.blank? || !match_seed?(value, seed)
+
+      record.errors[attribute] << (options[:message] || live_code_error_message(value))
+    end
+
+    def with_other record, attribute, value
+      return if value.present? && with_other_check(value)
+
+      record.errors[attribute] << (options[:message] || "Please select or provide an answer.")
+    end
+    alias radio_other with_other
+    alias checkbox_other with_other
+
+    #################################
+
+    def with_other_check value
+      return false unless value.respond_to? :keys
+      return false if Array(value[:options]).join.blank?
+      return false if value[:options].include?('other') && value[:other].to_s.blank?
+      true
+    end
+
+    def live_code_error_message value
+      if value.present? && value.keys.count == 1
+        return "Please check that you will come back to complete the code example."
+      end
+      "You must write comments or code in one of the textareas to progress."
+    end
+
+    def match_seed? value, seed
+      s_value = value.stringify_keys
+      s_seed = seed.stringify_keys
+      keys = s_value.merge(s_seed).keys.uniq
+
+      matches = keys.inject([]) do |memo, k|
+        memo << (s_value[k].to_s.strip == s_seed[k].to_s.strip)
+      end
+
+      matches.include? false
+    end
+end
diff --git a/app/validators/email_format_validator.rb b/app/validators/email_format_validator.rb
new file mode 100644
index 0000000..5b889a5
--- /dev/null
+++ b/app/validators/email_format_validator.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+class EmailFormatValidator < ActiveModel::EachValidator
+  def validate_each(record, attribute, value)
+    # EMAIL regex test
+    # (comma seperated) [any word combo] AT [any word combo] DOT [2 or more]
+    # me@no.yes.x == invalid
+    # some.thing+two@sub.domain.name == valid
+
+    results = value.to_s.split(',').map do |v|
+      (v.strip =~ /^([\w\.%\+\-]+)@([\w\-]+\.)+([\w]{2,})$/i) || v.strip.blank?
+    end
+
+    record.errors[attribute] << (options[:message] || "is not formatted properly") if results.include?(false)
+  end
+end
diff --git a/app/validators/input_options_presence_validator.rb b/app/validators/input_options_presence_validator.rb
new file mode 100644
index 0000000..0b2e89a
--- /dev/null
+++ b/app/validators/input_options_presence_validator.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+class InputOptionsPresenceValidator < ActiveModel::EachValidator
+  def validate_each(record, attribute, value)
+    return true unless record.input_type =~ /radio|check/i
+    return true if value.present? && value.count.positive?
+
+    record.errors[attribute] << (options[:message] ||
+                                "You must provide answer options for the selected input type.")
+
+    false
+  end
+end
diff --git a/app/views/admin/_nav.html.erb b/app/views/admin/_nav.html.erb
new file mode 100644
index 0000000..e81cb18
--- /dev/null
+++ b/app/views/admin/_nav.html.erb
@@ -0,0 +1,9 @@
+
diff --git a/app/views/admin/auth/login.html.erb b/app/views/admin/auth/login.html.erb
new file mode 100644
index 0000000..725a817
--- /dev/null
+++ b/app/views/admin/auth/login.html.erb
@@ -0,0 +1,19 @@
+<%
+  content_for :main_class, "intro_tpl"
+%>
+
+

Admin Login

+ +<%= form_for :auth, url: admin_login_path do |form| %> +
+ <%= form.label :email %> + <%= form.email_field :email %> +
+ +
+ <%= form.label :password %> + <%= form.password_field :password %> +
+ + <%= submit_tag "Log in" %> +<% end %> diff --git a/app/views/admin/auth/reset.html.erb b/app/views/admin/auth/reset.html.erb new file mode 100644 index 0000000..2b1aee8 --- /dev/null +++ b/app/views/admin/auth/reset.html.erb @@ -0,0 +1,21 @@ +<% + content_for :main_class, "intro_tpl" +%> + +

Password Reset

+ +<%= form_for :auth, url: admin_reset_password_path do |form| %> + <%= hidden_field_tag :reset_token, params[:reset_token] %> + +
+ <%= form.label :password %> + <%= form.password_field :password %> +
+ +
+ <%= form.label :password_confirmation %> + <%= form.password_field :password_confirmation %> +
+ + <%= submit_tag "Reset Password" %> +<% end %> diff --git a/app/views/admin/auth/reset_request.html.erb b/app/views/admin/auth/reset_request.html.erb new file mode 100644 index 0000000..bb23fb8 --- /dev/null +++ b/app/views/admin/auth/reset_request.html.erb @@ -0,0 +1,14 @@ +<% + content_for :main_class, "intro_tpl" +%> + +

Password Reset

+<%= form_for :auth, url: admin_send_reset_path do |form| %> + +
+ <%= form.label :email %> + <%= form.email_field :email %> +
+ + <%= submit_tag "Request Password Reset" %> +<% end %> diff --git a/app/views/admin/candidate/_form.html.erb b/app/views/admin/candidate/_form.html.erb new file mode 100644 index 0000000..0310471 --- /dev/null +++ b/app/views/admin/candidate/_form.html.erb @@ -0,0 +1,25 @@ +<%= render partial: 'shared/form_model_errors', locals: { obj: candidate } %> + +<%= form_for candidate, url: action, method: :post do |form| %> +
+ <%= form.label :name, "Candidate name" %> + <%= form.text_field :name %> +
+ +
+ <%= form.label :email, "Candidate email" %> + <%= form.email_field :email %> +
+ +
+ <%= form.label :experience, "Years of experience" %> + <%= form.select :experience, experience_options(candidate.experience), include_blank: false %> +
+ +
+ <%= form.label :quiz_id, "Quiz" %> + <%= form.select :quiz_id, quiz_options(quizzes, candidate.quiz_id), include_blank: (quizzes.size > 1) %> +
+ + <%= submit_tag %> +<% end %> diff --git a/app/views/admin/candidate/edit.html.erb b/app/views/admin/candidate/edit.html.erb new file mode 100644 index 0000000..5de8973 --- /dev/null +++ b/app/views/admin/candidate/edit.html.erb @@ -0,0 +1,6 @@ +
+

Edit: <%= @candidate.name %>

+

Test ID: <%= @candidate.test_hash %>

+ + <%= render partial: 'form', locals: { action: admin_update_candidate_path(@candidate.id), candidate: @candidate, quizzes: @quizzes } %> +
diff --git a/app/views/admin/candidate/index.html.erb b/app/views/admin/candidate/index.html.erb new file mode 100644 index 0000000..0c29e2e --- /dev/null +++ b/app/views/admin/candidate/index.html.erb @@ -0,0 +1,36 @@ +<% + content_for :section_title, "Candidates" +%> +
+ <%= link_to(admin_new_candidate_path, { class: 'secondary-btn' }) do %> + + <% end if policy(User).create? %> + + + + + + + + + + + + + <% @candidates.each do |candidate| %> + + + + + + + + + + <% end %> +
CandidateTest IDEmailExperienceProgressCompletedReminded
<%= link_to candidate.name, admin_edit_candidate_path(candidate.id) %><%= candidate.test_hash %> + <%= mail_to(candidate.email) %> +
+ <%= link_to "resend welcome email", admin_resend_welcome_path(candidate.id), remote: true, class: '', data: { id: 'ajax-action' } %> +
<%= candidate.experience %> years<%= candidate.status %><%= candidate.completed ? "Submitted" : "" %><%= candidate.reminded ? "Yes" : "" %>
+
diff --git a/app/views/admin/candidate/new.html.erb b/app/views/admin/candidate/new.html.erb new file mode 100644 index 0000000..c529467 --- /dev/null +++ b/app/views/admin/candidate/new.html.erb @@ -0,0 +1,6 @@ +
+

New Candidate

+ + <%= render partial: 'form', locals: + { action: admin_create_candidate_path, candidate: @candidate, quizzes: @quizzes } %> +
diff --git a/app/views/admin/profile/edit.html.erb b/app/views/admin/profile/edit.html.erb new file mode 100644 index 0000000..d1014d2 --- /dev/null +++ b/app/views/admin/profile/edit.html.erb @@ -0,0 +1,28 @@ +<% + content_for :section_title, "Edit: #{@user.name}" +%> + +<%= render partial: 'shared/form_model_errors', locals: {obj: @user} %> +<%= form_for @user, url: admin_profile_url, method: :post do |form| %> +
+ <%= form.label :name, "Full Name" %> + <%= form.text_field :name %> +
+ +
+ <%= form.label :email, "eMail" %> + <%= form.email_field :email %> +
+ +
+ <%= form.label :password, "New Password" %> + <%= form.password_field :password %> +
+ +
+ <%= form.label :password_confirmation, "New Password Confirmation" %> + <%= form.password_field :password_confirmation %> +
+ + <%= form.submit %> +<% end %> diff --git a/app/views/admin/profile/view.html.erb b/app/views/admin/profile/view.html.erb new file mode 100644 index 0000000..1c1af5f --- /dev/null +++ b/app/views/admin/profile/view.html.erb @@ -0,0 +1,8 @@ +<% + content_for :section_title, "Profile" +%> + +

Name: <%= current_user.name %>

+

email: <%= current_user.email %>

+

Role: <%= current_user.role %>

+<%= link_to('Edit', admin_edit_profile_path, { class: 'btn' }) %> diff --git a/app/views/admin/question/_checkbox.html.erb b/app/views/admin/question/_checkbox.html.erb new file mode 100644 index 0000000..2bba6bb --- /dev/null +++ b/app/views/admin/question/_checkbox.html.erb @@ -0,0 +1,18 @@ +Checkbox Options + +
    + <% question.input_options.each do | option | %> +
  • + <%= text_field_tag 'question[multi_choice][]', option, { disabled: (disable ||= false), data: { last: option } } %> +
  • + <% end %> +
+ +<% unless (disable ||= false) %> +
+
Add option
+
  • + <%= text_field_tag 'question[multi_choice][]', nil, { disabled: (disable ||= false), data: { last: nil } } %> +
  • +
    +<% end %> diff --git a/app/views/admin/question/_checkbox_other.html.erb b/app/views/admin/question/_checkbox_other.html.erb new file mode 100644 index 0000000..0a63171 --- /dev/null +++ b/app/views/admin/question/_checkbox_other.html.erb @@ -0,0 +1,21 @@ +Checkbox Options + +
      + <% question.input_options.each do | option | %> +
    • + <%= text_field_tag 'question[multi_choice][]', option, { disabled: (disable ||= false), data: { last: option } } %> +
    • + <% end %> +
    +
      +
    • Other:
    • +
    + +<% unless (disable ||= false) %> +
    +
    Add option
    +
  • + <%= text_field_tag 'question[multi_choice][]', nil, { disabled: (disable ||= false), data: { last: nil } } %> +
  • +
    +<% end %> diff --git a/app/views/admin/question/_form.html.erb b/app/views/admin/question/_form.html.erb new file mode 100644 index 0000000..ea1275a --- /dev/null +++ b/app/views/admin/question/_form.html.erb @@ -0,0 +1,50 @@ +<%= render partial: 'shared/form_model_errors', locals: { obj: question} %> +<%= form_for question, url: action do |form| %> +
    + <%= form.label :quiz_id, 'Quiz' %> + <%= form.select :quiz_id, options_for_select(@quizzes.map{ |q| [q.name, q.id] }, question.quiz_id), include_blank: (@quizzes.size > 1) %> +
    + +
    + <%= form.label :category, 'Category' %> + <%= form.text_field :category %> +
    + +
    + <%= form.label :sort, 'Sort' %> + <%= form.text_field :sort %> +
    + +
    + <%= form.check_box :active %> + <%= form.label :active, 'Active' %> +
    + +
    + <%= form.label :question, "Question" %> + <%= form.text_area :question %> +
    + +
    + <%= form.label :attachment, "Attachment URL" %> + <%= form.text_field :attachment %> + + <% if @question.attachment.present? %> +

    <%= image_tag @question.attachment %>

    + <% end %> +
    + +
    + <%= form.label :input_type, 'Input Type' %> + <%= form.select :input_type, question_type_options(question.input_type), { include_blank: false }, { data: { qid: question.id } } %> +
    + +
    + <%= fields_for @question do |fields| %> + <% partial = question.input_type.blank? ? 'admin/question/text' : "admin/question/#{question.input_type}" %> + <%= render partial: partial, locals: {question: question, fields: fields } %> + <% end %> +
    + + <%= form.submit %> +<% end %> diff --git a/app/views/admin/question/_live_code.html.erb b/app/views/admin/question/_live_code.html.erb new file mode 100644 index 0000000..4efc955 --- /dev/null +++ b/app/views/admin/question/_live_code.html.erb @@ -0,0 +1,24 @@ +<% + content_for_javascript_once 'live-coder' do + javascript_include_tag "live-coder" + end +%> + +
    +
    + + <%= text_area_tag 'question[live_code][html]', question.input_options['html'], { data: {id: 'code-html', last: question.input_options['html']}, class: 'code-answer code-html' } %> +
    + +
    + + <%= text_area_tag 'question[live_code][css]', question.input_options['css'], { data: {id: 'code-css', last: question.input_options['css']}, class: 'code-answer code-css' } %> +
    + +
    + + <%= text_area_tag 'question[live_code][js]', question.input_options['js'], { data: {id: 'code-js', last: question.input_options['js']}, class: 'code-answer code-js' } %> +
    + +
    +
    diff --git a/app/views/admin/question/_radio.html.erb b/app/views/admin/question/_radio.html.erb new file mode 100644 index 0000000..b918fc7 --- /dev/null +++ b/app/views/admin/question/_radio.html.erb @@ -0,0 +1,18 @@ +Radio Options + +
      + <% question.input_options.each do | option | %> +
    • + <%= text_field_tag 'question[multi_choice][]', option, { disabled: (disable ||= false), data: { last: option } } %> +
    • + <% end %> +
    + +<% unless (disable ||= false) %> +
    +
    Add option
    +
  • + <%= text_field_tag 'question[multi_choice][]', nil, { disabled: (disable ||= false), data: { last: nil } } %> +
  • +
    +<% end %> diff --git a/app/views/admin/question/_radio_other.html.erb b/app/views/admin/question/_radio_other.html.erb new file mode 100644 index 0000000..5c2f7e3 --- /dev/null +++ b/app/views/admin/question/_radio_other.html.erb @@ -0,0 +1,21 @@ +Radio Options + +
      + <% question.input_options.each do | option | %> +
    • + <%= text_field_tag 'question[multi_choice][]', option, { disabled: (disable ||= false), data: { last: option } } %> +
    • + <% end %> +
    +
      +
    • Other:
    • +
    + +<% unless (disable ||= false) %> +
    +
    Add option
    +
  • + <%= text_field_tag 'question[multi_choice][]', nil, { disabled: (disable ||= false), data: { last: nil } } %> +
  • +
    +<% end %> diff --git a/app/views/admin/question/_table_list.html.erb b/app/views/admin/question/_table_list.html.erb new file mode 100644 index 0000000..1ce0359 --- /dev/null +++ b/app/views/admin/question/_table_list.html.erb @@ -0,0 +1,21 @@ + + + + + + + + + + + <% questions.each do |question| %> + + + + + + + + + <% end %> +
    SortQuestionTypeCategoryActive
    <%= question.sort %><%= question.question %><%= question.input_type %><%= question.category %><%= question.active unless question.active? %><%= link_to 'Edit', admin_edit_question_path(question.to_i), { class: 'btn tertiary-btn' } %>
    diff --git a/app/models/concerns/.keep b/app/views/admin/question/_text.html.erb similarity index 100% rename from app/models/concerns/.keep rename to app/views/admin/question/_text.html.erb diff --git a/app/views/admin/question/edit.html.erb b/app/views/admin/question/edit.html.erb new file mode 100644 index 0000000..6428daa --- /dev/null +++ b/app/views/admin/question/edit.html.erb @@ -0,0 +1,6 @@ +<% + content_for :section_title, "Questions" +%> + +

    <%= @question.quiz.name %>

    +<%= render partial: 'form', locals: {question: @question, action: admin_update_question_path } %> diff --git a/app/views/admin/question/index.html.erb b/app/views/admin/question/index.html.erb new file mode 100644 index 0000000..f6f17b0 --- /dev/null +++ b/app/views/admin/question/index.html.erb @@ -0,0 +1,10 @@ +<% + content_for :section_title, "Questions" +%> + +<% quizzes = @questions.group_by{ |q| q.quiz.name } %> +<% quizzes.each do |quiz, questions| %> +

    <%= quiz %>

    + <%= render partial: 'admin/question/table_list', locals: { questions: questions } %> + <%= link_to('Edit Quiz', admin_quiz_path(questions.first.quiz.to_i), { class: 'btn' }) %> +<% end %> diff --git a/app/views/admin/question/new.html.erb b/app/views/admin/question/new.html.erb new file mode 100644 index 0000000..351b388 --- /dev/null +++ b/app/views/admin/question/new.html.erb @@ -0,0 +1,5 @@ +<% + content_for :section_title, "New Question" +%> + +<%= render partial: 'form', locals: {question: @question, action: admin_create_question_path } %> diff --git a/app/views/admin/question/options.html.erb b/app/views/admin/question/options.html.erb new file mode 100644 index 0000000..352995a --- /dev/null +++ b/app/views/admin/question/options.html.erb @@ -0,0 +1,5 @@ +<% + if lookup_context.exists?(params[:input_type], 'admin/question', true) + %><%= render partial: "admin/question/#{params[:input_type]}", locals: { question: @question } %><% + end +%> diff --git a/app/views/admin/question/view.html.erb b/app/views/admin/question/view.html.erb new file mode 100644 index 0000000..fc72e6c --- /dev/null +++ b/app/views/admin/question/view.html.erb @@ -0,0 +1,40 @@ +<% + content_for :section_title, "Question for #{@question.quiz.name}" +%> + + + + + + + + + + + + + + + + + + +
    Category<%= @question.category %>
    Type<%= @question.input_type %>
    Sort<%= @question.sort %>
    + <%= check_box_tag 'question_active', nil, @question.active?, {disabled: true} %> + <%= label_tag 'question_active', 'Active' %> +
    + +Question +

    <%= @question.question %>

    + +<% if @question.attachment.present? %> +

    <%= image_tag @question.attachment %>

    +<% end %> + +<%= fields_for @question do |fields| %> + <%= render partial: "admin/question/#{@question.input_type}", locals: {question: @question, disable: true, fields: fields } %> +<% end %> + +<%= link_to('Edit', admin_edit_question_path(@question.to_i), { class: 'btn' }) %> + +<%= link_to('View Quiz', admin_quiz_path(@question.quiz_id), { class: 'btn' }) %> diff --git a/app/views/admin/quiz/_form.html.erb b/app/views/admin/quiz/_form.html.erb new file mode 100644 index 0000000..5815161 --- /dev/null +++ b/app/views/admin/quiz/_form.html.erb @@ -0,0 +1,19 @@ +<%= render partial: 'shared/form_model_errors', locals: { obj: quiz} %> +<%= form_for quiz, url: action do |form| %> +
    + <%= form.label :name, "Quiz Description" %> + <%= form.text_field :name %> +
    + +
    + <%= form.label :dept, 'Department' %> + <%= form.text_field :dept %> +
    + +
    + <%= form.label :unit, 'Unit' %> + <%= form.text_field :unit %> +
    + + <%= form.submit %> +<% end %> diff --git a/app/views/admin/quiz/_table_list.html.erb b/app/views/admin/quiz/_table_list.html.erb new file mode 100644 index 0000000..8ba51bc --- /dev/null +++ b/app/views/admin/quiz/_table_list.html.erb @@ -0,0 +1,19 @@ + + + + + + + + + + <% quizzes.each do |quiz| %> + + + + + + + + <% end %> +
    NameDeptUnitQuestions
    <%= link_to quiz.name, admin_quiz_path(quiz.to_i) %><%= quiz.dept %><%= quiz.unit %><%= quiz.questions.count %><%= link_to 'edit', admin_edit_quiz_path(quiz.to_i), { class: 'btn tertiary-btn' } if policy(quiz).edit? %>
    diff --git a/app/views/admin/quiz/edit.html.erb b/app/views/admin/quiz/edit.html.erb new file mode 100644 index 0000000..bf23b0e --- /dev/null +++ b/app/views/admin/quiz/edit.html.erb @@ -0,0 +1,5 @@ +<% + content_for :section_title, "Edit: #{@quiz.name}" +%> + +<%= render partial: 'form', locals: { quiz: @quiz, action: admin_update_quiz_path } %> diff --git a/app/views/admin/quiz/index.html.erb b/app/views/admin/quiz/index.html.erb new file mode 100644 index 0000000..1bce4e8 --- /dev/null +++ b/app/views/admin/quiz/index.html.erb @@ -0,0 +1,6 @@ +<% + content_for :section_title, "Quizzes" +%> + +<%= render partial: 'admin/quiz/table_list', locals: { quizzes: @quizzes } %> +<%= link_to('New Quiz', admin_new_quiz_path, { class: 'btn' }) %> diff --git a/app/views/admin/quiz/new.html.erb b/app/views/admin/quiz/new.html.erb new file mode 100644 index 0000000..2bb5726 --- /dev/null +++ b/app/views/admin/quiz/new.html.erb @@ -0,0 +1,5 @@ +<% + content_for :section_title, "New Quiz" +%> + +<%= render partial: 'form', locals: { quiz: @quiz, action: admin_create_quiz_path } %> diff --git a/app/views/admin/quiz/view.html.erb b/app/views/admin/quiz/view.html.erb new file mode 100644 index 0000000..f37b865 --- /dev/null +++ b/app/views/admin/quiz/view.html.erb @@ -0,0 +1,11 @@ +<% + content_for :section_title, "#{@quiz.name}" +%> + +

    <%= @quiz.name %>

    +

    <%= @quiz.dept %>

    +

    <%= @quiz.unit %>

    +<%= link_to('Edit', admin_edit_quiz_path(@quiz.to_i), { class: 'btn' }) %> + +<%= render partial: 'admin/question/table_list', locals: { questions: @quiz.questions, disable: true } %> +<%= link_to('New Question', admin_new_question_path, { class: 'btn' }) %> diff --git a/app/views/admin/result/index.html.erb b/app/views/admin/result/index.html.erb new file mode 100644 index 0000000..3c1373c --- /dev/null +++ b/app/views/admin/result/index.html.erb @@ -0,0 +1,20 @@ +<% + content_for :section_title, "Completed Tests" +%> +
    + + + + + + + + <% @candidates.each do |candidate| %> + + + + + + <% end %> +
    Test IDExperienceRecruiter
    <%= link_to candidate.test_hash, admin_result_path(candidate.test_hash) %><%= candidate.experience %> years<%= mail_to(candidate.recruiter.email) %>
    +
    diff --git a/app/views/admin/result/view.html.erb b/app/views/admin/result/view.html.erb new file mode 100644 index 0000000..73a0f72 --- /dev/null +++ b/app/views/admin/result/view.html.erb @@ -0,0 +1,33 @@ +
    +

    Quiz Review

    +

    + Test ID: <%= @candidate.test_hash %>
    + Years of Experience: <%= @candidate.experience %>
    + Recruiter Email: <%= mail_to @candidate.recruiter.name, @candidate.recruiter.email %>
    +

    + + <% @quiz.each do |question| %> + <%= form_for(:answer, url: '#never-post', html:{id: 'summary-form'}) do |form| %> +
    +
    +
    +

    <%= question.question %>

    +
    +
    + +
    + <% if question.attachment.present? %> + <%= image_tag question.attachment %> + <% end %> +
    + <%= render partial: "quiz/#{question.input_type}", locals: {question: question, answer: question.answer, form: form} %> +
    +
    +
    + <% end #form_tag %> + <% end #questions loop %> + + <%= link_to(admin_results_path, { class: 'secondary-btn' }) do %> + + <% end %> +
    diff --git a/app/views/admin/user/_form.html.erb b/app/views/admin/user/_form.html.erb new file mode 100644 index 0000000..c3b5266 --- /dev/null +++ b/app/views/admin/user/_form.html.erb @@ -0,0 +1,26 @@ +<%= render partial: 'shared/form_model_errors', locals: { obj: user} %> +<%= form_for user, url: action do |form| %> +
    + <%= form.label :name, "Full Name" %> + <%= form.text_field :name %> +
    + +
    + <%= form.label :email, "eMail" %> + <%= form.email_field :email %> +
    + +
    + <%= form.label :role, "Role" %> + <%= form.select :role, admin_role_options(user.role), include_blank: false %> +
    + + <%= form.collection_check_boxes(:quiz_ids, Quiz.all, :id, :name, {}, {class: 'checkbox'}) do | quiz | %> +
    + <%= quiz.check_box( checked: user.quizzes.include?(quiz.object)) %> + <%= quiz.label %> +
    + <% end %> + + <%= form.submit %> +<% end %> diff --git a/app/views/admin/user/_table_list.html.erb b/app/views/admin/user/_table_list.html.erb new file mode 100644 index 0000000..9b8ef8f --- /dev/null +++ b/app/views/admin/user/_table_list.html.erb @@ -0,0 +1,17 @@ + + + + + + + + + <% users.each do |user| %> + + + + + + + <% end %> +
    UserEmailRole
    <%= link_to user.name, admin_user_path(user.to_i) %><%= mail_to(user.email) %><%= user.role %><%= link_to 'edit', admin_edit_user_path(user.to_i), { class: 'btn tertiary-btn' } if policy(user).edit? %>
    diff --git a/app/views/admin/user/edit.html.erb b/app/views/admin/user/edit.html.erb new file mode 100644 index 0000000..262ff3c --- /dev/null +++ b/app/views/admin/user/edit.html.erb @@ -0,0 +1,5 @@ +<% + content_for :section_title, "Edit: #{@user.name}" +%> + +<%= render partial: 'form', locals: {user: @user, action: admin_update_user_path } %> diff --git a/app/views/admin/user/index.html.erb b/app/views/admin/user/index.html.erb new file mode 100644 index 0000000..303aa62 --- /dev/null +++ b/app/views/admin/user/index.html.erb @@ -0,0 +1,7 @@ +<% + content_for :section_title, "Users" +%> + +

    Users

    +<%= render partial: 'admin/user/table_list', locals: { users: @users } %> +<%= link_to('New User', admin_new_user_path, { class: 'btn' }) if policy(User).create? %> diff --git a/app/views/admin/user/new.html.erb b/app/views/admin/user/new.html.erb new file mode 100644 index 0000000..0958f6a --- /dev/null +++ b/app/views/admin/user/new.html.erb @@ -0,0 +1,5 @@ +<% + content_for :section_title, "New User" +%> + +<%= render partial: 'form', locals: {user: @user, action: admin_create_user_path } %> diff --git a/app/views/admin/user/view.html.erb b/app/views/admin/user/view.html.erb new file mode 100644 index 0000000..9c5ec88 --- /dev/null +++ b/app/views/admin/user/view.html.erb @@ -0,0 +1,16 @@ +<% + content_for :section_title, "#{@user.name}" +%> + +

    <%= @user.name %>

    +

    <%= mail_to(@user.email) %>

    +

    <%= @user.role %>

    + +

    Quizzes:

    +
      + <% @user.quizzes.each do |quiz| %> +
    • <%= quiz.name %>
    • + <% end %> +
    + +<%= link_to('Edit', admin_edit_user_path(@user.to_i), { class: 'btn' }) %> diff --git a/app/views/candidate/login.html.erb b/app/views/candidate/login.html.erb new file mode 100644 index 0000000..248a4fa --- /dev/null +++ b/app/views/candidate/login.html.erb @@ -0,0 +1,20 @@ +<% content_for :title, "Skills Assessment" %> + +
    +

    Let's Get Started

    +

    + Hey there! Give us your Test ID, and we'll get you started. +

    + + <%= form_tag(validate_candidate_path) do %> +
    + + + + <% if flash[:error].present? %> +
    <%= flash[:error] %>
    + <% end %> +
    + + <% end %> +
    diff --git a/app/views/candidate/oops.html.erb b/app/views/candidate/oops.html.erb new file mode 100644 index 0000000..81eda59 --- /dev/null +++ b/app/views/candidate/oops.html.erb @@ -0,0 +1,9 @@ +
    +

    Oops!

    +

    + Looks like you hit the browser's Back button. You can't go backwards in the test, + but you'll have a chance at the end to review your answers and make changes. +

    + + +
    diff --git a/app/views/candidate/saved.html.erb b/app/views/candidate/saved.html.erb new file mode 100644 index 0000000..bd7dda0 --- /dev/null +++ b/app/views/candidate/saved.html.erb @@ -0,0 +1,10 @@ +
    +

    + Your test results have been saved. You can visit again later with your Test ID to complete + the test starting from where you left off. +

    + + +
    diff --git a/app/views/candidate/thankyou.html.erb b/app/views/candidate/thankyou.html.erb new file mode 100644 index 0000000..b796e52 --- /dev/null +++ b/app/views/candidate/thankyou.html.erb @@ -0,0 +1,7 @@ +
    +

    Thank you!

    +

    + Your answers have been submitted. We will review and your recruiter + will be in touch. +

    +
    diff --git a/app/views/candidate/welcome.html.erb b/app/views/candidate/welcome.html.erb new file mode 100644 index 0000000..c98dda7 --- /dev/null +++ b/app/views/candidate/welcome.html.erb @@ -0,0 +1,26 @@ +<% content_for :title, "Skills Assessment" %> + +
    +

    Welcome!

    +

    + This is a skills assessment test. It's the first step in the process of interviewing + for a Front-End Development position with Perficient Digital. +

    +

    + The questions will test your knowledge in the areas of HTML, CSS, and JavaScript. + Please note that you can only move forward through the test, not back, + and you must attempt to answer the question before moving to the + next one. You'll have an opportunity at the end of the test to review and update your + answers if need be. At any time, you may save your progress and log back in to continue + taking the test from where you left off. +

    +

    + Please answer to the best of your ability—it's totally okay to say + "I don't know"!—and above all, relax and have fun! Once you submit your + answers, we will review your assessment and your recruiter will be in touch. +

    + +
    + <%= link_to "Begin", question_path, {class: 'btn'} %> +
    +
    diff --git a/app/views/candidate/welcome_back.html.erb b/app/views/candidate/welcome_back.html.erb new file mode 100644 index 0000000..c813b48 --- /dev/null +++ b/app/views/candidate/welcome_back.html.erb @@ -0,0 +1,16 @@ +<% content_for :title, "Skills Assessment" %> + +
    +

    Welcome Back

    + +

    Hello, <%= current_candidate.name %>!

    + +

    + We are ready to pick up where you left off. +

    + +
    + <%= link_to "Return to Test", question_path, { class: "btn primary-btn" } %> +
    + +
    diff --git a/app/views/candidate_mailer/reminder.html.inky b/app/views/candidate_mailer/reminder.html.inky new file mode 100644 index 0000000..b0f87e4 --- /dev/null +++ b/app/views/candidate_mailer/reminder.html.inky @@ -0,0 +1,18 @@ + + + diff --git a/app/views/candidate_mailer/reminder.text.erb b/app/views/candidate_mailer/reminder.text.erb new file mode 100644 index 0000000..3a2e08f --- /dev/null +++ b/app/views/candidate_mailer/reminder.text.erb @@ -0,0 +1,11 @@ +PERFICIENT/digital - Skills Assessment Test + +Thank you for taking the Skills Assessment Test. However, it looks like you have not submitted it +yet. If you are having trouble, please reach out to your recruiter: +<%= @candidate.recruiter.email %> + +You can return to the test here: +<%= root_url %>. + +Your Test ID is: +<%= @candidate.test_hash %> diff --git a/app/views/candidate_mailer/submitted.html.inky b/app/views/candidate_mailer/submitted.html.inky new file mode 100644 index 0000000..10d6e6d --- /dev/null +++ b/app/views/candidate_mailer/submitted.html.inky @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/app/views/candidate_mailer/submitted.text.erb b/app/views/candidate_mailer/submitted.text.erb new file mode 100644 index 0000000..571bde5 --- /dev/null +++ b/app/views/candidate_mailer/submitted.text.erb @@ -0,0 +1,5 @@ +PERFICIENT/digital - Skills Assessment Test + +Dear <%= @candidate.name %>, + +Thank you for taking the Skills Assessment Test. Your recruiter will be in touch. diff --git a/app/views/candidate_mailer/welcome.html.inky b/app/views/candidate_mailer/welcome.html.inky new file mode 100644 index 0000000..82a4191 --- /dev/null +++ b/app/views/candidate_mailer/welcome.html.inky @@ -0,0 +1,18 @@ + + + diff --git a/app/views/candidate_mailer/welcome.text.erb b/app/views/candidate_mailer/welcome.text.erb new file mode 100644 index 0000000..cb16c8c --- /dev/null +++ b/app/views/candidate_mailer/welcome.text.erb @@ -0,0 +1,11 @@ +PERFICIENT/digital + +Hello there! You have been invited to take our Skills Assessment Test. + +Please visit <%= login_url(@candidate.test_hash) %> to begin your test. + +Or, visit <%= root_url %> and enter your Test ID to begin your test. +Your Test ID is: <%= @candidate.test_hash %> + +Once we have evaluated your answers, your recruiter will be in touch. +Good luck! diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb new file mode 100644 index 0000000..dae9069 --- /dev/null +++ b/app/views/layouts/admin.html.erb @@ -0,0 +1,53 @@ + + + + + + <%= csrf_meta_tags %> + + <%= yield(:title) %> + + + <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> + + + + + + + +
    + +
    + <%= render partial: "admin/nav" if current_user %> +
    + <% if content_for?(:section_title) %> +
    <%= yield(:section_title) %>
    + <% else %> +
    Skills Assessment Admin
    + <% end %> +
    +
    + +
    "> + <%= render partial: "shared/generic_flash" %> + <%= yield %> +
    + +
    + +
    + + + +
    + + + <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> + <%= javascript_include_tag 'admin', 'data-turbolinks-track': 'reload' %> + <%= yield :custom_javascipt %> + + + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 71eab8a..dde0cf3 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,14 +1,59 @@ - - - SkillAssessmentApp + + + + + <%= csrf_meta_tags %> - <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> - <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> - + <%= yield(:title) %> + + <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> + + + + + + - <%= yield %> +
    + +
    > +
    + <% if content_for?(:category) %> +
    Section: <%= yield(:category) %>
    + <% else %> +
    Skills Assessment Test
    + <% end %> +
    +
    + + <%= yield %> + + <% if content_for?(:progress) %> +
    +
    + <%= yield :progress %>% +
    +
    + <% end %> + +
    + +
    + + + +
    + + + <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> + <%= yield :custom_javascipt %> + + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb deleted file mode 100644 index cbd34d2..0000000 --- a/app/views/layouts/mailer.html.erb +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - <%= yield %> - - diff --git a/app/views/layouts/mailer.html.inky b/app/views/layouts/mailer.html.inky new file mode 100644 index 0000000..12f03c3 --- /dev/null +++ b/app/views/layouts/mailer.html.inky @@ -0,0 +1,58 @@ + + + + + + <%= stylesheet_link_tag "mailers/foundation_vendor_manifest" %> + + + + + + + +
    +
    + +
    +
    + + diff --git a/app/views/quiz/_answer_errors.html.erb b/app/views/quiz/_answer_errors.html.erb new file mode 100644 index 0000000..2f338a7 --- /dev/null +++ b/app/views/quiz/_answer_errors.html.erb @@ -0,0 +1,5 @@ +<% if flash[:error] == question.question_id && answer.try(:errors) %> + <% answer.errors.messages[:answer].each do |message| %> +
    <%= message %>
    + <% end %> +<% end %> diff --git a/app/views/quiz/_checkbox.html.erb b/app/views/quiz/_checkbox.html.erb new file mode 100644 index 0000000..bda75be --- /dev/null +++ b/app/views/quiz/_checkbox.html.erb @@ -0,0 +1,15 @@ +<% answers = Array(question.answer) %> +<%= form.collection_check_boxes(:answer_array, question.input_options, :to_s, :to_s, {}, {class: 'checkbox'}) do | option | %> + <% + option_id = "#{question.question_id}#{sanitize_to_id(option.value)}" + checked = answers.include?(option.value) ? 'checked' : '' + %> + +
    + <%= option.check_box( id: option_id, checked: checked, data: { last: checked } ) %> + <%= option.label(for: option_id) %> +
    + +<% end %> + +<%= render partial: "quiz/answer_errors", locals: {question: question, answer: answer} %> diff --git a/app/views/quiz/_checkbox_other.html.erb b/app/views/quiz/_checkbox_other.html.erb new file mode 100644 index 0000000..04d11cf --- /dev/null +++ b/app/views/quiz/_checkbox_other.html.erb @@ -0,0 +1,27 @@ +<% + answers = question.answer.nil? ? [] : Array(question.answer['options']) + other_value = question.answer.nil? ? '' : question.answer['other'] + + question.input_options.each do | option | + option_id = "#{option.parameterize}_#{question.to_i}" + checkbox_html = {class: 'checkbox', id: option_id, data: { last: answers.include?(option) ? 'checked' : '' } } + %> +
    + <%= check_box_tag('answer[answer_hash][options][]', option, answers.include?(option), checkbox_html) %> + <%= label_tag(option_id, option) %> +
    + <% + end %> + +
    + <% + option_id = "other_#{question.to_i}" + checkbox_html = {class: 'checkbox', id: option_id, data: { last: answers.include?('other') ? 'checked' : '' } } + text_html = {class: 'input-other', id: "text_#{option_id}", data: { last: other_value }} + %> + <%= check_box_tag('answer[answer_hash][options][]', 'other', answers.include?('other'), checkbox_html) %> + <%= label_tag(option_id, 'Other') %> + <%= text_field_tag 'answer[answer_hash][other]', other_value, text_html %> +
    + +<%= render partial: "quiz/answer_errors", locals: {question: question, answer: answer} %> diff --git a/app/views/quiz/_live_code.html.erb b/app/views/quiz/_live_code.html.erb new file mode 100644 index 0000000..98f8477 --- /dev/null +++ b/app/views/quiz/_live_code.html.erb @@ -0,0 +1,78 @@ +<% + content_for_javascript_once 'live-coder' do + javascript_include_tag "live-coder" + end + + option_id = "#{question.question_id}_finish-later" + checkbox_html = {class: 'checkbox', + id: "answer_#{option_id}", + name: "answer[#{question.input_type}][later]", + checked: Array(question.answer).include?('finish-later') + } + + answers = answer.try(:answer) || answer + value_text = answers['text'] || question.input_options['text'] + value_html = answers['html'] || question.input_options['html'] + value_css = answers['css'] || question.input_options['css'] + value_js = answers['js'] || question.input_options['js'] +%> + +<%= render partial: "quiz/answer_errors", locals: {question: question, answer: answer} %> + +
    + +
    + + + + diff --git a/app/views/quiz/_radio.html.erb b/app/views/quiz/_radio.html.erb new file mode 100644 index 0000000..c4ade59 --- /dev/null +++ b/app/views/quiz/_radio.html.erb @@ -0,0 +1,15 @@ +<% answer = answer.try(:answer) || answer %> +<%= form.collection_radio_buttons(:answer, question.input_options, :to_s, :to_s, {}, {class: 'radio'}) do | option | %> + <% + option_id = "#{question.question_id}#{sanitize_to_id(option.value)}" + checked = answer == option.value ? 'checked' : '' + %> + +
    + <%= option.radio_button( id: option_id, checked: checked, data: { last: checked } ) %> + <%= option.label(for: option_id) %> +
    + +<% end %> + +<%= render partial: "quiz/answer_errors", locals: {question: question, answer: answer} %> diff --git a/app/views/quiz/_radio_other.html.erb b/app/views/quiz/_radio_other.html.erb new file mode 100644 index 0000000..086a8c9 --- /dev/null +++ b/app/views/quiz/_radio_other.html.erb @@ -0,0 +1,27 @@ +<% + answer = question.answer.nil? ? '' : Array(question.answer['options']).first + other_value = question.answer.nil? ? '' : question.answer['other'] + + question.input_options.each do | option | + option_id = "#{option.parameterize}_#{question.to_i}" + radio_html = {class: 'radio', id: option_id, data: {last: (answer == option) ? 'checked' : '' }} + %> +
    + <%= radio_button_tag('answer[answer_hash][options][]', option, (answer == option), radio_html) %> + <%= label_tag(option_id, option) %> +
    + <% + end %> + +
    + <% + option_id = "other_#{question.to_i}" + radio_html = {class: 'radio', id: option_id, data: { last: answer }} + text_html = {class: 'input-other', id: "text_#{option_id}", data: { last: other_value }} + %> + <%= radio_button_tag('answer[answer_hash][options][]', 'other', (answer == 'other'), radio_html) %> + <%= label_tag option_id, 'Other' %> + <%= text_field_tag 'answer[answer_hash][other]', other_value, text_html %> +
    + +<%= render partial: "quiz/answer_errors", locals: {question: question, answer: answer} %> diff --git a/app/views/quiz/_text.html.erb b/app/views/quiz/_text.html.erb new file mode 100644 index 0000000..359f3ff --- /dev/null +++ b/app/views/quiz/_text.html.erb @@ -0,0 +1,8 @@ +<% answer = answer.respond_to?(:answer) ? answer.answer : answer %> + + +<%= text_area_tag 'answer[answer]', answer, {rows: 10, data: { last: answer } } %> + +
    Characters remaining:
    + +<%= render partial: "quiz/answer_errors", locals: {question: question, answer: answer} %> diff --git a/app/views/quiz/question.html.erb b/app/views/quiz/question.html.erb new file mode 100644 index 0000000..21cd044 --- /dev/null +++ b/app/views/quiz/question.html.erb @@ -0,0 +1,39 @@ +<% + content_for :title, "Skills Assessment" + content_for :category, @question.category + content_for :footer_title, "Skills Assessment" + content_for :progress, @status.progress.to_s +%> + +<%= form_for(@answer, url: post_answer_path(@answer.id), html:{method: :post, id: 'answer-form', data: {qid: @question.question_id}}) do |form| %> +
    +

    <%= @question.question %>

    + + <% if @question.attachment.present? %> + <%= image_tag @question.attachment %> + <% end %> + +
    + <%= hidden_field_tag 'answer[question_id]', @question.question_id %> + <%= hidden_field_tag 'answer[answer_id]', @question.answer_id %> + <%= render partial: @question.input_type, locals: {question: @question, form: form, answer: @answer } %> +
    + + <% if @status.on_summary %> + +
    + +
    + + <% else %> + +
    + +
    +
    + +
    + + <% end %> +
    +<% end %> diff --git a/app/views/quiz/summary.html.erb b/app/views/quiz/summary.html.erb new file mode 100644 index 0000000..cf0bab9 --- /dev/null +++ b/app/views/quiz/summary.html.erb @@ -0,0 +1,55 @@ +<% + content_for :title, "Skills Assessment" + content_for :footer_title, "Skills Assessment" + content_for :progress, @status.progress.to_s + content_for_javascript_once 'summary-edit' do + javascript_include_tag "summary-edit" + end +%> + +
    +

    Almost done!

    +

    + Now's the time to review your answers and go back and change any, if you need to. + Once you're done, hit the button at the bottom of the page to submit your answers. +

    + +
    + + <% @quiz.each do |question| %> + <%= form_for(:answer, remote: true, url: post_answer_path(answer_id: question.answer_id), html:{class: 'summary-form'}) do |form| %> +
    +
    +
    +

    <%= question.question %>

    +
    +
    + Edit + + +
    +
    + +
    + <% if question.attachment.present? %> + <%= image_tag question.attachment %> + <% end %> + +
    + <%= hidden_field_tag 'answer[question_id]', question.question_id %> + <%= hidden_field_tag 'answer[answer_id]', question.answer_id %> + <%= hidden_field_tag 'submit', true %> + <%= render partial: question.input_type, locals: {question: question, form: form, answer: question.answer} %> +
    +
    +
    + <% end %> + <% end %> + + <%= form_tag post_summary_path, id: 'summary-submit', class: "btn-center" do %> + <% unless @status.can_submit %> +
    Sorry, you must answer all questions before you can submit.
    + <% end %> + <%= submit_tag "Submit all answers", {class: 'submit-button', disabled: !@status.can_submit } %> + <% end %> +
    diff --git a/app/views/recruiter_mailer/candidate_created.html.inky b/app/views/recruiter_mailer/candidate_created.html.inky new file mode 100644 index 0000000..0a9cc9e --- /dev/null +++ b/app/views/recruiter_mailer/candidate_created.html.inky @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/app/views/recruiter_mailer/candidate_created.text.erb b/app/views/recruiter_mailer/candidate_created.text.erb new file mode 100644 index 0000000..56b001b --- /dev/null +++ b/app/views/recruiter_mailer/candidate_created.text.erb @@ -0,0 +1,10 @@ +PERFICIENT/digital - Skills Assessment Test + +The following candidate has been invited to take the Skills Assessment Test: + +Candidate name: <%= @candidate.name %> +Candidate email: <%= @candidate.email %> +Candidate ID: <%= @candidate.test_hash %> +Years of experience: <%= @candidate.experience %> Years + +You will be notified when the candidate has finished taking the test. diff --git a/app/views/recruiter_mailer/candidate_submitted.html.inky b/app/views/recruiter_mailer/candidate_submitted.html.inky new file mode 100644 index 0000000..c4c8b4b --- /dev/null +++ b/app/views/recruiter_mailer/candidate_submitted.html.inky @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/app/views/recruiter_mailer/candidate_submitted.text.erb b/app/views/recruiter_mailer/candidate_submitted.text.erb new file mode 100644 index 0000000..c6e7142 --- /dev/null +++ b/app/views/recruiter_mailer/candidate_submitted.text.erb @@ -0,0 +1,4 @@ +PERFICIENT/digital - Skills Assessment Test + +<%= @candidate.name %> has completed the Skills Assessment Test. +Martin Ridgway will let you know if we would like to interview this candidate. diff --git a/app/views/reviewer_mailer/candidate_submission.html.inky b/app/views/reviewer_mailer/candidate_submission.html.inky new file mode 100644 index 0000000..1c5a20f --- /dev/null +++ b/app/views/reviewer_mailer/candidate_submission.html.inky @@ -0,0 +1,6 @@ + + + diff --git a/app/views/reviewer_mailer/candidate_submission.text.erb b/app/views/reviewer_mailer/candidate_submission.text.erb new file mode 100644 index 0000000..e81acc2 --- /dev/null +++ b/app/views/reviewer_mailer/candidate_submission.text.erb @@ -0,0 +1,5 @@ +PERFICIENT/digital SKILLS ASSESSMENT RESULTS + +Candidate <%= @candidate.test_hash %> has completed the Skills Assessment Test. + +You can view the results here: <%= admin_result_url(@candidate.test_hash) %>. diff --git a/app/views/shared/_form_model_errors.html.erb b/app/views/shared/_form_model_errors.html.erb new file mode 100644 index 0000000..5ae5829 --- /dev/null +++ b/app/views/shared/_form_model_errors.html.erb @@ -0,0 +1,10 @@ +<% if flash[:error].present? %> +
    + <%= flash[:error] %> +

    + <% obj.errors.messages.each do |k,v| %> + <%= "#{k.to_s} #{v.join(' and ')}" %>
    + <% end %> +

    +
    +<% end %> diff --git a/app/views/shared/_generic_flash.html.erb b/app/views/shared/_generic_flash.html.erb new file mode 100644 index 0000000..bd703c4 --- /dev/null +++ b/app/views/shared/_generic_flash.html.erb @@ -0,0 +1,15 @@ +<% if flash[:error].present? %> +
    <%= flash[:error] %>
    +<% end %> + +<% if flash[:success].present? %> +
    <%= flash[:success] %>
    +<% end %> + +<% if flash[:warning].present? %> +
    <%= flash[:warning] %>
    +<% end %> + +<% if flash[:notice].present? %> +
    <%= flash[:notice] %>
    +<% end %> diff --git a/app/views/styleguide.html.erb b/app/views/styleguide.html.erb new file mode 100644 index 0000000..5594fe8 --- /dev/null +++ b/app/views/styleguide.html.erb @@ -0,0 +1,196 @@ +<% + content_for :title, "Perficient Digital - Skills Assessment Styleguide" + content_for :category, 'Design' + content_for :footer_title, "Styleguide" + content_for :progress, '88' +%> + + +
    +

    Perficient Digital Base Styles

    +

    The purpose of this HTML is to help determine what default settings are with Bitters and to make sure that all possible HTML Elements are included in this HTML so as to not miss any possible Elements when designing a site.

    + +
    + +

    Headings

    + +

    h1. Heading

    +

    h2. Heading

    +

    h3. Heading

    +

    h4. Heading

    +
    h5. Heading
    +
    h6. Heading
    + +
    + +

    Font Families

    + +

    HalisR

    +
    +

    HalisR-100

    +

    HalisR-200

    +

    HalisR-300

    +

    HalisR-400

    +

    HalisR-500

    +

    HalisR-600

    +

    HalisR-700

    +

    HalisR-800

    +

    HalisR-900

    +
    + +

    Lato

    +
    +

    Lato-100

    +

    Lato-300

    +

    Lato-500

    +

    Lato-700

    +

    Lato-900

    +

    Lato-Italic-100

    +

    Lato-Italic-300

    +

    Lato-Italic-500

    +

    Lato-Italic-700

    +

    Lato-Italic-900

    +
    + +
    + +

    Paragraph

    + +

    Lorem ipsum dolor sit amet, test link adipiscing elit. Nullam dignissim convallis est. Quisque aliquam. Donec faucibus. Nunc iaculis suscipit dui. Nam sit amet sem. Aliquam libero nisi, imperdiet at, tincidunt nec, gravida vehicula, nisl. Praesent mattis, massa quis luctus fermentum, turpis mi volutpat justo, eu volutpat enim diam eget metus. Maecenas ornare tortor. Donec sed tellus eget sapien fringilla nonummy. Mauris a ante. Suspendisse quam sem, consequat at, commodo vitae, feugiat in, nunc. Morbi imperdiet augue quis tellus.

    + +

    Lorem ipsum dolor sit amet, emphasis consectetuer adipiscing elit. Nullam dignissim convallis est. Quisque aliquam. Donec faucibus. Nunc iaculis suscipit dui. Nam sit amet sem. Aliquam libero nisi, imperdiet at, tincidunt nec, gravida vehicula, nisl. Praesent mattis, massa quis luctus fermentum, turpis mi volutpat justo, eu volutpat enim diam eget metus. Maecenas ornare tortor. Donec sed tellus eget sapien fringilla nonummy. Mauris a ante. Suspendisse quam sem, consequat at, commodo vitae, feugiat in, nunc. Morbi imperdiet augue quis tellus.

    + +
    + +

    List Types

    + +

    Definition List

    +
    +
    Definition List Title
    +
    This is a definition list division.
    +
    + +

    Ordered List

    +
      +
    1. List Item 1
    2. +
    3. List Item 2
    4. +
    5. List Item 3
    6. +
    + +

    Unordered List

    +
      +
    • List Item 1
    • +
    • List Item 2
    • +
    • List Item 3
    • +
    + +
    + +

    Fieldsets and Form Elements

    + +
    +

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Nullam dignissim convallis est. Quisque aliquam. Donec faucibus. Nunc iaculis suscipit dui. Nam sit amet sem. Aliquam libero nisi, imperdiet at, tincidunt nec, gravida vehicula, nisl. Praesent mattis, massa quis luctus fermentum, turpis mi volutpat justo, eu volutpat enim diam eget metus.

    + +
    +

    Form Element

    + +

    Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Nullam dignissim convallis est. Quisque aliquam. Donec faucibus. Nunc iaculis suscipit dui.

    + +
    + + +
    + +
    + + +
    +
    + + +
    +

    +

    +

    +

    + +

    + + + +

    + +

    + + + +

    + +
    + + +
    + +
    This is a sample error message.
    +
    This is a sample success message.
    +
    This is a sample warning message.
    +
    This is a sample notice message.
    + +
    + +

    Buttons

    +

    +

    +

    +

    +

    +

    +
    +
    + +
    + +

    Tables

    + + + + + + + + + + + + + + +
    Table Header 1Table Header 2Table Header 3
    Division 1Division 2Division 3
    Division 1Division 2Division 3
    Division 1Division 2Division 3
    + +
    +

    Button Group

    +
    + + + +
    + +
    diff --git a/app/views/user_mailer/password_reset.html.inky b/app/views/user_mailer/password_reset.html.inky new file mode 100644 index 0000000..38b913c --- /dev/null +++ b/app/views/user_mailer/password_reset.html.inky @@ -0,0 +1,13 @@ + + + diff --git a/app/views/user_mailer/password_reset.text.erb b/app/views/user_mailer/password_reset.text.erb new file mode 100644 index 0000000..5eacf4b --- /dev/null +++ b/app/views/user_mailer/password_reset.text.erb @@ -0,0 +1,7 @@ +Hey there <%= @user.name %>, + +It looks like you want to reset your password? +If not, it is safe to ignore this email. +Otherwise go to the following link to enter a new password: + +<%= admin_reset_url(reset_token: @user.reset_token) %> diff --git a/app/views/user_mailer/welcome.html.inky b/app/views/user_mailer/welcome.html.inky new file mode 100644 index 0000000..a9f7e31 --- /dev/null +++ b/app/views/user_mailer/welcome.html.inky @@ -0,0 +1,15 @@ + + + diff --git a/app/views/user_mailer/welcome.text.erb b/app/views/user_mailer/welcome.text.erb new file mode 100644 index 0000000..7743f45 --- /dev/null +++ b/app/views/user_mailer/welcome.text.erb @@ -0,0 +1,8 @@ +Hey there <%= @user.name %>, + +Looks like you now have access to the skills assessment app. +We've given you a temporary password below. Please sign in an personalize it asap. + +Password: <%= @password %> + +<%= admin_url %>. diff --git a/app/workers/candidate_quiz.rb b/app/workers/candidate_quiz.rb new file mode 100644 index 0000000..a7d74e7 --- /dev/null +++ b/app/workers/candidate_quiz.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +class CandidateQuiz + attr_reader :candidate_id + + def initialize candidate + @candidate_id = candidate.to_i + end + + def fetch_question qid + questions = raw_quiz(qid).each_with_object([]) { |row, quiz| quiz << CandidateQuizQuestion.new(row) } + questions.first + end + + def build_my_quiz + raw_quiz.each_with_object([]) { |row, quiz| quiz << CandidateQuizQuestion.new(row) } + end + + private + + def raw_quiz qid = nil + question = qid.nil? ? "" : " and q.id = #{qid} " + sql = "select c.id candidate_id, q.quiz_id, q.id question_id, a.id answer_id, q.sort + , q.question, q.attachment, q.category, q.input_type, q.input_options, a.answer + , ifnull(a.saved, false) saved, ifnull(a.submitted, false) submitted , a.updated_at + from candidates c + inner join questions q on q.quiz_id = c.quiz_id + left join answers a on a.candidate_id = c.id AND a.question_id = q.id + where q.active = true and c.id = #{candidate_id} #{question} + order by q.sort;" + ActiveRecord::Base.connection.exec_query(sql) + end +end diff --git a/app/workers/candidate_quiz_question.rb b/app/workers/candidate_quiz_question.rb new file mode 100644 index 0000000..dad8a16 --- /dev/null +++ b/app/workers/candidate_quiz_question.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true +class CandidateQuizQuestion + attr_reader :row + + def initialize row + @row = row + end + + def candidate_id + row["candidate_id"] + end + + def quiz_id + row["quiz_id"] + end + alias to_i quiz_id + + def question_id + row["question_id"] + end + + def answer_id + row["answer_id"] + end + + def question + row["question"] + end + + def attachment + row['attachment'] + end + + def category + row["category"] + end + + def input_type + row["input_type"] + end + + def input_options + YAML.load(row["input_options"].to_s) || {} + end + + def answer + YAML.load(row["answer"].to_s) unless row['answer'].nil? + end + + def saved + row["saved"] + end + + def submitted + row["submitted"] + end + + def updated_at + row["updated_at"] + end +end diff --git a/app/workers/quiz_status.rb b/app/workers/quiz_status.rb index 9e6d695..a4a5977 100644 --- a/app/workers/quiz_status.rb +++ b/app/workers/quiz_status.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true class QuizStatus attr_reader :candidate @@ -6,18 +7,49 @@ class QuizStatus end def started - candidate.answers.count > 0 + candidate.answers.count.positive? end def on_summary - candidate.submitted_answers.count == candidate.questions.count + candidate.submitted_answers.count == candidate.questions.where(active: true).count end - def completed - candidate.completed - end + delegate :completed, to: :candidate def can_submit - on_summary && candidate.answered_questions.count == candidate.questions.count + on_summary && + no_finish_later && + candidate.answered_questions.count == candidate.questions.where(active: true).count + end + + def progress + answs = candidate.answered_questions.count.to_f + total = candidate.quiz.questions.where(active: true).count.to_f + + (answs / total * 100).round.to_i + end + + def no_finish_later + sql = %(select count(a.id) todos + from answers a + inner join questions q on q.id = a.question_id + where q.input_type = 'live_code' + and a.answer like "%later: 'true'%" + and q.active = true + and a.candidate_id = #{candidate.id}) + result = ActiveRecord::Base.connection.exec_query(sql).to_hash.first + result['todos'].zero? + end + + def current_question_id + sql = "select q.id question_id + from candidates c + inner join questions q on q.quiz_id = c.quiz_id + left join answers a on a.candidate_id = c.id AND a.question_id = q.id + where q.active = true and c.id = #{candidate.to_i} + and (a.id is null OR a.submitted is false) + order by q.sort limit 1;" + result = ActiveRecord::Base.connection.exec_query(sql).to_hash.first + result['question_id'] unless result.nil? end end diff --git a/app/workers/reminder.rb b/app/workers/reminder.rb new file mode 100644 index 0000000..7437f04 --- /dev/null +++ b/app/workers/reminder.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +class Reminder + def initialize + @collection = reminder_collection + end + + def count + @collection.count + end + alias size count + + def candidates + Candidate.includes(:recruiter).where(id: @collection.map { |row| row['id'] }) + end + + def send_all + candidates.each do |candidate| + CandidateMailer.reminder(candidate).deliver_now + flag_as_reminded candidate.id + end + end + + private + + def reminder_collection + sql = "select c.id, c.test_hash, c.name, c.email, max(a.updated_at) last_updated + from candidates c + inner join answers a on a.candidate_id = c.id + where completed = false and reminded = false + group by c.id + having MAX(a.updated_at) < DATE_SUB(NOW(), INTERVAL 24 HOUR);" + ActiveRecord::Base.connection.exec_query(sql) + end + + def flag_as_reminded candidate_id + Candidate.find(candidate_id).update(reminded: true) + end +end diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..34e7316 --- /dev/null +++ b/bower.json @@ -0,0 +1,24 @@ +{ + "name": "rails-dev", + "authors": [ + "" + ], + "private": true, + "dependencies": { + "jquery": "1.9.1", + "jquery-validate": "", + "tota11y": "", + "modernizr-lite": "*", + "html5shiv": "^3.7.3", + "foundation-emails": "^2.2.1" + }, + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ], + "devDependencies": { + } +} diff --git a/config.ru b/config.ru index f7ba0b5..7eae264 100644 --- a/config.ru +++ b/config.ru @@ -1,3 +1,4 @@ +# frozen_string_literal: true # This file is used by Rack-based servers to start the application. require_relative 'config/environment' diff --git a/config/application.rb b/config/application.rb index 1f12e80..98b8077 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true require_relative 'boot' require 'rails/all' @@ -11,5 +12,23 @@ module SkillAssessmentApp # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. + + # bitters + config.assets.paths << Rails.root.join('vendor', 'assets', 'base') + + # bourbon + config.assets.paths << Rails.root.join('vendor', 'assets', 'bourbon') + + # bower + config.assets.paths << Rails.root.join('vendor', 'assets', 'bower_components') + + config.action_mailer.delivery_method = :mailjet + config.action_mailer.default_url_options = { host: ENV['full_app_url'] } + + config.generators do |g| + g.assets false + g.helper false + g.routes false + end end end diff --git a/config/application.yml.sample b/config/application.yml.sample new file mode 100644 index 0000000..d42b11b --- /dev/null +++ b/config/application.yml.sample @@ -0,0 +1,24 @@ +# Add configuration values here, as shown below. + +defaults: &defaults + mysql_host: "localhost" + mysql_usr: "user" + mysql_pwd: "password" + mailjet_key: "api-key" + mailjet_secret: "api-secret" + default_mail_from: "skills-assessment@dev.perficientxd.com" + full_app_url: "localhost:3000" + +development: + <<: *defaults + +test: + <<: *defaults + +production: + <<: *defaults + mysql_db: "database name" + mysql_usr: "user" + mysql_pwd: "password!" + secret_key_base: "super-long-secret-key-base" + full_app_url: "dev.perficientxd.com/Skills-Assessment/" diff --git a/config/boot.rb b/config/boot.rb index 30f5120..9be337a 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) require 'bundler/setup' # Set up gems listed in the Gemfile. diff --git a/config/brakeman.ignore b/config/brakeman.ignore new file mode 100644 index 0000000..3348c50 --- /dev/null +++ b/config/brakeman.ignore @@ -0,0 +1,10 @@ +{ + "ignored_warnings": [ + { + "fingerprint": "da17225c940987e6239cc4ecfe27bcb1e5da2db1134435dc3e1025d97927e0ba", + "note": "false positive" + } + ], + "updated": "2016-09-19 09:06:25 -0500", + "brakeman_version": "3.4.0" +} diff --git a/config/database.yml b/config/database.yml index b60aa35..9628f7f 100644 --- a/config/database.yml +++ b/config/database.yml @@ -13,9 +13,9 @@ default: &default adapter: mysql2 encoding: utf8 pool: 5 - username: root - password: root - host: localhost + host: <%= ENV['mysql_host'] %> + username: <%= ENV['mysql_usr'] %> + password: <%= ENV['mysql_pwd'] %> development: <<: *default @@ -49,6 +49,6 @@ test: # production: <<: *default - database: skill-assessment-app_production - username: skill-assessment-app - password: <%= ENV['SKILL-ASSESSMENT-APP_DATABASE_PASSWORD'] %> + database: <%= ENV['mysql_db'] %> + username: <%= ENV['mysql_usr'] %> + password: <%= ENV['mysql_pwd'] %> diff --git a/config/environment.rb b/config/environment.rb index 426333b..12ea62f 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Load the Rails application. require_relative 'application' diff --git a/config/environments/development.rb b/config/environments/development.rb index 6f71970..c10e626 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. @@ -51,4 +52,8 @@ Rails.application.configure do # Use an evented file watcher to asynchronously detect changes in source code, # routes, locales, etc. This feature depends on the listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker + + # Disable Rails's static asset server (Apache or nginx will already do this) + config.public_file_server.enabled = false + config.eager_load = false end diff --git a/config/environments/production.rb b/config/environments/production.rb index 2b66046..076378e 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. diff --git a/config/environments/test.rb b/config/environments/test.rb index 30587ef..826c3a5 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. @@ -39,4 +40,8 @@ Rails.application.configure do # Raises error for missing translations # config.action_view.raise_on_missing_translations = true + + # Disable Rails's static asset server (Apache or nginx will already do this) + config.public_file_server.enabled = false + config.eager_load = false end diff --git a/config/initializers/application_controller_renderer.rb b/config/initializers/application_controller_renderer.rb index 51639b6..315ac48 100644 --- a/config/initializers/application_controller_renderer.rb +++ b/config/initializers/application_controller_renderer.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Be sure to restart your server when you modify this file. # ApplicationController.renderer.defaults.merge!( diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 01ef3e6..4607738 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Be sure to restart your server when you modify this file. # Version of your assets, change this if you want to expire all your assets. @@ -9,3 +10,7 @@ Rails.application.config.assets.version = '1.0' # Precompile additional assets. # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. # Rails.application.config.assets.precompile += %w( search.js ) + +Rails.application.config.assets.precompile += ['vendor/assets/**/*'] +Rails.application.config.assets.precompile += %w(ie9.js admin.js summary-edit.js live-coder.js) +Rails.application.config.assets.precompile += %w(mailers/foundation_vendor_manifest.scss) diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb index 59385cd..d0f0d3b 100644 --- a/config/initializers/backtrace_silencers.rb +++ b/config/initializers/backtrace_silencers.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Be sure to restart your server when you modify this file. # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. diff --git a/config/initializers/cookies_serializer.rb b/config/initializers/cookies_serializer.rb index 5a6a32d..2a72959 100644 --- a/config/initializers/cookies_serializer.rb +++ b/config/initializers/cookies_serializer.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Be sure to restart your server when you modify this file. # Specify a serializer for the signed and encrypted cookie jars. diff --git a/config/initializers/custom_error_wrapper.rb b/config/initializers/custom_error_wrapper.rb new file mode 100644 index 0000000..6602606 --- /dev/null +++ b/config/initializers/custom_error_wrapper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +# TODO: needs better wrapping instead of nuking +# https://rubyplus.com/articles/3401 +ActionView::Base.field_error_proc = proc do |html_tag, _instance| + html_tag +end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 4a994e1..b7fe123 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Be sure to restart your server when you modify this file. # Configure sensitive parameters which will be filtered from the log file. diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ac033bf..aa7435f 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Be sure to restart your server when you modify this file. # Add new inflection rules using the following format. Inflections diff --git a/config/initializers/mailjet.rb b/config/initializers/mailjet.rb new file mode 100644 index 0000000..4fa89bc --- /dev/null +++ b/config/initializers/mailjet.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +Mailjet.configure do |config| + config.api_key = ENV['mailjet_key'] + config.secret_key = ENV['mailjet_secret'] + config.default_from = ENV['default_mail_from'] +end diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index dc18996..6e1d16f 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Be sure to restart your server when you modify this file. # Add new mime types for use in respond_to blocks: diff --git a/config/initializers/new_framework_defaults.rb b/config/initializers/new_framework_defaults.rb index 0706caf..d3c12d7 100644 --- a/config/initializers/new_framework_defaults.rb +++ b/config/initializers/new_framework_defaults.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Be sure to restart your server when you modify this file. # # This file contains migration options to ease your Rails 5.0 upgrade. diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index 2c7c004..df5c83b 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Be sure to restart your server when you modify this file. Rails.application.config.session_store :cookie_store, key: '_skill-assessment-app_session' diff --git a/config/initializers/wrap_parameters.rb b/config/initializers/wrap_parameters.rb index bbfc396..18c3825 100644 --- a/config/initializers/wrap_parameters.rb +++ b/config/initializers/wrap_parameters.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Be sure to restart your server when you modify this file. # This file contains settings for ActionController::ParamsWrapper which diff --git a/config/puma.rb b/config/puma.rb index c7f311f..14a0f44 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Puma can serve each request in a thread from an internal thread pool. # The `threads` method setting takes two numbers a minimum and maximum. # Any libraries that use thread pools should be configured to match diff --git a/config/routes.rb b/config/routes.rb index 787824f..c5356dd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,3 +1,72 @@ +# frozen_string_literal: true Rails.application.routes.draw do + get "/styleguide", to: "application#styleguide", as: :styleguide + get "/admin/styleguide", to: "application#styleguide" + + post "/admin/login", to: "admin/auth#auth", as: :admin_auth + get "/admin/login", to: "admin/auth#login", as: :admin_login + get "/admin/logout", to: "admin/auth#logout", as: :admin_logout + get "/admin/reset/:reset_token", to: "admin/auth#reset", as: :admin_reset + post "/admin/reset", to: "admin/auth#reset_password", as: :admin_reset_password + get "/admin/reset_request", to: "admin/auth#reset_request", as: :admin_reset_request + post "/admin/reset_request", to: "admin/auth#send_reset", as: :admin_send_reset + + get "/admin/quizzes", to: "admin/quiz#index", as: :admin_quizzes + get "/admin/quiz/new", to: "admin/quiz#new", as: :admin_new_quiz + post "/admin/quiz/new", to: "admin/quiz#create", as: :admin_create_quiz + get "/admin/quiz/:quiz_id", to: "admin/quiz#view", as: :admin_quiz + get "/admin/quiz/:quiz_id/edit", to: "admin/quiz#edit", as: :admin_edit_quiz + post "/admin/quiz/:quiz_id/edit", to: "admin/quiz#update", as: :admin_update_quiz + patch "/admin/quiz/:quiz_id/edit", to: "admin/quiz#update" + + get "/admin/users", to: "admin/user#index", as: :admin_users + get "/admin/user/new", to: "admin/user#new", as: :admin_new_user + post "/admin/user/new", to: "admin/user#create", as: :admin_create_user + get "/admin/user/:user_id", to: "admin/user#view", as: :admin_user + get "/admin/user/:user_id/edit", to: "admin/user#edit", as: :admin_edit_user + post "/admin/user/:user_id/edit", to: "admin/user#update", as: :admin_update_user + patch "/admin/user/:user_id/edit", to: "admin/user#update" + + get "/admin/questions", to: "admin/question#index", as: :admin_questions + get "/admin/question/new", to: "admin/question#new", as: :admin_new_question + post "/admin/question/new", to: "admin/question#create", as: :admin_create_question + get "/admin/question(/:question_id)/options/:input_type", to: "admin/question#options", as: :admin_question_option_form + get "/admin/question/:question_id", to: "admin/question#view", as: :admin_question + get "/admin/question/:question_id/edit", to: "admin/question#edit", as: :admin_edit_question + post "/admin/question/:question_id/edit", to: "admin/question#update", as: :admin_update_question + patch "/admin/question/:question_id/edit", to: "admin/question#update" + + get "/admin/profile", to: "admin/profile#view", as: :admin_profile + post "/admin/profile", to: "admin/profile#update", as: :admin_update_profile + get "/admin/profile/edit", to: "admin/profile#edit", as: :admin_edit_profile + + get "/admin/candidates", to: "admin/candidate#index", as: :admin_candidates + get "/admin/candidate/new", to: "admin/candidate#new", as: :admin_new_candidate + post "/admin/candidate/new", to: "admin/candidate#create", as: :admin_create_candidate + get "/admin/candidate/:id", to: "admin/candidate#edit", as: :admin_edit_candidate + post "/admin/candidate/:id", to: "admin/candidate#update", as: :admin_update_candidate + get "/admin/candidate/:id/resend", to: "admin/candidate#resend_welcome", as: :admin_resend_welcome + + get "/admin/results", to: "admin/result#index", as: :admin_results + get "/admin/result/:test_hash", to: "admin/result#view", as: :admin_result + + get "/admin", to: "admin/dashboard#show", as: :admin + + ######################################################################################### + + post "/validate", to: "candidate#validate", as: :validate_candidate + get "/login(/:test_id)", to: "candidate#login", as: :login + get "/welcome", to: "candidate#welcome", as: :welcome + get "/saved", to: "candidate#saved", as: :saved + get "/thankyou", to: "candidate#thankyou", as: :thankyou + get "/oops", to: "candidate#oops", as: :oops + + post "/question(/:answer_id)", to: "quiz#update_answer", as: :post_answer + get "/question(/:question_id)", to: "quiz#question", as: :question + post "/summary", to: "quiz#submit_summary", as: :post_summary + get "/summary", to: "quiz#summary", as: :summary + + root to: "candidate#login" + # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html end diff --git a/config/secrets.yml b/config/secrets.yml index 01da792..1fb9a20 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -19,4 +19,4 @@ test: # Do not keep production secrets in the repository, # instead read values from the environment. production: - secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> + secret_key_base: <%= ENV["secret_key_base"] %> diff --git a/config/spring.rb b/config/spring.rb index c9119b4..312295f 100644 --- a/config/spring.rb +++ b/config/spring.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true %w( .ruby-version .rbenv-vars diff --git a/db/migrate/20160726193255_db_init.rb b/db/migrate/20160726193255_db_init.rb index 6351352..9da2dd5 100644 --- a/db/migrate/20160726193255_db_init.rb +++ b/db/migrate/20160726193255_db_init.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true class DbInit < ActiveRecord::Migration[5.0] def change create_table :candidates do |t| diff --git a/db/migrate/20160727154057_add_quiz_to_candidate.rb b/db/migrate/20160727154057_add_quiz_to_candidate.rb index 88ee3e2..d504fb0 100644 --- a/db/migrate/20160727154057_add_quiz_to_candidate.rb +++ b/db/migrate/20160727154057_add_quiz_to_candidate.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true class AddQuizToCandidate < ActiveRecord::Migration[5.0] def change add_column :candidates, :quiz_id, :integer diff --git a/db/migrate/20160803003932_add_attachments_to_questions.rb b/db/migrate/20160803003932_add_attachments_to_questions.rb new file mode 100644 index 0000000..0773fef --- /dev/null +++ b/db/migrate/20160803003932_add_attachments_to_questions.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddAttachmentsToQuestions < ActiveRecord::Migration[5.0] + def change + add_column :questions, :attachment, :string + end +end diff --git a/db/migrate/20160818225721_add_name_to_quiz.rb b/db/migrate/20160818225721_add_name_to_quiz.rb new file mode 100644 index 0000000..ecbdb10 --- /dev/null +++ b/db/migrate/20160818225721_add_name_to_quiz.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddNameToQuiz < ActiveRecord::Migration[5.0] + def change + add_column :quizzes, :name, :string, after: :id + end +end diff --git a/db/migrate/20160824183159_add_resets_to_users.rb b/db/migrate/20160824183159_add_resets_to_users.rb new file mode 100644 index 0000000..364e217 --- /dev/null +++ b/db/migrate/20160824183159_add_resets_to_users.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +class AddResetsToUsers < ActiveRecord::Migration[5.0] + def change + add_column :users, :reset_token, :string + add_column :users, :reset_timestamp, :datetime + + add_index :users, :reset_token + end +end diff --git a/db/migrate/20160826200610_encode_candidate_emails.rb b/db/migrate/20160826200610_encode_candidate_emails.rb new file mode 100644 index 0000000..d00d27c --- /dev/null +++ b/db/migrate/20160826200610_encode_candidate_emails.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +class EncodeCandidateEmails < ActiveRecord::Migration[5.0] + def change + sql = "select id, email from candidates;" + candidates = ActiveRecord::Base.connection.execute(sql).to_h + + candidates.each do |id, email| + sql = if base64?(email) + # going down - decrypt + "UPDATE candidates set email = '#{CryptSerializer.load email}' WHERE id = #{id};" + else + # going up - encrypt emails + "UPDATE candidates set email = '#{CryptSerializer.dump email}' WHERE id = #{id};" + end + ActiveRecord::Base.connection.execute(sql) + end + end + + private + + def base64? string + Base64.urlsafe_decode64 string + true + rescue ArgumentError + false + end +end diff --git a/db/migrate/20160915164450_create_reviewer_to_quizzes.rb b/db/migrate/20160915164450_create_reviewer_to_quizzes.rb new file mode 100644 index 0000000..4f29634 --- /dev/null +++ b/db/migrate/20160915164450_create_reviewer_to_quizzes.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +class CreateReviewerToQuizzes < ActiveRecord::Migration[5.0] + def change + create_table :reviewer_to_quizzes do |t| + t.integer :user_id, null: false + t.integer :quiz_id, null: false + + t.timestamps + end + add_index :reviewer_to_quizzes, :quiz_id + end +end diff --git a/db/schema.rb b/db/schema.rb index bc22151..41a294d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160727154057) do +ActiveRecord::Schema.define(version: 20160915164450) do create_table "answers", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| t.integer "candidate_id" @@ -51,6 +51,7 @@ ActiveRecord::Schema.define(version: 20160727154057) do t.boolean "active" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "attachment" t.index ["active"], name: "index_questions_on_active", using: :btree t.index ["quiz_id"], name: "index_questions_on_quiz_id", using: :btree t.index ["sort"], name: "index_questions_on_sort", using: :btree @@ -61,6 +62,15 @@ ActiveRecord::Schema.define(version: 20160727154057) do t.string "dept" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "name" + end + + create_table "reviewer_to_quizzes", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| + t.integer "user_id", null: false + t.integer "quiz_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["quiz_id"], name: "index_reviewer_to_quizzes_on_quiz_id", using: :btree end create_table "users", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| @@ -71,6 +81,9 @@ ActiveRecord::Schema.define(version: 20160727154057) do t.boolean "active" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "reset_token" + t.datetime "reset_timestamp" + t.index ["reset_token"], name: "index_users_on_reset_token", using: :btree end end diff --git a/db/seeds.rb b/db/seeds.rb index e9590a6..e1186d7 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -13,4 +13,8 @@ User.create( role: 'admin' ) -Quiz.create(unit: 'FED', dept: 'PDR') +Quiz.create( + name: 'PDR Standard FED Screening', + unit: 'FED', + dept: 'PDR' +) diff --git a/db/sql/candidate_quiz.sql b/db/sql/candidate_quiz.sql new file mode 100644 index 0000000..97143f8 --- /dev/null +++ b/db/sql/candidate_quiz.sql @@ -0,0 +1,17 @@ +select c.id candidate_id + , c.name, c.test_hash + , q.quiz_id, q.id question_id, a.id answer_id, q.sort + , q.question, q.category, q.input_type, q.input_options, a.answer + , ifnull(a.saved, false) saved, ifnull(a.submitted, false) submitted , a.updated_at +from candidates c + inner join questions q on q.quiz_id = c.quiz_id + left join answers a on a.candidate_id = c.id AND a.question_id = q.id + +where c.test_hash = 'R67PmfDHGiw' -- and q.input_type = 'radio' + +order by c.name, q.sort; + + + +-- delete from answers where id = 1008398109 + diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..37d3adc --- /dev/null +++ b/deploy.sh @@ -0,0 +1,126 @@ +#!/bin/bash +set -e + +function do_pull(){ + msg "Reset and pull $branch" + git reset --hard + git checkout $branch + git pull +} + +function do_bundle(){ + msg "Bundling gems, npm, and bower" + bundle install --deployment --without development test + npm install --production + ./node_modules/bower/bin/bower install --production +} + +function do_assets(){ + msg "Clean Assets" + RAILS_ENV=production bundle exec rake assets:clean + + msg "Precompiling Assets" + RAILS_ENV=production bundle exec rake assets:precompile +} + +function do_migrate(){ + if [ $app_env == "sandbox" ]; then + msg "SANDBOX: rebuilding db with fixtures" + RAILS_ENV=production \ + DISABLE_DATABASE_ENVIRONMENT_CHECK=1 \ + bundle exec rake db:drop db:setup db:migrate db:fixtures:load + else + msg "DB Migrate PRODUCTION" + RAILS_ENV=production bundle exec rake db:migrate + fi +} + +function quick(){ + do_pull + app_restart +} + +function full(){ + do_pull + do_bundle + do_migrate + do_assets + app_restart +} + +function assets(){ + do_pull + do_assets + app_restart +} + +function migration(){ + do_pull + do_migrate + app_restart +} + +function app_restart(){ + touch tmp/restart.txt + msg "App Restarted" +} + +function msg(){ + if [ ${#@} != 0 ]; then + tput setaf 2 + echo $* + tput sgr0 + fi +} + +function helps { + tput setaf 3 + echo "SkillApp Deploy-er + A simple deploy helper meant to be ran from a server ssh session. + -q, --quick : pull, restart + -f, --full : pull, bundle, migrate (rebuild sandbox db), asset clean/precompile, restart + -a, --with-assets : pull, precompile, restart + -m, --with-migration : pull, migrate (rebuild sandbox db), restart + --restart : just restart the app + + " + tput sgr0 +} + +########################################### +########################################### +########################################### + +if [ ! -f ".deploy.conf" ]; then + msg "Missing .deploy.conf -- check out .deploy.conf.sample" +else + IFS="=" + while read -r name value; do + case $name in + 'branch') export branch="${value//\"/}" ;; + 'app_env') export app_env="${value//\"/}" ;; + esac + done < .deploy.conf + + if [ ! -d ".git" ]; then + msg "No git repo found. You must call this script from the application root." + helps + else + if [ ${#@} != 1 ]; then + helps + else + case $1 in + '-a' ) assets ;; + '--with-assets' ) assets ;; + '-q' ) quick ;; + '--quick' ) quick ;; + '-f') full;; + '--full') full;; + '-m') migration;; + '--with-migration') migration;; + '--restart') app_restart;; + *) helps ;; + esac + fi + fi +fi diff --git a/erd.pdf b/erd.pdf index b7f2f8b..1d050f2 100644 Binary files a/erd.pdf and b/erd.pdf differ diff --git a/lib/tasks/reminders.rake b/lib/tasks/reminders.rake new file mode 100644 index 0000000..2695aa6 --- /dev/null +++ b/lib/tasks/reminders.rake @@ -0,0 +1,8 @@ +# frozen_string_literal: true +namespace :reminders do + desc "send reminders to stagnate quizes" + task send_all: :environment do + reminders = Reminder.new + reminders.send_all + end +end diff --git a/package.json b/package.json new file mode 100644 index 0000000..ed3ef70 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "rails-dev", + "version": "1.0.0", + "description": "rails-dev assets", + "dependencies": { + "bower": "^1.7.9" + }, + "devDependencies": { + "eslint": "^3.2.2", + "eslint-plugin-ignore-erb": "^0.1.0" + }, + "repository": { + "type": "git", + "url": "" + }, + "license": "ISC" +} diff --git a/public/favicon.ico b/public/favicon.ico index e69de29..bb48e4e 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/rebuild-dev-db.sh b/rebuild-dev-db.sh index fcb014e..a563084 100755 --- a/rebuild-dev-db.sh +++ b/rebuild-dev-db.sh @@ -1,6 +1,8 @@ #!/bin/bash -rails db:drop && \ -rails db:setup && \ -rails db:migrate && \ -rails db:fixtures:load +RAILS_ENV=development \ +bundle exec rake \ + db:drop \ + db:setup \ + db:migrate \ + db:fixtures:load diff --git a/start-dev.sh b/start-dev.sh index 8e5b2b7..b5b054a 100755 --- a/start-dev.sh +++ b/start-dev.sh @@ -1,17 +1,28 @@ #!/bin/bash if [ -d '/usr/app' ]; then - cd /usr/app - bundle - - service mysql start - rails db:setup - rails db:migrate - rails db:fixtures:load - - tmux new-session -d -s 'rails-dev' 'rails s -b 0.0.0.0' - tmux split-window -p 85 -v 'bundle exec guard' - tmux split-window -p 20 -v - tmux attach -t rails-dev + if [ ! -f 'config/application.yml' ]; then + echo '' + echo -e "\E[1;31m###################################" + echo "Missing application.yml" + echo -e "\E[1;32m cp config/application.yml.sample config/application.yml" + tput sgr0 + echo "edit and update proper values" + echo '' + else + if [ ! -f '/root/.container-setup' ]; then + bundle + npm install + bower install --allow-root + rails db:setup + rails db:migrate + rails db:fixtures:load + touch /root/.container-setup + fi + tmux new-session -d -s 'rails-dev' 'rails s -b 0.0.0.0' + tmux split-window -p 15 -v + tmux split-window -p 85 -v 'bundle exec guard start --wait-for-delay 1.5' + tmux attach -t rails-dev + fi else echo "Are you in docker container?" fi diff --git a/start-docker.sh b/start-docker.sh index 5e9740e..2c07db6 100755 --- a/start-docker.sh +++ b/start-docker.sh @@ -1,16 +1,12 @@ #!/bin/bash -IMAGE=deb-rails-dev +IMAGE=markamoser/rails-dev-mysql:latest CONTAINER=`basename ${PWD}` -if [ "$(docker images -q $IMAGE:latest 2> /dev/null)" == "" ]; then - docker build -t $IMAGE . -fi - STATUS=$(docker inspect --format="{{ .State.Running }}" $CONTAINER 2> /dev/null) if [ $? -eq 1 ]; then - docker run --name $CONTAINER -it -p 3000:3000 -p 35729:35729 -p 3306:3306 -v ${PWD}:/usr/app $IMAGE /bin/bash + docker run --name $CONTAINER -it -p 3000:3000 -p 35729:35729 -v ${PWD}:/usr/app $IMAGE /bin/bash -c "service mysql start; /bin/bash" exit fi @@ -19,3 +15,4 @@ if [ "$STATUS"=="false" ]; then fi docker attach $CONTAINER + diff --git a/start-server.sh b/start-server.sh index 0b36159..c6d5043 100755 --- a/start-server.sh +++ b/start-server.sh @@ -1,14 +1,26 @@ #!/bin/bash if [ -d '/usr/app' ]; then - cd /usr/app - bundle - - service mysql start - rails db:setup - rails db:migrate - rails db:fixtures:load - - rails s -b 0.0.0.0 + if [ ! -f 'config/application.yml' ]; then + echo '' + echo -e "\E[1;31m###################################" + echo "Missing application.yml" + echo -e "\E[1;32m cp config/application.yml.sample config/application.yml" + tput sgr0 + echo "edit and update proper values" + echo '' + else + if [ ! -f '/root/.container-setup' ]; then + bundle + npm install + bower install --allow-root + rails db:setup + rails db:migrate + rails db:fixtures:load + touch /root/.container-setup + fi + echo 'Delete ~/.container-setup and re-run startup script to update app.' + rails s -b 0.0.0.0 + fi else echo "Are you in docker container?" fi diff --git a/test/channels/application_cable/channel_test.rb b/test/channels/application_cable/channel_test.rb new file mode 100644 index 0000000..d4e416b --- /dev/null +++ b/test/channels/application_cable/channel_test.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +require 'test_helper' + +module ApplicationCable + class ChannelTest < ActiveSupport::TestCase + test 'should exists' do + assert Channel + end + end +end diff --git a/test/channels/application_cable/connection_test.rb b/test/channels/application_cable/connection_test.rb new file mode 100644 index 0000000..17e1590 --- /dev/null +++ b/test/channels/application_cable/connection_test.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +require 'test_helper' + +module ApplicationCable + class ConnectionTest < ActiveSupport::TestCase + test 'should exists' do + assert Connection + end + end +end diff --git a/test/controllers/admin/auth_controller_test.rb b/test/controllers/admin/auth_controller_test.rb new file mode 100644 index 0000000..f99bfaa --- /dev/null +++ b/test/controllers/admin/auth_controller_test.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true +require 'test_helper' + +module Admin + class AuthControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + + test "should get login" do + get admin_login_url + assert_response :success + assert_template 'admin/auth/login' + end + + test "should get logout" do + post admin_auth_url, params: { auth: + { email: 'alan.admin@mailinator.com', password: 'password' } } + + get admin_logout_url + assert_redirected_to admin_login_url + assert session[:user].nil? + end + + test "should auth to dashboard" do + post admin_auth_url, params: { auth: + { email: 'alan.admin@mailinator.com', password: 'password' } } + assert_redirected_to admin_url + end + + test "should FAIL auth" do + post admin_auth_url, params: { auth: + { email: 'alan.admin@mailinator.com', password: 'b@d9a$$werD' } } + assert_redirected_to admin_login_url + assert_match(/incorrect.*password/i, flash[:error]) + end + + test "recruiter should auth to dashboard" do + post admin_auth_url, params: { auth: + { email: 'pdr.recruiter@mailinator.com', password: 'password' } } + assert_redirected_to admin_url + end + + test "reviewer should auth to dashboard" do + post admin_auth_url, params: { auth: + { email: 'fed.reviewer@mailinator.com', password: 'password' } } + assert_redirected_to admin_url + end + + test "should get reset_request" do + get admin_reset_request_url + assert_response :success + end + + test "should process a reset request" do + user = users(:admin) + assert_enqueued_jobs 1 do + post admin_send_reset_url, params: { auth: { email: user.email } } + end + refute_equal user.reset_token, User.find(user.id).reset_token + assert_redirected_to admin_reset_request_url + assert_match(/request.*sent/i, flash[:success]) + end + + test "should redirect with invalid reset_token" do + get admin_reset_url('fooBarBaz') + assert_redirected_to admin_reset_request_url + end + + test "should get reset form" do + user = users(:admin) + user.setup_reset + get admin_reset_url(user.reset_token) + assert :success + end + + test "should post password reset" do + user = users(:admin) + user.setup_reset + + post admin_reset_password_url, params: { auth: + { reset_token: user.reset_token, password: '12345', password_confirmation: '12345' } } + + assert_redirected_to admin_auth_path + assert_match(/reset.*log/i, flash[:success]) + end + + test "should fail to reset with mistyped password" do + user = users(:admin) + user.setup_reset + + post admin_reset_password_url, params: { auth: + { reset_token: user.reset_token, password: '12345', password_confirmation: 'abcde' } } + + assert :success + assert flash[:error] + end + end +end diff --git a/test/controllers/admin/candidate_controller/index_test.rb b/test/controllers/admin/candidate_controller/index_test.rb new file mode 100644 index 0000000..cd6ebe0 --- /dev/null +++ b/test/controllers/admin/candidate_controller/index_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +require 'test_helper' + +module Admin + class CandidateControllerTest < ActionDispatch::IntegrationTest + test "should require auth or redirect" do + get admin_candidates_url + assert_redirected_to admin_login_url + + get admin_new_candidate_url + assert_redirected_to admin_login_url + + post admin_create_candidate_url, params: { candidate: { name: 'foo', email: 'bar', experience: 'baz' } } + assert_redirected_to admin_login_url + end + + test "should get candidate list" do + auth_recruiter + get admin_candidates_url + assert_response :success + assert assigns(:candidates), "@candidates not present" + end + + test 'should have edit links' do + auth_recruiter + get admin_candidates_url + assert_response :success + assert_select "a[href='#{admin_edit_candidate_path(candidates(:martha))}']" + end + end +end diff --git a/test/controllers/admin/candidate_controller/new_candidate_test.rb b/test/controllers/admin/candidate_controller/new_candidate_test.rb new file mode 100644 index 0000000..c59f58c --- /dev/null +++ b/test/controllers/admin/candidate_controller/new_candidate_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true +require 'test_helper' + +module Admin + class CandidateControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + + test "should get new" do + auth_recruiter + get admin_new_candidate_url + assert_response :success + assert assigns(:candidate), "@candidate not present" + end + + test "should get create" do + auth_recruiter + get admin_create_candidate_url + assert_response :success + end + + test "should create new candidate" do + auth_recruiter + + assert_enqueued_jobs 2 do + assert_difference("Candidate.count") do + post admin_create_candidate_path, params: { candidate: + { name: 'new name', email: 'test@mailinator.com', experience: '0-3', quiz_id: quizzes(:fed).id } } + end + end + assert_redirected_to admin_candidates_path + assert flash[:success] + end + + test "should fail creation with improper email format" do + auth_recruiter + + assert_enqueued_jobs 0 do + assert_difference("Candidate.count", 0) do + post admin_create_candidate_path, params: { candidate: + { name: 'new name', email: 'test@mailinatorcom', experience: '0-3', quiz_id: quizzes(:fed).id } } + end + end + assert :success + assert assigns(:candidate), "@candidate not present" + assert_match(/failed.*save/i, flash[:error]) + end + + test "should fail creation gracefully with empty email" do + auth_recruiter + + assert_enqueued_jobs 0 do + assert_difference("Candidate.count", 0) do + post admin_create_candidate_path, params: { candidate: + { name: 'new name', email: "", experience: '0-3', quiz_id: quizzes(:fed).id } } + end + end + assert :success + assert assigns(:candidate), "@candidate not present" + assert_match(/failed.*save/i, flash[:error]) + end + + test 'should queue up a welcome email [resend]' do + auth_recruiter + + assert_enqueued_jobs 1 do + get admin_resend_welcome_path(id: candidates(:peggy)), xhr: true + end + assert_response :success + data = JSON.parse(response.body) + assert_match 'queued', data["message"] + end + end +end diff --git a/test/controllers/admin/candidate_controller/update_candidate_test.rb b/test/controllers/admin/candidate_controller/update_candidate_test.rb new file mode 100644 index 0000000..e4b2779 --- /dev/null +++ b/test/controllers/admin/candidate_controller/update_candidate_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +require 'test_helper' + +module Admin + class CandidateControllerTest < ActionDispatch::IntegrationTest + test 'should edit candidate' do + auth_recruiter + candidate = candidates(:martha) + + get admin_edit_candidate_path(candidate.id) + assert_response :success + assert_select 'form' + end + + test 'should update candidate, but NOT test_hash' do + auth_recruiter + candidate = candidates(:martha) + post admin_update_candidate_url(id: candidate.id), params: + { candidate: { name: 'new name', email: "mail@martha.me", test_hash: 'SOMENEWSTRING' } } + + refute_equal candidate.name, Candidate.find_by(id: candidate.id).name + assert_equal candidate.test_hash, Candidate.find_by(id: candidate.id).test_hash + assert_redirected_to admin_candidates_url + end + + test 'should redirect to form on fail' do + auth_recruiter + candidate = candidates(:martha) + post admin_update_candidate_url(id: candidate.id), params: + { candidate: { name: 'new name', email: "mail@martha" } } + + assert :success + assert_match(/failed.*save/i, flash[:error]) + end + end +end diff --git a/test/controllers/admin/dashboard_controller_test.rb b/test/controllers/admin/dashboard_controller_test.rb new file mode 100644 index 0000000..a0cc4e7 --- /dev/null +++ b/test/controllers/admin/dashboard_controller_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true +require 'test_helper' + +module Admin + class DashboardControllerTest < ActionDispatch::IntegrationTest + test "dashboard should require auth" do + get admin_url + assert_redirected_to admin_login_url + end + + test "should get dashboard" do + post admin_auth_url, params: { auth: + { email: 'alan.admin@mailinator.com', password: 'password' } } + get admin_users_url + assert_response :success + end + + test "admin should redirect to users" do + auth_admin + get admin_url + + assert_redirected_to admin_users_path + end + + test "manager should redirect to quizzes" do + auth_manager + get admin_url + + assert_redirected_to admin_quizzes_path + end + + test "reviewer should redirect to results" do + auth_reviewer + get admin_url + + assert_redirected_to admin_results_path + end + + test "recruiter should redirect to candidates" do + auth_recruiter + get admin_url + + assert_redirected_to admin_candidates_path + end + end +end diff --git a/test/controllers/admin/profile_controller_test.rb b/test/controllers/admin/profile_controller_test.rb new file mode 100644 index 0000000..ce3e01e --- /dev/null +++ b/test/controllers/admin/profile_controller_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +require 'test_helper' + +module Admin + class ProfileControllerTest < ActionDispatch::IntegrationTest + def setup + post admin_auth_url, params: { auth: + { email: 'alan.admin@mailinator.com', password: 'password' } } + end + + test "should get view" do + get admin_profile_url + assert_response :success + end + + test "should get edit" do + get admin_edit_profile_url + assert_response :success + end + + test "should post update" do + post admin_profile_url, params: { user: { name: 'bobby tables' } } + assert_redirected_to admin_profile_url + assert flash[:success] + end + + test "should FAIL update" do + post admin_profile_url, params: { user: { name: '' } } + assert :success + assert flash[:error] + end + end +end diff --git a/test/controllers/admin/question_controller_test.rb b/test/controllers/admin/question_controller_test.rb new file mode 100644 index 0000000..767eada --- /dev/null +++ b/test/controllers/admin/question_controller_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true +require 'test_helper' + +module Admin + class QuestionControllerTest < ActionDispatch::IntegrationTest + def setup + post admin_auth_url, params: { auth: + { email: 'alan.admin@mailinator.com', password: 'password' } } + end + + test "should get index" do + get admin_questions_url + assert_response :success + assert assigns :questions + end + + test "should get new" do + get admin_new_question_url + assert_response :success + assert assigns :question + end + + test "should fail create" do + assert_difference("Question.count", 0) do + post admin_create_question_url, params: { question: { question: 'foo bar baz' } } + end + assert :success + assert_match(/failed/i, session[:flash].values.join) + end + + test "should fail to create without quiz id" do + assert_difference("Question.count", 0) do + post admin_create_question_url, params: { question: + { question: 'foo bar baz', category: 'ops', input_type: 'text' } } + end + assert :success + assert_match(/failed/i, session[:flash].values.join) + end + + test "should post create" do + assert_difference("Question.count", 1) do + post admin_create_question_url, params: { question: + { quiz_id: quizzes(:fed).to_i, question: 'foo bar baz', category: 'ops', input_type: 'text' } } + end + assert_redirected_to admin_questions_url + end + + test "should get view" do + get admin_question_url questions(:fed5).to_i + assert_response :success + assert assigns :question + end + + test "should get edit" do + get admin_edit_question_url questions(:fed5).to_i + assert_response :success + assert assigns :question + end + + test "should post update quiz" do + question = questions(:fed9) + post admin_update_question_url(question.to_i), params: { question: + { quiz_id: quizzes(:fed).to_i, question: 'foo bar baz', category: 'ops', input_type: 'text' } } + assert_redirected_to admin_question_path(question.to_i) + + get admin_question_path question.to_i + assert_select 'p', 'foo bar baz' + end + + test "should fail to update question" do + question = questions(:fed9) + post admin_update_question_url(question.to_i), params: { question: { question: nil } } + assert :success + assert_match(/failed/i, session[:flash].values.join) + end + + test "should gracefully fail input_type" do + get admin_question_option_form_url(input_type: 'fooBarBaz') + + assert :success + assigns :locals + end + + test "should return partial for new radio" do + get admin_question_option_form_url(input_type: 'radio') + + assert :success + assigns :locals + assert_select "input[id^=question_multi_choice_]" + end + end +end diff --git a/test/controllers/admin/quiz_controller_test.rb b/test/controllers/admin/quiz_controller_test.rb new file mode 100644 index 0000000..cc1b435 --- /dev/null +++ b/test/controllers/admin/quiz_controller_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true +require 'test_helper' + +module Admin + class QuizControllerTest < ActionDispatch::IntegrationTest + def setup + post admin_auth_url, params: { auth: + { email: 'alan.admin@mailinator.com', password: 'password' } } + end + + test "should get index" do + get admin_quizzes_url + assert_response :success + assert assigns :quizzes + end + + test "should get new" do + get admin_new_quiz_url + assert_response :success + assert assigns :quiz + end + + test "should fail create" do + assert_difference("Quiz.count", 0) do + post admin_create_quiz_url, params: { quiz: { dept: nil } } + end + assert :success + assert_match(/failed/i, session[:flash].values.join) + end + + test "should post create" do + assert_difference("Quiz.count", 1) do + post admin_create_quiz_url, params: { quiz: + { name: 'PDW Mobile team screening', unit: 'PDW', dept: 'MBL' } } + end + assert_redirected_to admin_quizzes_url + end + + test "should get view" do + quiz = quizzes :fed + get admin_quiz_url quiz.to_i + assert_response :success + assert_select 'p', quiz.dept + end + + test "should get edit" do + quiz = quizzes :fed + get admin_edit_quiz_url quiz.to_i + assert_response :success + assert_select "[value=?]", quiz.dept + end + + test "should post update quiz" do + quiz = quizzes(:fed) + post admin_update_quiz_url(quiz.to_i), params: { quiz: { dept: 'new', unit: 'another' } } + assert_redirected_to admin_quiz_path(quiz.to_i) + + get admin_quiz_path quiz.to_i + assert_select 'p', 'another' + end + + test "should fail to update quiz" do + quiz = quizzes(:fed) + post admin_update_quiz_url(quiz.to_i), params: { quiz: { dept: nil } } + assert :success + assert_match(/failed/i, session[:flash].values.join) + end + end +end diff --git a/test/controllers/admin/result_controller_test.rb b/test/controllers/admin/result_controller_test.rb new file mode 100644 index 0000000..699517d --- /dev/null +++ b/test/controllers/admin/result_controller_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +require 'test_helper' + +module Admin + class ResultControllerTest < ActionDispatch::IntegrationTest + test "should get results list" do + auth_reviewer + get admin_results_url + assert_response :success + assert assigns(:candidates), '@candidates not present' + end + + test "should get view" do + auth_reviewer + + get admin_result_url(candidates(:richard).test_hash) + assert_response :success + assert assigns(:candidate), "@candidate not present" + assert assigns(:quiz), "@quiz not present" + assert assigns(:status), "@status not present" + end + end +end diff --git a/test/controllers/admin/user_controller_test.rb b/test/controllers/admin/user_controller_test.rb new file mode 100644 index 0000000..8498ffc --- /dev/null +++ b/test/controllers/admin/user_controller_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +require 'test_helper' + +module Admin + class UserControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + + test "should get index" do + auth_admin + get admin_users_url + assert_response :success + assert assigns :users + end + + test "should get new" do + auth_admin + get admin_new_user_url + assert_response :success + assert assigns :user + end + + test "should fail create" do + auth_admin + assert_difference("User.count", 0) do + post admin_create_user_url, params: { user: { name: 'New User' } } + end + assert :success + assert_match(/failed/i, session[:flash].values.join) + end + + test "should post create" do + auth_admin + assert_enqueued_jobs 1 do + assert_difference("User.count", 1) do + post admin_create_user_url, params: { user: + { email: 'new.user@mailinator.com', name: 'New User', role: 'reviewer' } } + end + end + assert_redirected_to admin_users_url + end + + test "should get view" do + auth_admin + user = users(:recruiter) + get admin_user_url user.to_i + assert_response :success + assert_select 'p', user.name + end + + test "should get edit" do + auth_admin + user = users(:recruiter) + get admin_edit_user_url user.to_i + assert_response :success + assert_select "[value=?]", user.name + end + + test "should post update user" do + auth_admin + user = users(:recruiter) + post admin_update_user_url(user.to_i), params: { user: { name: 'new name' } } + assert_redirected_to admin_user_path(user.to_i) + + get admin_user_url user.to_i + assert_select 'p', 'new name' + end + + test "should fail to update user" do + auth_admin + user = users(:recruiter) + post admin_update_user_url(user.to_i), params: { user: { name: nil } } + assert :success + assert_match(/failed/i, session[:flash].values.join) + end + + test 'non admin can not change own role' do + auth_recruiter + user = users(:recruiter) + post admin_update_user_url(user.to_i), params: { user: { name: 'new name', role: 'foobar' } } + + assert_equal user.role, User.find_by(id: user.id).role + end + end +end diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb new file mode 100644 index 0000000..6666e23 --- /dev/null +++ b/test/controllers/admin_controller_test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +require 'test_helper' + +class AdminControllerTest < ActionDispatch::IntegrationTest + test 'should provide flash mesage when access is denied' do + auth_reviewer + get admin_candidates_path + + assert_redirected_to admin_login_path + assert_match 'not authorized', flash[:error] + end +end diff --git a/test/controllers/application_controller_test.rb b/test/controllers/application_controller_test.rb new file mode 100644 index 0000000..189e330 --- /dev/null +++ b/test/controllers/application_controller_test.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +require 'test_helper' + +class ApplicationControllerTest < ActionDispatch::IntegrationTest + test "should get styleguide" do + get styleguide_path + + assert :success + end +end diff --git a/test/controllers/candidate_controller_test.rb b/test/controllers/candidate_controller_test.rb new file mode 100644 index 0000000..276199d --- /dev/null +++ b/test/controllers/candidate_controller_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true +require 'test_helper' + +class CandidateControllerTest < ActionDispatch::IntegrationTest + test "should get login" do + get login_path + assert_response :success + refute flash[:error].present?, "Should not be displaying an error message" + end + + test "should require auth and redirect" do + get saved_path + assert_redirected_to login_path + + get thankyou_path + assert_redirected_to login_path + end + + test "should auth to welcome" do + auth_candidate candidates(:martha) + + assert_redirected_to welcome_path + assert session[:test_id].present? + refute flash[:error].present?, "Should not be displaying an error message" + end + + test "should auth directly to welcome" do + get login_path(candidates(:martha).test_hash) + + assert_redirected_to welcome_path + assert session[:test_id].present? + refute flash[:error].present?, "Should not be displaying an error message" + end + + test "should display welcome view" do + auth_candidate candidates(:martha) + get welcome_path + + assert_select '.prft-heading', "Welcome!" + end + + test "should display welcome back view" do + auth_candidate candidates(:roy) + get welcome_path + + assert_select '.prft-heading', "Welcome Back" + end + + test "should redirect to thankyou when completed" do + auth_candidate candidates(:richard) + + assert_redirected_to thankyou_path + end + + test 'should reset session' do + auth_candidate candidates(:dawn) + get thankyou_path + + assert :success + assert session[:test_id].nil? + end +end diff --git a/test/controllers/quiz_controller/get_question_test.rb b/test/controllers/quiz_controller/get_question_test.rb new file mode 100644 index 0000000..80f3613 --- /dev/null +++ b/test/controllers/quiz_controller/get_question_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +require 'test_helper' + +class QuizControllerTest < ActionDispatch::IntegrationTest + test "should require auth and redirect" do + get question_path + assert_redirected_to login_path + + get question_path(questions(:fed1).id) + assert_redirected_to login_path + end + + test "should redirect to next question on next" do + auth_candidate candidates(:roy) + qid = questions(:fed3).id + params = { submit: 'Next', answer: { question_id: qid, answer_hash: { text: 'stuff' } } } + post post_answer_path, params: params + + assert_redirected_to question_path + assert session[:test_id].present? + assert assigns(:question), '@question not present' + assert assigns(:answer), '@answer not present' + assert assigns(:status), '@status not present' + end +end diff --git a/test/controllers/quiz_controller/post_answer_test.rb b/test/controllers/quiz_controller/post_answer_test.rb new file mode 100644 index 0000000..f6b4a9b --- /dev/null +++ b/test/controllers/quiz_controller/post_answer_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +require 'test_helper' + +class QuizControllerTest < ActionDispatch::IntegrationTest + test "should redirect to saved on save" do + auth_candidate candidates(:dawn) + qid = questions(:fed5).id + post post_answer_path, params: { save: 'Save', answer: { question_id: qid, answer: 'an option' } } + + assert_redirected_to saved_path + assert session[:test_id].present? + end + + test "should get flash message on bad radio response" do + auth_candidate candidates(:dawn) + qid = questions(:fed5).id + post post_answer_path, params: { answer: { question_id: qid, answer: nil } } + + assert_response :success + assert session[:test_id].present? + assert_equal qid, flash[:error] + assert assigns(:question), '@question not present' + assert assigns(:answer), '@answer not present' + end + + test "should get flash message on bad text response" do + auth_candidate candidates(:dawn) + qid = questions(:fed4).id + post post_answer_path, params: { answer: { question_id: qid, answer: nil } } + + assert_response :success + assert session[:test_id].present? + assert_equal qid, flash[:error] + assert assigns(:question), '@question not present' + assert assigns(:answer), '@answer not present' + end + + test "should process checkbox" do + auth_candidate candidates(:dawn) + qid = questions(:fed10).id + post post_answer_path, params: { answer: { question_id: qid, answer_array: 'an-option' } } + + assert_response :success + assert session[:test_id].present? + assert assigns(:question), '@question not present' + assert assigns(:answer), '@answer not present' + end + + test 'should handle XHR update and complete progress' do + auth_candidate candidates(:peggy) + qid = questions(:fed10).id + post post_answer_path, xhr: true, params: { answer: { question_id: qid, answer_array: ['an-option'] } } + + assert_response :success + assert_match(/updated successfully/, JSON.parse(@response.body)['message']) + assert_equal 100, JSON.parse(@response.body)['progress'] + assert assigns(:question), '@question not present' + assert assigns(:answer), '@answer not present' + end + + test 'should handle XHR fail' do + auth_candidate candidates(:peggy) + qid = questions(:fed10).id + post post_answer_path, xhr: true, params: { answer: { question_id: qid, answer_array: [nil] } } + + assert_response 400 + assert_match(/select.*answer/i, JSON.parse(@response.body).join) + assert assigns(:question), '@question not present' + assert assigns(:answer), '@answer not present' + end +end diff --git a/test/controllers/quiz_controller/summary_test.rb b/test/controllers/quiz_controller/summary_test.rb new file mode 100644 index 0000000..1142466 --- /dev/null +++ b/test/controllers/quiz_controller/summary_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +require 'test_helper' + +class QuizControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + + test 'summary should require auth and redirect' do + get summary_path + assert_redirected_to login_path + end + + test "should get summary" do + auth_candidate candidates :dawn + get summary_path + + assert_response :success + assert assigns(:quiz), '@quiz not present' + end + + test "should get summary if complete but not submitted" do + auth_candidate candidates(:dawn) + + get summary_url + assert_response :success + end + + test "should redirect from summary" do + auth_candidate candidates :roy + get summary_path + + assert_redirected_to question_path + end + + test "should NOT send mailers on submission" do + auth_candidate candidates(:dawn) + + assert_enqueued_jobs 0 do + post post_summary_path + end + assert_redirected_to summary_path + assert_match 'must complete', flash[:error] + end + + test "should send mailers on submission" do + auth_candidate candidates(:peggy) + + assert_enqueued_jobs 3 do + post post_summary_path + end + assert_redirected_to thankyou_path + end +end diff --git a/test/fixtures/answers.yml b/test/fixtures/answers.yml index 4297a9b..9c3455a 100644 --- a/test/fixtures/answers.yml +++ b/test/fixtures/answers.yml @@ -3,25 +3,25 @@ roy1: candidate: roy question: fed1 - answer: option2 + answer: Nullam id dolor id nibh ultricies vehicula ut id elit. Sed posuere consectetur est at lobortis. Nullam id dolor id nibh ultricies vehicula ut id elit. Sed posuere consectetur est at lobortis. saved: 0 submitted: true - created_at: <%= DateTime.now() - 90.minutes %> - updated_at: <%= DateTime.now() - 90.minutes %> + created_at: <%= DateTime.now() - 90.minutes - 36.hours %> + updated_at: <%= DateTime.now() - 90.minutes - 36.hours %> roy2: candidate: roy question: fed2 - answer: ["option-1", "option-4", "option5"] + answer: ['indexOf()', 'inArray()'] saved: 1 submitted: true - created_at: <%= DateTime.now() - 85.minutes %> - updated_at: <%= DateTime.now() - 85.minutes %> + created_at: <%= DateTime.now() - 85.minutes - 36.hours %> + updated_at: <%= DateTime.now() - 85.minutes - 36.hours %> dawn1: candidate: dawn question: fed1 - answer: option-1 + answer: Sed posuere consectetur est at lobortis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. saved: 0 submitted: true created_at: <%= DateTime.now() - 38.hours - 50.minutes %> @@ -30,7 +30,7 @@ dawn1: dawn2: candidate: dawn question: fed2 - answer: ["option2", "option-4"] + answer: ["indexOf()"] saved: 0 submitted: true created_at: <%= DateTime.now() - 38.hours - 50.minutes %> @@ -39,7 +39,7 @@ dawn2: dawn3: candidate: dawn question: fed3 - answer: {html: "

    I'm a little tealpot

    ", css: 'h1: {color: teal;}', js: ''} + answer: {html: "

    I'm a little tealpot

    ", css: 'h1 {color: teal;}', js: '', text: 'I did this because reasons.'} saved: 0 submitted: true created_at: <%= DateTime.now() - 38.hours - 50.minutes %> @@ -57,7 +57,7 @@ dawn4: dawn5: candidate: dawn question: fed5 - answer: "option 3" + answer: 'Dynamic listeners' saved: 0 submitted: true created_at: <%= DateTime.now() - 38.hours - 50.minutes %> @@ -75,7 +75,7 @@ dawn6: dawn7: candidate: dawn question: fed7 - answer: {html: '

    This means jQuery needs to be available in live-coder!

    ', css: 'strong: {font-size: 1.6em;} green: {color: green;}', js: '$("strong").addClass("green");'} + answer: {html: '

    This means jQuery needs to be available in live-coder!

    ', css: "strong {font-size: 1.6em;}\n.green {color: green;}", js: '$("strong").addClass("green");'} saved: 0 submitted: true created_at: <%= DateTime.now() - 38.hours - 34.minutes %> @@ -84,7 +84,9 @@ dawn7: dawn8: candidate: dawn question: fed8 - answer: option2 + answer: + options: + - option2 saved: 0 submitted: true created_at: <%= DateTime.now() - 38.hours - 38.minutes %> @@ -93,7 +95,10 @@ dawn8: dawn9: candidate: dawn question: fed9 - answer: Grunt + answer: + other: + options: + - Grunt saved: 0 submitted: true created_at: <%= DateTime.now() - 38.hours - 38.minutes %> @@ -108,9 +113,107 @@ dawn10: created_at: <%= DateTime.now() - 38.hours - 40.minutes %> updated_at: <%= DateTime.now() - 38.hours - 20.minutes %> +peggy1: + candidate: peggy + question: fed1 + answer: Donec sed odio dui. Etiam porta sem malesuada magna mollis euismod. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 50.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 50.minutes %> + +peggy2: + candidate: peggy + question: fed2 + answer: ['indexOf()'] + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 50.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 50.minutes %> + +peggy3: + candidate: peggy + question: fed3 + answer: {html: "

    I'm a little tealpot

    ", css: 'h1 {color: teal;}', js: '', text: 'I like turtles.'} + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 50.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 50.minutes %> + +peggy4: + candidate: peggy + question: fed4 + answer: Vestibulum id ligula porta felis euismod semper. Sed posuere consectetur est at lobortis. + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 50.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 50.minutes %> + +peggy5: + candidate: peggy + question: fed5 + answer: 'Dynamic listeners' + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 50.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 50.minutes %> + +peggy6: + candidate: peggy + question: fed6 + answer: Integer posuere erat a ante venenatis dapibus posuere velit aliquet. + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 32.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 12.minutes %> + +peggy7: + candidate: peggy + question: fed7 + answer: {html: '

    This means jQuery needs to be available in live-coder!

    ', css: "strong {font-size: 1.6em;}\n.green {color: green;}", js: '$("strong").addClass("green");'} + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 34.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 14.minutes %> + +peggy8: + candidate: peggy + question: fed8 + answer: + other: Some generic user input + options: + - other + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 38.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 16.minutes %> + +peggy9: + candidate: peggy + question: fed9 + answer: + other: npm + options: + - Grunt + - other + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 38.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 18.minutes %> + +peggy10: + candidate: peggy + question: fed10 + answer: ["Live long and prosper", "Who you calling Scruffy?"] + saved: 1 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 40.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 20.minutes %> + + richard1: candidate: richard - question: fed1 + question: Cras justo odio, dapibus ac facilisis in, egestas eget quam. Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Nullam id dolor id nibh ultricies vehicula ut id elit. answer: option 3 saved: 0 submitted: true @@ -120,7 +223,7 @@ richard1: richard2: candidate: richard question: fed2 - answer: [option-1, option2, option5] + answer: 'indexOf()' saved: 0 submitted: true created_at: <%= DateTime.now() - 36.hours - 24.minutes %> @@ -129,7 +232,7 @@ richard2: richard3: candidate: richard question: fed3 - answer: {html: '

    Salmon

    ', css: 'h1: {color: salmon;}', js: ''} + answer: {html: '

    Salmon

    ', css: 'h1 {color: salmon;}', js: '', text: 'Gotta lotta GOOD things on sale, strangah.'} saved: 0 submitted: true created_at: <%= DateTime.now() - 36.hours - 26.minutes %> @@ -147,7 +250,7 @@ richard4: richard5: candidate: richard question: fed5 - answer: option-1 + answer: 'Dynamic listeners' saved: 0 submitted: true created_at: <%= DateTime.now() - 36.hours - 30.minutes %> @@ -165,7 +268,7 @@ richard6: richard7: candidate: richard question: fed7 - answer: {html: '

    This means jQuery needs to be available in live-coder!

    ', css: 'strong: {font-size: 1.6em;} green: {color: green;}', js: '$("strong").addClass("green");'} + answer: {html: '

    This means jQuery needs to be available in live-coder!

    ', css: "strong {font-size: 1.6em;}\n.green {color: green;}", js: '$("strong").addClass("green");'} saved: 0 submitted: true created_at: <%= DateTime.now() - 36.hours - 34.minutes %> @@ -174,7 +277,10 @@ richard7: richard8: candidate: richard question: fed8 - answer: option2 + answer: + other: Some generic user input + options: + - other saved: 0 submitted: true created_at: <%= DateTime.now() - 36.hours - 36.minutes %> @@ -183,7 +289,11 @@ richard8: richard9: candidate: richard question: fed9 - answer: Grunt + answer: + other: Brunch + options: + - Neither + - other saved: 0 submitted: true created_at: <%= DateTime.now() - 36.hours - 38.minutes %> @@ -198,3 +308,99 @@ richard10: created_at: <%= DateTime.now() - 36.hours - 40.minutes %> updated_at: <%= DateTime.now() - 36.hours - 20.minutes %> +juan1: + candidate: juan + question: fed1 + answer: Sed posuere consectetur est at lobortis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 50.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 50.minutes %> + +juan2: + candidate: juan + question: fed2 + answer: ["indexOf()"] + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 50.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 50.minutes %> + +juan3: + candidate: juan + question: fed3 + answer: {later: "true"} + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 50.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 50.minutes %> + +juan4: + candidate: juan + question: fed4 + answer: Vestibulum id ligula porta felis euismod semper. Sed posuere consectetur est at lobortis. + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 50.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 50.minutes %> + +juan5: + candidate: juan + question: fed5 + answer: 'Dynamic listeners' + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 50.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 50.minutes %> + +juan6: + candidate: juan + question: fed6 + answer: Integer posuere erat a ante venenatis dapibus posuere velit aliquet. + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 32.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 12.minutes %> + +juan7: + candidate: juan + question: fed7 + answer: {later: "true"} + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 34.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 14.minutes %> + +juan8: + candidate: juan + question: fed8 + answer: + other: Some generic user input + options: + - other + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 38.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 16.minutes %> + +juan9: + candidate: juan + question: fed9 + answer: + other: Mimosa + options: + - other + saved: 0 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 38.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 18.minutes %> + +juan10: + candidate: juan + question: fed10 + answer: 'wibbly wobbly, timey wimey' + saved: 1 + submitted: true + created_at: <%= DateTime.now() - 38.hours - 40.minutes %> + updated_at: <%= DateTime.now() - 38.hours - 20.minutes %> + diff --git a/test/fixtures/candidates.yml b/test/fixtures/candidates.yml index c5c8342..c55d18f 100644 --- a/test/fixtures/candidates.yml +++ b/test/fixtures/candidates.yml @@ -1,41 +1,71 @@ # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html -roy: +roy: # Roy should have started, and is ready for a reminder name: Roy Cruz - email: roy.cruz@mailinator.com + email: <%= CryptSerializer.dump 'roy.cruz@mailinator.com' %> experience: 0-3 - recruiter: reviewer + recruiter: recruiter quiz: fed completed: false reminded: false test_hash: NmEjDkOEKY4 -martha: - name: Martha Watts - email: martha.watts@mailinator.com +gillian: # Gillian has not begun the test + name: Gillian Anderson + email: <%= CryptSerializer.dump 'gillian.anderson@mailinator.com' %> experience: 4-6 - recruiter: reviewer + recruiter: recruiter + quiz: fed + completed: false + reminded: false + test_hash: fbBOCmkhVC0 + +martha: # Martha has not begun the test + name: Martha Watts + email: <%= CryptSerializer.dump 'martha.watts@mailinator.com' %> + experience: 4-6 + recruiter: recruiter quiz: fed completed: false reminded: false test_hash: R67PmfDHGiw -dawn: +dawn: # Dawn has completed, and been reminded, but not submitted the test name: Dawn Hopkins - email: dawn.hopkins@mailinator.com + email: <%= CryptSerializer.dump 'dawn.hopkins@mailinator.com' %> experience: 0-2 - recruiter: reviewer + recruiter: recruiter quiz: fed completed: false reminded: true test_hash: OvP0ZqGKwJ0 -richard: +peggy: # Peggy has completed, and been reminded, but not submitted the test + name: Peggy Blisters + email: <%= CryptSerializer.dump 'peggy.blisters@mailinator.com' %> + experience: 0-2 + recruiter: recruiter + quiz: fed + completed: false + reminded: true + test_hash: 242a9d5d085 + +richard: # Richard has completed AND submitted the test name: Richard Burns - email: richard.burns@mailinator.com + email: <%= CryptSerializer.dump 'richard.burns@mailinator.com' %> experience: 15+ - recruiter: reviewer + recruiter: recruiter quiz: fed completed: true reminded: false test_hash: 6NjnourLE6Y + +juan: # Juan has chosen "finish later" for live coders + name: Juan Campbell + email: <%= CryptSerializer.dump 'juan.campbell@mailinator.com' %> + experience: 15+ + recruiter: recruiter + quiz: fed + completed: false + reminded: true + test_hash: <%= CryptSerializer.dump 'qKQo0l4dyol diff --git a/test/fixtures/questions.yml b/test/fixtures/questions.yml index 8ecfbc1..97c7d00 100644 --- a/test/fixtures/questions.yml +++ b/test/fixtures/questions.yml @@ -2,19 +2,22 @@ fed1: quiz: fed - question: Select an example of media query from below. + question: 'You have a bit of text that you want to be available for screen readers but not visible on the screen, so you set it with CSS to “visibility: hidden”. Is this the best decision?' category: CSS - input_type: radio - input_options: [ option-1, option2, "option 3"] + input_type: text + input_options: sort: 0 active: true fed2: quiz: fed - question: What are some ways you can improve a site's performance (load time)? - category: Performance + question: What function can be used to determine if a value is present in a given array? + category: JavaScript input_type: checkbox - input_options: [ option-1, option2, "option 3", option-4, option5] + input_options: + - 'indexOf()' + - 'present()' + - 'inArray()' sort: 1 active: true @@ -22,15 +25,15 @@ fed3: quiz: fed question: How would you create a widget that would fit in a 250px wide area as well as a 400px wide area? category: CSS - input_type: live-coder + input_type: live_code input_options: sort: 2 active: true fed4: quiz: fed - question: Briefly explain the principles of Progressive Enhancement. - category: Performance + question: Does your app/website need a doctype? Why or why not? + category: HTML input_type: text input_options: sort: 3 @@ -38,16 +41,21 @@ fed4: fed5: quiz: fed - question: Which is your favorite grid system? - category: CSS + question: What is the best way to track user interaction within a group of dynamically generated child elements? + category: JavaScript input_type: radio - input_options: [ option-1, option2, "option 3"] + input_options: + - 'Dynamic listeners' + - 'Event delegation' + - 'Callback functions' + - 'HTML data attributes' sort: 4 active: true fed6: quiz: fed - question: List one advantage of using IDs over classes. List one disadvantage. + question: Comment on how realistic the following image is. + attachment: "http://dev.perficientxd.com/skill_assets/commets_css.jpg" category: CSS input_type: text input_options: @@ -58,8 +66,10 @@ fed7: quiz: fed question: Provide a code example to manipulate the DOM using jQuery/JavaScript to change the classname of a div 'classB' to 'classC', only if the div 'classA' exists in the page? category: Javascript - input_type: live-coder + input_type: live_code input_options: + :html: "

    Sample seed HTML

    " + :css: "body { color: #644; }" sort: 6 active: true @@ -67,8 +77,11 @@ fed8: quiz: fed question: Select the HTML from below that would create an input field which restricts the number of characters inside it to 10. category: HTML - input_type: radio - input_options: [ option-1, option2, "option 3", option-4] + input_type: radio_other + input_options: + - option-1 + - option2 + - "option 3" sort: 7 active: true @@ -76,8 +89,12 @@ fed9: quiz: fed question: Grunt or Gulp? category: Javascript - input_type: radio - input_options: [Grunt, Gulp, Neither, Either] + input_type: checkbox_other + input_options: + - Grunt + - Gulp + - Neither + - Either sort: 8 active: true @@ -86,6 +103,50 @@ fed10: question: StarWars or Star Trek? category: Performance input_type: checkbox - input_options: ["Live long and prosper", "Who you calling Scruffy?", "J.J. Abrams"] + input_options: + - "Live long and prosper" + - "Who ya gonna call?" + - "Who you calling Scruffy?" + - "J.J. Abrams" + - "wibbly wobbly, timey wimey" sort: 9 active: true + +fed11: + quiz: fed + question: nope, nope nope? + category: Performance + input_type: checkbox + input_options: + - "Live long and prosper" + - "Who ya gonna call?" + - "Who you calling Scruffy?" + - "J.J. Abrams" + - "wibbly wobbly, timey wimey" + sort: 91 + active: false + +fed12: + quiz: fed + question: yet, another old fake question + category: Performance + input_type: checkbox + input_options: + - "Live long and prosper" + - "Who ya gonna call?" + - "Who you calling Scruffy?" + - "J.J. Abrams" + - "wibbly wobbly, timey wimey" + sort: 91 + active: false + + +admin1: + quiz: admin + question: 'You have a question you want to ask.' + category: Admin + input_type: text + input_options: + sort: 0 + active: true + diff --git a/test/fixtures/quizzes.yml b/test/fixtures/quizzes.yml index 87dc32d..f4bd5fa 100644 --- a/test/fixtures/quizzes.yml +++ b/test/fixtures/quizzes.yml @@ -1,5 +1,11 @@ # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html fed: + name: PDR Standard FED Screening unit: PDR dept: FED + +admin: + name: An extra quiz not assigned to anyone + unit: PDR + dept: NOPE diff --git a/test/fixtures/reviewer_to_quizzes.yml b/test/fixtures/reviewer_to_quizzes.yml new file mode 100644 index 0000000..4e8812f --- /dev/null +++ b/test/fixtures/reviewer_to_quizzes.yml @@ -0,0 +1,9 @@ +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: reviewer + quiz: fed + +two: + user: reviewer2 + quiz: fed diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 54ff4ec..94e21e3 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -12,9 +12,20 @@ reviewer: password_digest: <%= BCrypt::Password.create("password", cost: 4) %> role: reviewer +reviewer2: + name: David Reviewer + email: david.reviewer@mailinator.com + password_digest: <%= BCrypt::Password.create("password", cost: 4) %> + role: reviewer + +manager: + name: Mary Manager + email: mary.manager@mailinator.com + password_digest: <%= BCrypt::Password.create("password", cost: 4) %> + role: manager + admin: name: Alan Admin email: alan.admin@mailinator.com password_digest: <%= BCrypt::Password.create("password", cost: 4) %> role: admin - diff --git a/test/integration/question_attachments_test.rb b/test/integration/question_attachments_test.rb new file mode 100644 index 0000000..84b4215 --- /dev/null +++ b/test/integration/question_attachments_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +require 'test_helper' + +class QuestionAttachmentsTest < ActionDispatch::IntegrationTest + test "should show attachments on question" do + auth_candidate candidates(:dawn) + + get question_path questions(:fed6) + assert_response :success + assert_select '.question-text', questions(:fed6).question + assert_select "img[src=\"#{questions(:fed6).attachment}\"]" + end + + test "should show attachments on summary" do + auth_candidate candidates(:dawn) + + get summary_path + assert_response :success + assert_select "img[src=\"#{questions(:fed6).attachment}\"]" + end + + test "should show attachments on review" do + auth_reviewer + + get admin_result_path(candidates(:richard).test_hash) + assert_response :success + assert_select "img[src=\"#{questions(:fed6).attachment}\"]" + end +end diff --git a/test/integration/question_flow_test.rb b/test/integration/question_flow_test.rb new file mode 100644 index 0000000..3820a1d --- /dev/null +++ b/test/integration/question_flow_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +require 'test_helper' + +class QuestionFlowTest < ActionDispatch::IntegrationTest + test "should load the first question" do + auth_candidate candidates(:martha) + + get question_path + assert_response :success + assert_select '.question-text', questions(:fed1).question + end + + test "should load the summary" do + auth_candidate candidates(:dawn) + + get summary_path + assert_response :success + assert_select '.prft-heading', 'Almost done!' + end + + test "can load specific question from summary" do + auth_candidate candidates(:dawn) + question = questions(:fed4) + + get question_path(question.id) + assert_response :success + assert_select '.question-text', question.question + end + + test 'juan should be on summary with 80% complete' do + auth_candidate candidates(:juan) + + get summary_path + assert_response :success + assert_select '.progress span', '80%' + end +end diff --git a/test/integration/question_live_coder_test.rb b/test/integration/question_live_coder_test.rb new file mode 100644 index 0000000..c61f18f --- /dev/null +++ b/test/integration/question_live_coder_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require 'test_helper' + +class QuestionLiveCoderTest < ActionDispatch::IntegrationTest + test "can load a live coder question" do + auth_candidate candidates(:dawn) + question = questions(:fed7) + + get question_path(question.id) + assert_response :success + assert_select '.question-text', question.question + end + + test "should load seed data into live coder" do + auth_candidate candidates(:juan) + question = questions(:fed7) + + get question_path(question.id) + assert_response :success + assert_select '#answer_answer_hash_html', question.input_options['html'] + end +end diff --git a/test/jobs/application_job_test.rb b/test/jobs/application_job_test.rb new file mode 100644 index 0000000..2de3271 --- /dev/null +++ b/test/jobs/application_job_test.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +require 'test_helper' + +class ApplicationJobTest < ActiveSupport::TestCase + test 'should exists' do + assert ApplicationJob.new + end +end diff --git a/test/mailers/candidate_mailer_test.rb b/test/mailers/candidate_mailer_test.rb new file mode 100644 index 0000000..00817fd --- /dev/null +++ b/test/mailers/candidate_mailer_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +require 'test_helper' + +class CandidateMailerTest < ActionMailer::TestCase + test "welcome" do + candidate = candidates(:martha) + mail = CandidateMailer.welcome candidate + assert_match(/skills assessment test/i, mail.subject) + assert_equal [candidate.email], mail.to + assert_equal [ENV["default_mail_from"]], mail.from + assert_match candidate.test_hash, mail.body.encoded + end + + test "reminder" do + candidate = candidates(:roy) + mail = CandidateMailer.reminder candidate + assert_match(/skills assessment test/i, mail.subject) + assert_equal [candidate.email], mail.to + assert_equal [ENV["default_mail_from"]], mail.from + assert_match candidate.test_hash, mail.body.encoded + assert_match candidate.recruiter.email, mail.body.encoded + end + + test "submitted" do + candidate = candidates(:dawn) + mail = CandidateMailer.submitted candidate + assert_match(/skills assessment test/i, mail.subject) + assert_equal [candidate.email], mail.to + assert_equal [ENV["default_mail_from"]], mail.from + assert_match candidate.name, mail.body.encoded + end +end diff --git a/test/mailers/previews/candidate_mailer_preview.rb b/test/mailers/previews/candidate_mailer_preview.rb new file mode 100644 index 0000000..888625e --- /dev/null +++ b/test/mailers/previews/candidate_mailer_preview.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +# Preview all emails at http://localhost:3000/rails/mailers/candidate_mailer +class CandidateMailerPreview < ActionMailer::Preview + def welcome + CandidateMailer.welcome Candidate.find_by(test_hash: 'R67PmfDHGiw') # Martha + end + + def reminder + CandidateMailer.reminder Candidate.find_by(test_hash: 'NmEjDkOEKY4') # Roy + end + + def submitted + CandidateMailer.submitted Candidate.find_by(test_hash: 'OvP0ZqGKwJ0') # Dawn + end +end diff --git a/test/mailers/previews/recruiter_mailer_preview.rb b/test/mailers/previews/recruiter_mailer_preview.rb new file mode 100644 index 0000000..9a14fb3 --- /dev/null +++ b/test/mailers/previews/recruiter_mailer_preview.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +# Preview all emails at http://localhost:3000/rails/mailers/recruiter_mailer +class RecruiterMailerPreview < ActionMailer::Preview + def candidate_created + RecruiterMailer.candidate_created Candidate.find_by(test_hash: 'R67PmfDHGiw') # Martha + end + + def candidate_submitted + RecruiterMailer.candidate_submitted Candidate.find_by(test_hash: 'OvP0ZqGKwJ0') # Dawn + end +end diff --git a/test/mailers/previews/reviewer_mailer_preview.rb b/test/mailers/previews/reviewer_mailer_preview.rb new file mode 100644 index 0000000..df8b1cb --- /dev/null +++ b/test/mailers/previews/reviewer_mailer_preview.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +# Preview all emails at http://localhost:3000/rails/mailers/reviewer_mailer +class ReviewerMailerPreview < ActionMailer::Preview + def candidate_submission + ReviewerMailer.candidate_submission Candidate.find_by(test_hash: 'OvP0ZqGKwJ0') # Dawn + end +end diff --git a/test/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb new file mode 100644 index 0000000..27b0d13 --- /dev/null +++ b/test/mailers/previews/user_mailer_preview.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +# Preview all emails at http://localhost:3000/rails/mailers/user_mailer +class UserMailerPreview < ActionMailer::Preview + def password_reset + user = User.find_by(email: 'alan.admin@mailinator.com') + UserMailer.password_reset user + end + + def welcome + user = User.find_by(email: 'alan.admin@mailinator.com') + UserMailer.welcome user, '[default-password]' + end +end diff --git a/test/mailers/recruiter_mailer_test.rb b/test/mailers/recruiter_mailer_test.rb new file mode 100644 index 0000000..5331ca9 --- /dev/null +++ b/test/mailers/recruiter_mailer_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require 'test_helper' + +class RecruiterMailerTest < ActionMailer::TestCase + test "candidate_created" do + candidate = candidates :martha + mail = RecruiterMailer.candidate_created candidate + assert_match candidate.name, mail.subject + assert_equal [candidate.recruiter.email], mail.to + assert_equal [ENV["default_mail_from"]], mail.from + assert_match candidate.test_hash, mail.body.encoded + end + + test "candidate_submitted" do + candidate = candidates :dawn + mail = RecruiterMailer.candidate_submitted candidate + assert_match candidate.name, mail.subject + assert_equal [candidate.recruiter.email], mail.to + assert_equal [ENV["default_mail_from"]], mail.from + assert_match candidate.name, mail.body.encoded + end +end diff --git a/test/mailers/reviewer_mailer_test.rb b/test/mailers/reviewer_mailer_test.rb new file mode 100644 index 0000000..6241752 --- /dev/null +++ b/test/mailers/reviewer_mailer_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +require 'test_helper' + +class ReviewerMailerTest < ActionMailer::TestCase + test "candidate_submission" do + candidate = candidates :dawn + mail = ReviewerMailer.candidate_submission candidate + assert_match "Results", mail.subject + assert_equal candidate.quiz.reviewers.map(&:email), mail.to + assert_equal [ENV["default_mail_from"]], mail.from + assert_match candidate.test_hash, mail.body.encoded + end +end diff --git a/test/mailers/user_mailer_test.rb b/test/mailers/user_mailer_test.rb new file mode 100644 index 0000000..4ef0c09 --- /dev/null +++ b/test/mailers/user_mailer_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +require 'test_helper' + +class UserMailerTest < ActionMailer::TestCase + test "password_reset" do + user = users(:admin) + user.setup_reset + mail = UserMailer.password_reset user + + assert_equal [user.email], mail.to + assert_match user.reset_token, mail.body.encoded + end + + test "welcome" do + user = users(:admin) + mail = UserMailer.welcome user, 'p0o9i8u7' + + assert_equal [user.email], mail.to + assert_match 'p0o9i8u7', mail.body.encoded + end +end diff --git a/test/models/answer_test.rb b/test/models/answer_test.rb index c0af5c3..3950320 100644 --- a/test/models/answer_test.rb +++ b/test/models/answer_test.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true require 'test_helper' class AnswerTest < ActiveSupport::TestCase diff --git a/test/models/candidate_test.rb b/test/models/candidate_test.rb index 85798b8..7cb0f5d 100644 --- a/test/models/candidate_test.rb +++ b/test/models/candidate_test.rb @@ -1,7 +1,27 @@ +# frozen_string_literal: true require 'test_helper' class CandidateTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "test_hash is auto generated" do + candidate = Candidate.create(name: 'new name', + email: 'test@mailinator.com', + experience: '0-3', + quiz_id: quizzes(:fed).id) + + assert candidate.test_hash.present? + end + + test "should encrypt emails" do + email = 'test@mailinator.com' + candidate = Candidate.create(name: 'new name', + email: email, + experience: '0-3', + recruiter_id: users(:recruiter).id, + quiz_id: quizzes(:fed).id) + + sql = "select email from candidates where id = #{candidate.id};" + enc_email = ActiveRecord::Base.connection.execute(sql).first.first + + refute_equal email, enc_email + end end diff --git a/test/models/question_test.rb b/test/models/question_test.rb index 88f6ea7..13e958e 100644 --- a/test/models/question_test.rb +++ b/test/models/question_test.rb @@ -1,7 +1,22 @@ +# frozen_string_literal: true require 'test_helper' class QuestionTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test 'should compact arrays for input_options' do + question = Question.new(quiz_id: quizzes(:fed).to_i, + question: 'foo', + category: 'bar', + input_type: 'radio', + input_options: ['one', 'two', '', ' ', nil]) + question.validate + + assert_equal 2, question.input_options.count + end + + test 'should have seed input for live_coder' do + question = Question.find questions(:fed7).to_i + + assert_kind_of Hash, question.input_options + assert question.input_options.keys.include? :html + end end diff --git a/test/models/quiz_test.rb b/test/models/quiz_test.rb index f0c73ff..63a5253 100644 --- a/test/models/quiz_test.rb +++ b/test/models/quiz_test.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true require 'test_helper' class QuizTest < ActiveSupport::TestCase diff --git a/test/models/reviewer_to_quiz_test.rb b/test/models/reviewer_to_quiz_test.rb new file mode 100644 index 0000000..43282af --- /dev/null +++ b/test/models/reviewer_to_quiz_test.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +require 'test_helper' + +class ReviewerToQuizTest < ActiveSupport::TestCase + test "the truth" do + assert ReviewerToQuiz + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 82f61e0..bc372e4 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -1,7 +1,82 @@ +# frozen_string_literal: true require 'test_helper' class UserTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test 'admin should act as any role' do + user = users(:admin) + + assert user.acts_as_admin? + assert user.acts_as_manager? + assert user.acts_as_recruiter? + assert user.acts_as_reviewer? + end + + test 'admin should only be admin' do + user = users(:admin) + + assert user.admin? + refute user.manager? + refute user.recruiter? + refute user.reviewer? + end + + test 'manager should act as manager' do + user = users(:manager) + + assert user.acts_as_manager? + + refute user.acts_as_admin? + refute user.acts_as_recruiter? + refute user.acts_as_reviewer? + end + + test 'manager should only be manager' do + user = users(:manager) + + assert user.manager? + + refute user.admin? + refute user.recruiter? + refute user.reviewer? + end + + test 'recruiter should act as recruiter' do + user = users(:recruiter) + + assert user.acts_as_recruiter? + + refute user.acts_as_manager? + refute user.acts_as_admin? + refute user.acts_as_reviewer? + end + + test 'recruiter should only be recruiter' do + user = users(:recruiter) + + assert user.recruiter? + + refute user.admin? + refute user.manager? + refute user.reviewer? + end + + test 'reviewer should act as reviewer' do + user = users(:reviewer) + + assert user.acts_as_reviewer? + + refute user.acts_as_manager? + refute user.acts_as_admin? + refute user.acts_as_recruiter? + end + + test 'reviewer should only be reviewer' do + user = users(:reviewer) + + assert user.reviewer? + + refute user.admin? + refute user.manager? + refute user.recruiter? + end end diff --git a/test/policies/application_policy_test.rb b/test/policies/application_policy_test.rb new file mode 100644 index 0000000..66337a9 --- /dev/null +++ b/test/policies/application_policy_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +require 'test_helper' + +class ApplicationPolicyTest < PolicyAssertions::Test + # Verify default policies are most restrictive + + test 'should require a user' do + assert_raise Pundit::NotAuthorizedError do + ApplicationPolicy.new(nil, User.new) + end + end + + test 'should not allow collections' do + assert_raise Pundit::NotAuthorizedError do + ApplicationPolicy::Scope.new(users(:admin), User).resolve + end + end + + test 'should not permit by default' do + admin = users(:admin) + refute ApplicationPolicy.new(admin, User.new).view? + refute ApplicationPolicy.new(admin, User.new).show? + refute ApplicationPolicy.new(admin, nil).index? + refute ApplicationPolicy.new(admin, nil).create? + refute ApplicationPolicy.new(admin, nil).new? + refute ApplicationPolicy.new(admin, nil).update? + refute ApplicationPolicy.new(admin, nil).edit? + refute ApplicationPolicy.new(admin, nil).destroy? + end +end diff --git a/test/policies/candidate_policy_test.rb b/test/policies/candidate_policy_test.rb new file mode 100644 index 0000000..5124ec8 --- /dev/null +++ b/test/policies/candidate_policy_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true +require 'test_helper' + +class CandidatePolicyTest < PolicyAssertions::Test + test 'should require current_user' do + assert_raise Pundit::NotAuthorizedError do + CandidatePolicy.new(nil, Candidate.first).view? + end + end + + test 'should allow admin to scope' do + scope = CandidatePolicy::Scope.new(users(:admin), Candidate).resolve + assert_equal Candidate.count, scope.count + end + + test 'should allow recruiter to scope' do + scope = CandidatePolicy::Scope.new(users(:recruiter), Candidate).resolve + assert_equal Candidate.count, scope.count + end + + test 'reviewer CAN NOT scope candidates' do + assert_raise Pundit::NotAuthorizedError do + CandidatePolicy::Scope.new(users(:reviewer), Candidate).resolve + end + end + + test 'manager CAN NOT scope candidates' do + assert_raise Pundit::NotAuthorizedError do + CandidatePolicy::Scope.new(users(:manager), Candidate).resolve + end + end + + def test_view_and_update + assert_permit users(:admin), candidates(:roy) + assert_permit users(:recruiter), candidates(:roy) + + refute_permit users(:manager), candidates(:roy) + refute_permit users(:reviewer), candidates(:roy) + end + + def test_create + assert_permit users(:admin), Candidate + assert_permit users(:recruiter), Candidate + + refute_permit users(:manager), Candidate + refute_permit users(:reviewer), Candidate + end +end diff --git a/test/policies/dashboard_policy_test.rb b/test/policies/dashboard_policy_test.rb new file mode 100644 index 0000000..93d0b78 --- /dev/null +++ b/test/policies/dashboard_policy_test.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +require 'test_helper' + +class DashboardPolicyTest < PolicyAssertions::Test + def test_show + assert_permit users(:recruiter), :dashboard + end +end diff --git a/test/policies/question_policy_test.rb b/test/policies/question_policy_test.rb new file mode 100644 index 0000000..f8173db --- /dev/null +++ b/test/policies/question_policy_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +require 'test_helper' + +class QuestionPolicyTest < PolicyAssertions::Test + test 'should require current_user' do + assert_raise Pundit::NotAuthorizedError do + QuestionPolicy.new(nil, Question.first).view? + end + end + + test 'should allow admin to scope' do + scope = QuestionPolicy::Scope.new(users(:admin), Question).resolve + assert_equal Question.count, scope.count + end + + test 'should allow manager to scope' do + scope = QuestionPolicy::Scope.new(users(:manager), Question).resolve + assert_equal Question.count, scope.count + end + + test 'should allow reviewer to scope' do + quiz_ids = users(:reviewer).quizzes.map(&:id) + + scope = QuestionPolicy::Scope.new(users(:reviewer), Question).resolve + assert_equal Question.where(quiz_id: quiz_ids).count, scope.count + end + + test 'should NOT allow recruiter to scope' do + assert_raise Pundit::NotAuthorizedError do + QuestionPolicy::Scope.new(users(:recruiter), Question).resolve + end + end + + def test_view_and_options + assert_permit users(:admin), questions(:fed1) + assert_permit users(:manager), questions(:fed1) + assert_permit users(:reviewer), questions(:fed1) + + refute_permit users(:reviewer), questions(:admin1) + refute_permit users(:recruiter), questions(:fed1) + end + + def test_create_and_update + assert_permit users(:admin), Question + assert_permit users(:manager), Question + + refute_permit users(:recruiter), Question + refute_permit users(:reviewer), Question + end +end diff --git a/test/policies/quiz_policy_test.rb b/test/policies/quiz_policy_test.rb new file mode 100644 index 0000000..e5eb7be --- /dev/null +++ b/test/policies/quiz_policy_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +require 'test_helper' + +class QuizPolicyTest < PolicyAssertions::Test + test 'should require current_user' do + assert_raise Pundit::NotAuthorizedError do + QuizPolicy.new(nil, Quiz.first).view? + end + end + + test 'should allow admin to scope' do + scope = QuizPolicy::Scope.new(users(:admin), Quiz).resolve + assert_equal Quiz.count, scope.count + end + + test 'should allow manager to scope' do + scope = QuizPolicy::Scope.new(users(:manager), Quiz).resolve + assert_equal Quiz.count, scope.count + end + + test 'should allow reviewer to scope' do + scope = QuizPolicy::Scope.new(users(:reviewer), Quiz).resolve + assert_equal users(:reviewer).quizzes.count, scope.count + end + + test 'should allow recruiter to scope' do + scope = QuizPolicy::Scope.new(users(:recruiter), Quiz).resolve + assert_equal Quiz.count, scope.count + end + + def test_view + assert_permit users(:admin), quizzes(:fed) + assert_permit users(:manager), quizzes(:fed) + assert_permit users(:reviewer), quizzes(:fed) + + refute_permit users(:reviewer), quizzes(:admin) + refute_permit users(:recruiter), quizzes(:fed) + end + + def test_create_and_update + assert_permit users(:admin), Quiz + assert_permit users(:manager), Quiz + + refute_permit users(:recruiter), Quiz + refute_permit users(:reviewer), Quiz + end +end diff --git a/test/policies/user_policy_test.rb b/test/policies/user_policy_test.rb new file mode 100644 index 0000000..98f0dd3 --- /dev/null +++ b/test/policies/user_policy_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true +require 'test_helper' + +class UserPolicyTest < PolicyAssertions::Test + test 'should require current_user' do + assert_raise Pundit::NotAuthorizedError do + UserPolicy.new(nil, User.first).view? + end + end + + test 'should allow admin to scope' do + scope = UserPolicy::Scope.new(users(:admin), User).resolve + assert_equal User.count, scope.count + end + + test 'non admins can only scope themselves' do + %i(manager reviewer recruiter).each do |role| + scope = UserPolicy::Scope.new(users(role), User).resolve + assert_equal 1, scope.count, "Scope did not have 1 result for #{role}" + assert_equal users(role), scope.first, "Scope did not contain self for #{role}" + end + end + + # view? + test 'admin can view any user role' do + assert_permit users(:admin), users(:admin), 'view?' + assert_permit users(:admin), users(:manager), 'view?' + assert_permit users(:admin), users(:reviewer), 'view?' + assert_permit users(:admin), users(:recruiter), 'view?' + end + + test 'manager can only view herself' do + assert_permit users(:manager), users(:manager), 'view?' + + refute_permit users(:manager), users(:admin), 'view?' + refute_permit users(:manager), users(:reviewer), 'view?' + refute_permit users(:manager), users(:recruiter), 'view?' + end + + test 'reviewer can only view herself' do + assert_permit users(:reviewer), users(:reviewer), 'view?' + + refute_permit users(:reviewer), users(:admin), 'view?' + refute_permit users(:reviewer), users(:manager), 'view?' + refute_permit users(:reviewer), users(:recruiter), 'view?' + end + + test 'recruiter can only view herself' do + assert_permit users(:recruiter), users(:recruiter), 'view?' + + refute_permit users(:recruiter), users(:admin), 'view?' + refute_permit users(:recruiter), users(:manager), 'view?' + refute_permit users(:recruiter), users(:reviewer), 'view?' + end + + # update? + test 'admin can update any user role' do + assert_permit users(:admin), users(:admin), 'update?' + assert_permit users(:admin), users(:manager), 'update?' + assert_permit users(:admin), users(:reviewer), 'update?' + assert_permit users(:admin), users(:recruiter), 'update?' + end + + test 'manager can only update herself' do + assert_permit users(:manager), users(:manager), 'update?' + + refute_permit users(:manager), users(:admin), 'update?' + refute_permit users(:manager), users(:reviewer), 'update?' + refute_permit users(:manager), users(:recruiter), 'update?' + end + + test 'reupdateer can only update herself' do + assert_permit users(:reviewer), users(:reviewer), 'update?' + + refute_permit users(:reviewer), users(:admin), 'update?' + refute_permit users(:reviewer), users(:manager), 'update?' + refute_permit users(:reviewer), users(:recruiter), 'update?' + end + + test 'recruiter can only update herself' do + assert_permit users(:recruiter), users(:recruiter), 'update?' + + refute_permit users(:recruiter), users(:admin), 'update?' + refute_permit users(:recruiter), users(:manager), 'update?' + refute_permit users(:recruiter), users(:reviewer), 'update?' + end + + # create + test 'only admin can create users' do + assert_permit users(:admin), User, 'create?' + + refute_permit users(:manager), User, 'create?' + refute_permit users(:reviewer), User, 'create?' + refute_permit users(:recruiter), User, 'create?' + end +end diff --git a/test/services/crypt_serializer_test.rb b/test/services/crypt_serializer_test.rb new file mode 100644 index 0000000..2bcdd43 --- /dev/null +++ b/test/services/crypt_serializer_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +require 'test_helper' + +class CryptSerializerTest < ActiveSupport::TestCase + test "should generate marshaled array" do + string = "some string to encrypt" + encrypted = CryptSerializer.dump string + ar = Marshal.load(Base64.urlsafe_decode64(encrypted)) + + assert_instance_of Array, ar + assert_equal 3, ar.count + end + + test "should encrypt and dencrypt" do + string = "test@string.email" + encrypted = CryptSerializer.dump string + decrypted = CryptSerializer.load encrypted + + assert_equal string, decrypted + end + + test "must raise RuntimeError" do + assert_raises RuntimeError do + CryptSerializer.dump nil + end + end +end diff --git a/test/services/skill_config_test.rb b/test/services/skill_config_test.rb new file mode 100644 index 0000000..8d6fcff --- /dev/null +++ b/test/services/skill_config_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +require 'test_helper' + +class SkillConfigTest < ActiveSupport::TestCase + test "verify sample file exists" do + assert File.exist? "#{Rails.root}/config/application.yml.sample" + end + + test "verify config file exists" do + assert File.exist? "#{Rails.root}/config/application.yml" + end + + test 'config can load and return proper values' do + skonfig = SkillConfig.new + + assert_equal 'localhost', skonfig.mysql_host + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 5087494..65a1a33 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,9 +1,21 @@ +# frozen_string_literal: true ENV['RAILS_ENV'] ||= 'test' +# https://github.com/colszowka/simplecov +require 'simplecov' +SimpleCov.start 'rails' do + add_group 'Models', %w(app/models app/validators) + add_group 'Services & Workers', %w(app/workers app/services) + add_group "Jobs", 'app/jobs' + add_group "Policies", 'app/policies' +end + require File.expand_path('../../config/environment', __FILE__) require 'rails/test_help' -require "minitest/autorun" require 'minitest/reporters' +require 'policy_assertions' +Dir[Rails.root.join("test/test_helpers/**/*.rb")].each { |f| require f } + Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new(color: true)] class ActiveSupport::TestCase @@ -13,4 +25,5 @@ class ActiveSupport::TestCase fixtures :all # Add more helper methods to be used by all tests here... + include AuthTestHelper end diff --git a/test/test_helpers/README.txt b/test/test_helpers/README.txt new file mode 100644 index 0000000..037f183 --- /dev/null +++ b/test/test_helpers/README.txt @@ -0,0 +1,25 @@ +Use this folder to store mocks, stubs, etc to make isolated testing possible. + +Some definitions borrowed from: +http://martinfowler.com/articles/mocksArentStubs.html + +* Dummy + objects are passed around but never actually used. + Usually they are just used to fill parameter lists. + +* Fake + objects actually have working implementations, but usually + take some shortcut which makes them not suitable for + production (an in memory database is a good example). + +* Stubs + provide canned answers to calls made during the test, + usually not responding at all to anything outside what's + programmed in for the test. Stubs may also record information + about calls, such as an email gateway stub that remembers the + messages it 'sent', or maybe only how many messages it 'sent'. + +* Mocks + objects pre-programmed with expectations which form a + specification of the calls they are expected to receive. + diff --git a/test/test_helpers/answer_validatable.rb b/test/test_helpers/answer_validatable.rb new file mode 100644 index 0000000..e886459 --- /dev/null +++ b/test/test_helpers/answer_validatable.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +class AnswerValidatable + include ActiveModel::Validations + + attr_accessor :answer + attr_accessor :question + attr_accessor :question_id + + validates :answer, answer_format: true + + MockQuestion = Struct.new(:input_type) + + def initialize input_type, qid = nil + @input_type = input_type + @question_id = qid + end + + def question + MockQuestion.new(@input_type) + end +end diff --git a/test/test_helpers/auth_test_helper.rb b/test/test_helpers/auth_test_helper.rb new file mode 100644 index 0000000..152a7b6 --- /dev/null +++ b/test/test_helpers/auth_test_helper.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true +module AuthTestHelper + def auth_candidate candidate + post validate_candidate_url, params: { test_id: candidate.test_hash } + end + + def auth_user user + post admin_auth_url, params: { auth: + { email: user.email, password: 'password' } } + end + + def auth_admin + post admin_auth_url, params: { auth: + { email: 'alan.admin@mailinator.com', password: 'password' } } + end + + def auth_manager + post admin_auth_url, params: { auth: + { email: 'mary.manager@mailinator.com', password: 'password' } } + end + + def auth_recruiter + post admin_auth_url, params: { auth: + { email: 'pdr.recruiter@mailinator.com', password: 'password' } } + end + + def auth_reviewer + post admin_auth_url, params: { auth: + { email: 'fed.reviewer@mailinator.com', password: 'password' } } + end +end diff --git a/test/test_helpers/email_validatable.rb b/test/test_helpers/email_validatable.rb new file mode 100644 index 0000000..475d157 --- /dev/null +++ b/test/test_helpers/email_validatable.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class EmailValidatable + include ActiveModel::Validations + attr_accessor :email + validates :email, email_format: true +end diff --git a/test/test_helpers/input_options_validatable.rb b/test/test_helpers/input_options_validatable.rb new file mode 100644 index 0000000..b2673a7 --- /dev/null +++ b/test/test_helpers/input_options_validatable.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class InputOptionsValidatable + include ActiveModel::Validations + attr_accessor :input_type + attr_accessor :input_options + validates :input_options, input_options_presence: true +end diff --git a/test/validators/answer_format_validator/checkbox_other_test.rb b/test/validators/answer_format_validator/checkbox_other_test.rb new file mode 100644 index 0000000..702bb6f --- /dev/null +++ b/test/validators/answer_format_validator/checkbox_other_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true +require 'test_helper' + +# *_with_other answers expect a hash response: +# with_other: { other: 'TEXT-FIELD-VALUE', options: ['selected', 'answer', 'values'] } +class AnswerFormatValidatorTest < ActiveSupport::TestCase + test "checkbox_other should PASS with populated array" do + obj = AnswerValidatable.new('checkbox_other') + obj.answer = { other: nil, options: ['some', 'selections', 'not-other'] } + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "checkbox_other should PASS with other and value" do + obj = AnswerValidatable.new('checkbox_other') + obj.answer = { other: 'some random user input', options: ['other', 'another option'] } + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "checkbox_other should FAIL with nil" do + obj = AnswerValidatable.new('checkbox_other') + obj.answer = nil + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end + + test "checkbox_other should FAIL with nil options" do + obj = AnswerValidatable.new('checkbox_other') + obj.answer = { other: '', options: nil } + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end + + test "checkbox_other should FAIL with empty string" do + obj = AnswerValidatable.new('checkbox_other') + obj.answer = { other: '', options: [''] } + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end + + test "checkbox_other should FAIL with other selected and no value" do + obj = AnswerValidatable.new('checkbox_other') + obj.answer = { other: '', options: %w(other some more selections) } + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end + + test "checkbox_other should FAIL with array of empty strings" do + obj = AnswerValidatable.new('checkbox_other') + obj.answer = { other: 'This is an unselected value', options: ["", "", " "] } + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end +end diff --git a/test/validators/answer_format_validator/checkbox_test.rb b/test/validators/answer_format_validator/checkbox_test.rb new file mode 100644 index 0000000..29c1881 --- /dev/null +++ b/test/validators/answer_format_validator/checkbox_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +require 'test_helper' + +class AnswerFormatValidatorTest < ActiveSupport::TestCase + test "checkbox should PASS with populated array" do + obj = AnswerValidatable.new('checkbox') + obj.answer = ["", "", "valid answer"] + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "checkbox should FAIL with nil" do + obj = AnswerValidatable.new('checkbox') + obj.answer = nil + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end + + test "checkbox should FAIL with empty string" do + obj = AnswerValidatable.new('checkbox') + obj.answer = " " + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end + + test "checkbox should FAIL with array of empty strings" do + obj = AnswerValidatable.new('checkbox') + obj.answer = ["", "", " "] + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end +end diff --git a/test/validators/answer_format_validator/live_code_test.rb b/test/validators/answer_format_validator/live_code_test.rb new file mode 100644 index 0000000..c031c98 --- /dev/null +++ b/test/validators/answer_format_validator/live_code_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +require 'test_helper' + +class AnswerFormatValidatorTest < ActiveSupport::TestCase + test "live_code should PASS with populated hash" do + obj = AnswerValidatable.new('live_code') + obj.answer = { html: 'this is html', css: '', js: '', text: 'some reasons' } + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "live_code should PASS with finish later" do + obj = AnswerValidatable.new('live_code') + obj.answer = { later: "true" } + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "live_code should FAIL with nil" do + obj = AnswerValidatable.new('live_code') + obj.answer = nil + + refute obj.valid? + assert_match(/write.*code/, obj.errors.messages[:answer][0]) + end + + test "live_code should FAIL without checking finish later" do + obj = AnswerValidatable.new('live_code') + obj.answer = { later: "" } + + refute obj.valid? + assert_match(/come back/, obj.errors.messages[:answer][0]) + end + + test "live_code should FAIL without values" do + obj = AnswerValidatable.new('live_code') + obj.answer = { text: "", html: "", css: "", js: "" } + + refute obj.valid? + assert_match(/write.*code/, obj.errors.messages[:answer][0]) + end + + test "live_code should PASS with text only" do + obj = AnswerValidatable.new('live_code') + obj.answer = { html: "", css: "", js: "", text: "reasons" } + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "live_code should PASS using seed data" do + seeded_answer = questions(:fed7).input_options + obj = AnswerValidatable.new('live_code', questions(:fed7).id) + obj.answer = seeded_answer.merge(text: "no thanks", js: "") + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "live_code should FAIL with seed data only" do + seeded_answer = questions(:fed7).input_options + obj = AnswerValidatable.new('live_code', questions(:fed7).id) + obj.answer = seeded_answer.merge(text: "", js: "") + + refute obj.valid? + assert_match(/write.*code/, obj.errors.messages[:answer][0]) + end +end diff --git a/test/validators/answer_format_validator/radio_other_test.rb b/test/validators/answer_format_validator/radio_other_test.rb new file mode 100644 index 0000000..397e049 --- /dev/null +++ b/test/validators/answer_format_validator/radio_other_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +require 'test_helper' + +# *_with_other answers expect a hash answer: +# with_other: { other: 'TEXT-FIELD-VALUE', options: ['selected', 'answer', 'values'] } + +class AnswerFormatValidatorTest < ActiveSupport::TestCase + test "radio_other should PASS with selection" do + obj = AnswerValidatable.new('radio_other') + obj.answer = { other: nil, options: ['some-selection-not-other'] } + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "radio_other should PASS with other and value" do + obj = AnswerValidatable.new('radio_other') + obj.answer = { other: 'some random user input', options: ['other'] } + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "radio_other should FAIL with nil" do + obj = AnswerValidatable.new('radio_other') + obj.answer = nil + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end + + test "radio_other should FAIL with nil options" do + obj = AnswerValidatable.new('radio_other') + obj.answer = { other: '', options: nil } + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end + + test "radio_other should FAIL with empty string" do + obj = AnswerValidatable.new('radio_other') + obj.answer = { other: '', options: [''] } + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end + + test "radio_other should FAIL with other selected and no value" do + obj = AnswerValidatable.new('radio_other') + obj.answer = { other: '', options: ['other'] } + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end +end diff --git a/test/validators/answer_format_validator/radio_test.rb b/test/validators/answer_format_validator/radio_test.rb new file mode 100644 index 0000000..30141be --- /dev/null +++ b/test/validators/answer_format_validator/radio_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require 'test_helper' + +class AnswerFormatValidatorTest < ActiveSupport::TestCase + test "radio should pass with string" do + obj = AnswerValidatable.new('radio') + obj.answer = 'option-1' + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "radio should FAIL with nil" do + obj = AnswerValidatable.new('radio') + obj.answer = nil + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end + + test "radio should FAIL with empty string" do + obj = AnswerValidatable.new('radio') + obj.answer = '' + + refute obj.valid? + assert_match(/select.*answer/, obj.errors.messages[:answer][0]) + end +end diff --git a/test/validators/answer_format_validator/text_test.rb b/test/validators/answer_format_validator/text_test.rb new file mode 100644 index 0000000..e1dca24 --- /dev/null +++ b/test/validators/answer_format_validator/text_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true +require 'test_helper' + +class AnswerFormatValidatorTest < ActiveSupport::TestCase + test "text should PASS with string" do + obj = AnswerValidatable.new('text') + obj.answer = "this is a valid answer" + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "text should FAIL with nil" do + obj = AnswerValidatable.new('text') + obj.answer = nil + + refute obj.valid? + assert_match(/enter.*answer/, obj.errors.messages[:answer][0]) + end + + test "text should FAIL with empry string" do + obj = AnswerValidatable.new('text') + obj.answer = " " + + refute obj.valid? + assert_match(/enter.*answer/, obj.errors.messages[:answer][0]) + end + + test "text should PASS with 999 character" do + obj = AnswerValidatable.new('text') + obj.answer = long_string_with_returns + + assert obj.valid? + assert obj.errors.messages.empty? + end + + test "text should FAIL with more than 1000 character" do + obj = AnswerValidatable.new('text') + obj.answer = long_string_with_returns + " - to long now " + + refute obj.valid? + assert_match(/char.*limit.*1000.$/, obj.errors.messages[:answer][0]) + end + + def long_string_with_returns + # returns 999 chars, after \r is stripped. + "Some rando input\r\n\rYo. Making this up.\r\n" * 27 + end +end diff --git a/test/validators/email_format_validator_test.rb b/test/validators/email_format_validator_test.rb new file mode 100644 index 0000000..151d88e --- /dev/null +++ b/test/validators/email_format_validator_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +require 'test_helper' + +class EmailFormatValidatorTest < ActiveSupport::TestCase + test "tld length" do + obj = EmailValidatable.new + + obj.email = "me@no.yes.x" + refute obj.valid?, 'allowed single length tld' + + obj.email = "me@no.yes.co" + assert obj.valid?, 'did not allow tld length 2' + + obj.email = "me@no.yes.com" + assert obj.valid?, 'did not allow tld length 3' + + obj.email = "me@no.yes.commets" + assert obj.valid?, 'did not allow tld length > 3' + end + + test "can handle comma seperated addresses" do + obj = EmailValidatable.new + obj.email = "me@no.yes, me@yes.no" + + assert obj.valid?, 'did not allow multiple address [comma seperated]' + end + + test "provides proper error message" do + obj = EmailValidatable.new + obj.email = "this is a bad email address" + obj.valid? + + refute obj.errors.messages.empty?, 'needs an error message' + assert_match(/not formatted properly/, obj.errors.messages[:email].join) + end +end diff --git a/test/validators/input_options_presence_validator_test.rb b/test/validators/input_options_presence_validator_test.rb new file mode 100644 index 0000000..921c39e --- /dev/null +++ b/test/validators/input_options_presence_validator_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +require 'test_helper' + +class InputOptionsPresenceValidatorTest < ActiveSupport::TestCase + test "should pass when inpute type not radio or checkbox" do + obj = InputOptionsValidatable.new + obj.input_type = "text" + + assert obj.valid? + assert_equal 0, obj.errors.messages[:input_options].count + end + + test "should fail when missing options for radio" do + obj = InputOptionsValidatable.new + obj.input_type = "radio" + obj.input_options = nil + obj.valid? + + refute obj.errors.messages.empty?, 'needs an error message' + assert_match(/provide.*option/i, obj.errors.messages[:input_options].join) + end + + test "should pass when provided options for radio" do + obj = InputOptionsValidatable.new + obj.input_type = "radio" + obj.input_options = ['one', 'two', nil] + + assert obj.valid? + assert_equal 0, obj.errors.messages[:input_options].count + end + + test "should fail when missing options for checkbox" do + obj = InputOptionsValidatable.new + obj.input_type = "checkbox" + obj.input_options = nil + obj.valid? + + refute obj.errors.messages.empty?, 'needs an error message' + assert_match(/provide.*option/i, obj.errors.messages[:input_options].join) + end + + test "should pass when provided options for checkbox" do + obj = InputOptionsValidatable.new + obj.input_type = "checkbox" + obj.input_options = ['one', 'two', nil] + + assert obj.valid? + assert_equal 0, obj.errors.messages[:input_options].count + end +end diff --git a/test/workers/candidate_quiz_question_test.rb b/test/workers/candidate_quiz_question_test.rb new file mode 100644 index 0000000..ac967eb --- /dev/null +++ b/test/workers/candidate_quiz_question_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true +require 'test_helper' + +class CandidateQuizQuestionTest < ActiveSupport::TestCase + def setup + @row = { "candidate_id" => "12345", "quiz_id" => 9876, "question_id" => 5, "answer_id" => 6, + "input_type" => 'text', "question" => 'what now?', + "input_options" => %w(one two three).to_yaml, + "answer" => { test: 1, foo: 'bar', cheer: 'huzzah!' }.to_yaml, + "saved" => false, "submitted" => true, + "updated_at" => DateTime.parse('20160816').in_time_zone } + end + + test "propper dot attributes work" do + question = CandidateQuizQuestion.new @row + + assert_equal '12345', question.candidate_id + assert_equal 9876, question.quiz_id + assert_equal 5, question.question_id + assert_equal 6, question.answer_id + assert_equal 'what now?', question.question + assert_equal 'text', question.input_type + assert_equal false, question.saved + assert_equal true, question.submitted + assert_equal DateTime.parse('20160816').in_time_zone, question.updated_at + end + + test 'should handle array of input options' do + question = CandidateQuizQuestion.new @row + + assert_kind_of Array, question.input_options + end + + test "should handle string answer" do + question = CandidateQuizQuestion.new("answer" => "this is a text answer".to_yaml) + + assert_kind_of String, question.answer + end + + test "should handle array answer" do + question = CandidateQuizQuestion.new("answer" => %w(one two three four).to_yaml) + + assert_kind_of Array, question.answer + end + + test "should handle hash answer" do + question = CandidateQuizQuestion.new @row + + assert_kind_of Hash, question.answer + end +end diff --git a/test/workers/candidate_quiz_test.rb b/test/workers/candidate_quiz_test.rb new file mode 100644 index 0000000..f77267c --- /dev/null +++ b/test/workers/candidate_quiz_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +require 'test_helper' + +class CandidateQuizTest < ActiveSupport::TestCase + test "can get quiz question" do + fed8 = questions(:fed8) + + quiz = CandidateQuiz.new candidates(:richard) + fetched = quiz.fetch_question(fed8.id) + + assert_equal fed8.id, fetched.question_id + assert_equal fed8.question, fetched.question + end + + test "can build full quiz" do + quiz = CandidateQuiz.new(candidates(:richard)).build_my_quiz + + assert_equal questions(:fed1).question, quiz.first.question + assert_equal answers(:richard10).answer, quiz[9].answer + end +end diff --git a/test/workers/quiz_status_test.rb b/test/workers/quiz_status_test.rb index 8dffbe1..f79b299 100644 --- a/test/workers/quiz_status_test.rb +++ b/test/workers/quiz_status_test.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true require 'test_helper' class QuizStatusTest < ActiveSupport::TestCase @@ -56,4 +57,60 @@ class QuizStatusTest < ActiveSupport::TestCase assert status.can_submit end + + test "richard is at 100%" do + richard = candidates :richard + status = QuizStatus.new richard + + assert_equal 100, status.progress + end + + test "roy is 20% done" do + roy = candidates :roy + status = QuizStatus.new roy + + assert_equal 20, status.progress + end + + test "martha is 0% done" do + martha = candidates :martha + status = QuizStatus.new martha + + assert_equal 0, status.progress + end + + test "roy is on question 3" do + roy = candidates :roy + status = QuizStatus.new roy + + assert_equal questions(:fed3).id, status.current_question_id + end + + test "martha is on question 1" do + martha = candidates :martha + status = QuizStatus.new martha + + assert_equal questions(:fed1).id, status.current_question_id + end + + test "dawn is on summary" do + dawn = candidates :dawn + status = QuizStatus.new dawn + + assert_equal nil, status.current_question_id + end + + test "richard has no_finish_laters" do + richard = candidates :richard + status = QuizStatus.new richard + + assert status.no_finish_later + end + + test "juan has some finish_laters" do + juan = candidates :juan + status = QuizStatus.new juan + + refute status.no_finish_later + end end diff --git a/test/workers/reminder_test.rb b/test/workers/reminder_test.rb new file mode 100644 index 0000000..32dabbb --- /dev/null +++ b/test/workers/reminder_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +require 'test_helper' + +class ReminderTest < ActiveSupport::TestCase + test "collection is created with one result" do + reminders = Reminder.new + assert_equal 1, reminders.size + end + + test "each candidate has needed attributes" do + reminders = Reminder.new + + assert_instance_of String, reminders.candidates.first.name + assert_instance_of String, reminders.candidates.first.test_hash + assert_instance_of String, reminders.candidates.first.email + end + + test "send reminders sends email, and flags reminded" do + reminders = Reminder.new + pre_reminded = Candidate.find(reminders.candidates.first.id).reminded + + assert_difference("ActionMailer::Base.deliveries.size", reminders.count) do + reminders.send_all + end + refute_equal pre_reminded, Candidate.find(reminders.candidates.first.id).reminded + end +end diff --git a/vendor/assets/javascripts/.keep b/vendor/assets/javascripts/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/vendor/assets/stylesheets/.keep b/vendor/assets/stylesheets/.keep deleted file mode 100644 index e69de29..0000000