Commit a4d092e

mo <mo@mokhan.ca>
2018-12-15 17:06:21
move to karma-fixture instead of jasmine-fixture
1 parent cd0d12b
app/controllers/application_controller.rb
@@ -4,10 +4,17 @@ class ApplicationController < ActionController::Base
   include Authenticatable
   include Featurable
   protect_from_forgery with: :exception
+  around_action :apply_locale
   add_flash_types :error, :warning
 
   def render_error(status, model: nil)
     @model = model
     render template: "errors/#{status}", status: status
   end
+
+  def apply_locale
+    I18n.with_locale(current_user&.locale || I18n.default_locale) do
+      yield
+    end
+  end
 end
app/javascript/controllers/application_controller.js
@@ -28,6 +28,16 @@ export default class extends Controller {
     element.setAttribute('disabled', 'disabled');
   }
 
+  hide(element) {
+    if (element)
+      element.classList.add('hide');
+  }
+
+  show(element) {
+    if (element)
+      element.classList.remove('hide');
+  }
+
   log(message) {
     if (this.isDevelopment) {
       console.log(message); /* eslint-disable-line no-console */
app/javascript/controllers/input_controller.js
@@ -0,0 +1,74 @@
+import validator from 'validator';
+import ApplicationController from './application_controller';
+import FormValidation from '../models/form_validation';
+import I18n from '../i18n';
+
+export default class extends ApplicationController {
+  get form() { return this.element.closest('form'); }
+
+  get formValidation() { return new FormValidation(this.form); }
+
+  get submitButton() { return this.form.querySelector('[type=submit]'); }
+
+  changed() {
+    this.validate(this.element);
+
+    if (this.formValidation.valid()) super.enable(this.submitButton);
+  }
+
+  validate(element) {
+    const isRequired = element.getAttribute('required') === 'required';
+    if (isRequired && element.value.length === 0) {
+      return this.displayError(I18n.translate('js.form.errors.required'));
+    }
+
+    const isEmail = element.getAttribute('type') === 'email';
+    if (isEmail && !validator.isEmail(element.value)) {
+      return this.displayError(I18n.translate('js.form.errors.invalid'));
+    }
+
+    const minLength = element.getAttribute('minLength');
+    if (minLength && element.value.length < parseInt(minLength, 10)) {
+      return this.displayError(I18n.translate('js.form.errors.too_short'));
+    }
+
+    const maxLength = element.getAttribute('maxLength');
+    if (maxLength && element.value.length > parseInt(maxLength, 10)) {
+      return this.displayError(I18n.translate('js.form.errors.too_long'));
+    }
+
+    const isEqualTo = element.getAttribute('data-is-equal-to');
+    if (isEqualTo && document.querySelector(isEqualTo).value !== element.value) {
+      return this.displayError(I18n.translate('js.form.errors.confirmation'));
+    }
+
+    return this.hideError();
+  }
+
+  hideError() {
+    this.element.classList.remove('input--state-danger');
+
+    const { parentElement } = this.element;
+    super.hide(parentElement.querySelector('.help-block'));
+
+    const textElement = parentElement.querySelector('.help-block__text');
+    textElement.classList.remove('help-block__text--state-danger');
+    if (textElement) textElement.textContent = '';
+
+    this.data.set('valid', true);
+  }
+
+  displayError(message) {
+    this.element.classList.add('input--state-danger');
+
+    const { parentElement } = this.element;
+    super.show(parentElement.querySelector('.help-block'));
+
+    const textElement = parentElement.querySelector('.help-block__text');
+    textElement.classList.add('help-block__text--state-danger');
+    if (textElement) textElement.textContent = message;
+
+    this.data.set('valid', false);
+    super.disable(this.submitButton);
+  }
+}
app/javascript/models/form_validation.js
@@ -0,0 +1,13 @@
+export default class {
+  constructor(form) {
+    this.form = form;
+  }
+
+  valid() {
+    if (this.form.querySelectorAll('[data-input-valid="false"]').length > 0) return false;
+
+    if (this.form.querySelectorAll('[data-checkbox-valid="false"]').length > 0) return false;
+
+    return true;
+  }
+}
config/locales/en.yml
@@ -14,6 +14,14 @@ en:
       title: Forbidden
     not_found:
       title: 404 - Not Found
+  js:
+    form:
+      errors:
+        confirmation: doesn't match
+        invalid: is invalid
+        required: is required
+        too_long: is too long
+        too_short: is too short
   layouts:
     application:
       title: Proof
spec/fixtures/input.html
@@ -0,0 +1,13 @@
+<form action="#">
+  <div>
+    <input type="text" id="name" data-controller='input' data-action='keyup->input#changed' required="required" minLength=3 maxLength=9 />
+    <div id='name-help-block' class="help-block">
+      <span id=name-error class='help-block__text'></span>
+    </div>
+  </div>
+  <input type="email" id="email" data-controller='input' data-action='keyup->input#changed' required="required">
+  <input type="checkbox" id="accept" data-controller='input' data-action='keyup->input#changed' required="required">
+  <input type="password" id="password" data-controller='input' data-action='keyup->input#changed' required="required">
+  <input type="password" id="password_confirmation" data-controller='input' data-action='keyup->input#changed' required="required" data-is-equal-to="#password">
+  <button type="submit" id="submit-button">submit</button>
+</form>
spec/fixtures/mfa-setup.html
@@ -0,0 +1,6 @@
+<div data-controller='mfa--setup'>
+  <canvas data-target='mfa--setup.canvas'></canvas>
+  <form action="#">
+    <input type="hidden" data-target='mfa--setup.secret' value='secret'>
+  </form>
+</div>
spec/fixtures/sessions-new.html
@@ -0,0 +1,5 @@
+<form data-controller='sessions--new' action="#">
+  <input id='user_email' type="email" data-target='sessions--new.email' data-action='keyup->sessions--new#validate' >
+  <input id='user_password' type="password" data-target='sessions--new.password' data-action='keyup->sessions--new#validate' >
+  <button type="submit" data-target='sessions--new.submit'>submit</button>
+</form>
spec/javascripts/controllers/mfa/setup.spec.js
@@ -3,16 +3,19 @@ import { Application } from 'stimulus';
 
 describe('mfa--setup', () => {
   beforeEach(() => {
-    const $container = affix('div[data-controller="mfa--setup"]')
-    $container.affix('canvas[data-target="mfa--setup.canvas"]')
-    const $form = $container.affix('form')
-    $form.affix('input[type="hidden" data-target="mfa--setup.secret" value="secret"]')
+    fixture.setBase('spec/fixtures')
+    const el = fixture.load('mfa-setup.html')
+
     const application = new Application();
     application.router.start();
     application.dispatcher.start();
     application.register('mfa--setup', Controller);
   });
 
+  afterEach(() => {
+    fixture.cleanup();
+  });
+
   describe("connect", () => {
     it("displays a QR code representation of the secret", () => {
       expect($('canvas')).toBeDefined()
spec/javascripts/controllers/sessions/new.spec.js
@@ -3,16 +3,19 @@ import { Application } from 'stimulus';
 
 describe('sessions--new', () => {
   beforeEach(() => {
-    let $form = affix('form[data-controller="sessions--new"]')
-    $form.affix('input[data-target="sessions--new.email" data-action="keyup->sessions--new#validate" type="email" id="user_email"]')
-    $form.affix('input[data-target="sessions--new.password" data-action="keyup->sessions--new#validate" type="password" id="user_password"]')
-    $form.affix('button[name="button" type="submit" data-target="sessions--new.submit"]')
+    fixture.setBase('spec/fixtures')
+    const el = fixture.load('sessions-new.html')
+
     const application = new Application();
     application.router.start();
     application.dispatcher.start();
     application.register('sessions--new', Controller);
   });
 
+  afterEach(() => {
+    fixture.cleanup();
+  });
+
   describe("validate", () => {
     let emailField;
     let passwordField;
spec/javascripts/controllers/input.spec.js
@@ -0,0 +1,128 @@
+import Controller from '../../../app/javascript/controllers/input_controller'
+import { Application } from 'stimulus';
+
+describe('input', () => {
+  beforeEach(() => {
+    fixture.setBase('spec/fixtures')
+    const el = fixture.load('input.html')
+
+    const application = new Application();
+    application.router.start();
+    application.dispatcher.start();
+    application.register('input', Controller);
+  });
+
+  afterEach(() => {
+    fixture.cleanup();
+  });
+
+  it("displays an error message next to the field", () => {
+    const nameElement = document.querySelector('#name')
+    nameElement.value = ''
+    nameElement.dispatchEvent(new Event('keyup'));
+
+    const errorElement = document.querySelector('#name-error')
+    expect(errorElement.textContent).toEqual('is required')
+
+    const helpElement = document.querySelector('#name-help-block')
+    expect(helpElement.classList.contains('hide')).toEqual(false)
+  });
+
+  it("disables the submit button, one one field is valid and the other is not", () => {
+    let nameElement = document.querySelector('#name');
+    nameElement.value = '';
+    nameElement.dispatchEvent(new Event('keyup'));
+    expect(nameElement.getAttribute('data-input-valid')).toEqual('false')
+
+    let emailElement = document.querySelector('#email');
+    emailElement.value = 'test@example.org';
+    emailElement.dispatchEvent(new Event('keyup'));
+    expect(emailElement.getAttribute('data-input-valid')).toEqual('true')
+
+    const submitButton = document.querySelector('#submit-button')
+    expect(submitButton.getAttribute('disabled')).toEqual('disabled')
+  });
+
+  it('disables the submit button, when a field does not meet the minLength', () => {
+    document.querySelector('#email').value = 'test@example.org';
+    let nameElement = document.querySelector('#name');
+    nameElement.value = '12';
+    nameElement.dispatchEvent(new Event('keyup'));
+    expect(nameElement.getAttribute('data-input-valid')).toEqual('false')
+
+    const submitButton = document.querySelector('#submit-button')
+    expect(submitButton.getAttribute('disabled')).toEqual('disabled')
+  });
+
+  it('disables the submit button, when a field exceeds the maxLength', () => {
+    document.querySelector('#email').value = 'test@example.org';
+
+    let nameElement = document.querySelector('#name');
+    nameElement.value = '1234567890';
+    nameElement.dispatchEvent(new Event('keyup'));
+    expect(nameElement.getAttribute('data-input-valid')).toEqual('false')
+
+    const submitButton = document.querySelector('#submit-button')
+    expect(submitButton.getAttribute('disabled')).toEqual('disabled')
+  });
+
+  it("disables the submit button, when the password does not match the confirmation", () => {
+    document.querySelector('#email').value = 'test@example.org';
+    document.querySelector('#name').value = 'Tsuyoshi';
+
+    const passwordElement = document.querySelector('#password')
+    passwordElement.value = "PASSWORD"
+
+    const confirmationElement = document.querySelector('#password_confirmation')
+    confirmationElement.value = "NOT PASSWORD"
+    confirmationElement.dispatchEvent(new Event('keyup'));
+
+    expect(confirmationElement.getAttribute('data-input-valid')).toEqual('false')
+
+    const submitButton = document.querySelector('#submit-button')
+    expect(submitButton.getAttribute('disabled')).toEqual('disabled')
+  });
+
+  it("is invalid, when the email is not valid", () => {
+    const emailElement = document.querySelector('#email')
+    emailElement.value = 'invalid';
+    emailElement.dispatchEvent(new Event('keyup'));
+    expect(emailElement.getAttribute('data-input-valid')).toEqual('false')
+  });
+
+  it('enables the submit button, when the fields are valid', () => {
+    document.querySelector('#email').value = 'test@example.org';
+    document.querySelector('#password').value = 'password';
+    document.querySelector('#password_confirmation').value = 'password';
+
+    let nameElement = document.querySelector('#name');
+    nameElement.value = '';
+    nameElement.dispatchEvent(new Event('keyup'));
+
+    nameElement.value = 'Tsuyoshi';
+    nameElement.dispatchEvent(new Event('keyup'));
+    expect(nameElement.getAttribute('data-input-valid')).toEqual('true')
+
+    const submitButton = document.querySelector('#submit-button')
+    expect(submitButton.getAttribute('disabled')).toEqual(null)
+  });
+
+  it('hides error messages, when the fields are valid', () => {
+    document.querySelector('#email').value = 'test@example.org';
+    document.querySelector('#password').value = 'password';
+    document.querySelector('#password_confirmation').value = 'password';
+
+    let nameElement = document.querySelector('#name');
+    nameElement.value = '';
+    nameElement.dispatchEvent(new Event('keyup'));
+
+    nameElement.value = 'Tsuyoshi';
+    nameElement.dispatchEvent(new Event('keyup'));
+
+    const helpElement = document.querySelector('#name-help-block')
+    expect(helpElement.classList.contains('hide')).toEqual(true)
+
+    const errorElement = document.querySelector('#name-error')
+    expect(errorElement.textContent).toEqual('')
+  });
+});
spec/javascripts/i18n.spec.js
@@ -5,7 +5,7 @@ describe("I18n", () => {
     const subject = I18n;
 
     it("returns the correct translations for a nested value", () => {
-      const result = subject.translate('application.navbar.home')
+      const result = subject.translate('sessions.show.home')
       expect(result).toEqual('Home')
     });
   });
karma.conf.js
@@ -2,20 +2,28 @@
 // Generated on Sun Jan 28 2018 13:49:43 GMT-0700 (MST)
 
 module.exports = function(config) {
+  const tests = 'spec/javascripts/**/*.spec.js';
   config.set({
     basePath: '',
-    frameworks: ['jasmine'],
+    frameworks: ['jasmine', 'fixture'],
     files: [
-      'spec/javascripts/**/*.spec.js',
+      tests,
+      'spec/fixtures/**/*.html',
+      'spec/fixtures/**/*.json',
       'node_modules/jquery/dist/jquery.min.js',
       'node_modules/jasmine-fixture/dist/jasmine-fixture.min.js'
     ],
     exclude: [ ],
     preprocessors: {
       'app/javascript/packs/*.js': ['webpack', 'sourcemap'],
-      'spec/javascripts/**/*.spec.js': ['webpack', 'sourcemap']
+      '**/*.html': ['html2js'],
+      '**/*.json': ['json_fixtures'],
+      [tests]: ['webpack', 'sourcemap']
     },
     reporters: ['mocha'],
+    mochaReporter: {
+      output: 'autowatch'
+    },
     port: 9876,
     colors: true,
     logLevel: config.LOG_INFO,
package.json
@@ -7,7 +7,8 @@
     "lint:js": "eslint app/javascript/",
     "lint:scss": "stylelint -f unix --color app/javascript/styles/*.scss",
     "start": "./bin/webpack-dev-server",
-    "test": "NODE_ENV=test karma start"
+    "test": "NODE_ENV=test karma start",
+    "test:watch": "yarn test --no-single-run"
   },
   "dependencies": {
     "@rails/webpacker": "3.4",
@@ -20,7 +21,8 @@
     "rails-translations-webpack-plugin": "^1.1.0",
     "rails-ujs": "^5.2.1",
     "stimulus": "^1.1.0",
-    "turbolinks": "^5.2.0"
+    "turbolinks": "^5.2.0",
+    "validator": "^10.9.0"
   },
   "devDependencies": {
     "babel-eslint": "^9.0.0",
@@ -33,8 +35,11 @@
     "jasmine-fixture": "^2.0.0",
     "karma": "^2.0.0",
     "karma-chrome-launcher": "^2.2.0",
+    "karma-fixture": "^0.2.6",
+    "karma-html2js-preprocessor": "^1.1.0",
     "karma-jasmine": "^1.1.1",
     "karma-jquery": "^0.2.2",
+    "karma-json-fixtures-preprocessor": "^0.0.6",
     "karma-mocha-reporter": "^2.2.5",
     "karma-sourcemap-loader": "^0.3.7",
     "karma-webpack": "^3.0.0",
yarn.lock
@@ -4973,6 +4973,16 @@ karma-chrome-launcher@^2.2.0:
     fs-access "^1.0.0"
     which "^1.2.1"
 
+karma-fixture@^0.2.6:
+  version "0.2.6"
+  resolved "https://registry.yarnpkg.com/karma-fixture/-/karma-fixture-0.2.6.tgz#971cea8c216d73f07043964cb73f10e0830018ef"
+  integrity sha1-lxzqjCFtc/BwQ5ZMtz8Q4IMAGO8=
+
+karma-html2js-preprocessor@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/karma-html2js-preprocessor/-/karma-html2js-preprocessor-1.1.0.tgz#fc09edf04bbe2bb6eee9ba1968f826b7388020bd"
+  integrity sha1-/Ant8Eu+K7bu6boZaPgmtziAIL0=
+
 karma-jasmine@^1.1.1:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/karma-jasmine/-/karma-jasmine-1.1.2.tgz#394f2b25ffb4a644b9ada6f22d443e2fd08886c3"
@@ -4983,6 +4993,11 @@ karma-jquery@^0.2.2:
   resolved "https://registry.yarnpkg.com/karma-jquery/-/karma-jquery-0.2.3.tgz#e67107934d0b4047c206baa520e43c78d7e8411f"
   integrity sha1-5nEHk00LQEfCBrqlIOQ8eNfoQR8=
 
+karma-json-fixtures-preprocessor@^0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/karma-json-fixtures-preprocessor/-/karma-json-fixtures-preprocessor-0.0.6.tgz#4f78a2ebcd34387f8e55ababff634655164c4c76"
+  integrity sha1-T3ii6800OH+OVaur/2NGVRZMTHY=
+
 karma-mocha-reporter@^2.2.5:
   version "2.2.5"
   resolved "https://registry.yarnpkg.com/karma-mocha-reporter/-/karma-mocha-reporter-2.2.5.tgz#15120095e8ed819186e47a0b012f3cd741895560"
@@ -9385,6 +9400,11 @@ validate-npm-package-license@^3.0.1:
     spdx-correct "^3.0.0"
     spdx-expression-parse "^3.0.0"
 
+validator@^10.9.0:
+  version "10.9.0"
+  resolved "https://registry.yarnpkg.com/validator/-/validator-10.9.0.tgz#d10c11673b5061fb7ccf4c1114412411b2bac2a8"
+  integrity sha512-hZJcZSWz9poXBlAkjjcsNAdrZ6JbjD3kWlNjq/+vE7RLLS/+8PAj3qVVwrwsOz/WL8jPmZ1hYkRvtlUeZAm4ug==
+
 vary@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"