Commit a4d092e
Changed files (15)
app
controllers
javascript
controllers
models
config
locales
spec
fixtures
javascripts
controllers
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"