From a2ec376d03a1e4d4b6c6cc938879846f615007eb Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Wed, 22 May 2019 13:11:48 +0100 Subject: [PATCH 1/7] Make Hogan definition clearer in radioSelect JS It helps the tests to know the `Hogan` variable is actually a property of the global variable (`window` in this case) and doesn't hurt the readability of the script. --- app/assets/javascripts/radioSelect.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/radioSelect.js b/app/assets/javascripts/radioSelect.js index 802382736..02b2afc18 100644 --- a/app/assets/javascripts/radioSelect.js +++ b/app/assets/javascripts/radioSelect.js @@ -1,7 +1,10 @@ -(function(Modules) { +(function(global) { "use strict"; + var Modules = global.GOVUK.Modules; + var Hogan = global.Hogan; + let states = { 'initial': Hogan.compile(`
@@ -151,4 +154,4 @@ }; -})(window.GOVUK.Modules); +})(window); From 25b2414cec7e6962dbf03b6de98fd89b9010e6f8 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Wed, 22 May 2019 13:10:20 +0100 Subject: [PATCH 2/7] Add tests for radioSelect module --- tests/javascripts/radioSelect.test.js | 278 ++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 tests/javascripts/radioSelect.test.js diff --git a/tests/javascripts/radioSelect.test.js b/tests/javascripts/radioSelect.test.js new file mode 100644 index 000000000..1e93de4e6 --- /dev/null +++ b/tests/javascripts/radioSelect.test.js @@ -0,0 +1,278 @@ +const helpers = require('./support/helpers'); + +beforeAll(() => { + window.Hogan = require('hogan.js'); + require('../../app/assets/javascripts/radioSelect.js'); +}); + +afterAll(() => { + require('./support/teardown.js'); +}); + +describe('RadioSelect', () => { + const CATEGORIES = [ + 'Later today', + 'Tomorrow', + 'Friday', + 'Saturday' + ]; + const HOURS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]; + let originalOptionsForAllCategories; + + const getDataFromOption = (option) => { + return { + value: option.querySelector('input').getAttribute('value'), + label: option.querySelector('label').textContent.trim() + }; + }; + + const clickButtonForCategory = (category) => { + + // click the button for this category + const categoryButton = document.querySelector(`.radio-select-column:nth-child(2) input[value="${category}"]`); + helpers.triggerEvent(categoryButton, 'click'); + + }; + + beforeEach(() => { + const options = () => { + let result = ''; + + const getHourLabel = (hour) => { + let label = hour; + + if (hour === 12) { + return 'midday'; + } + + if (hour === 24) { + return 'midnight'; + } + + return `${hour}${hour > 12 ? 'am' : 'pm'}`; + }; + + const hours = (day, start) => { + let result = ''; + let hours = HOURS; + let dayAsNumber = { + 'Later today': 22, + 'Tomorrow': 23, + 'Friday': 24, + 'Saturday': 25 + }[day]; + + if (start !== undefined) { + hours = hours.slice(start - 1); + } + + hours.forEach((hour, idx) => { + const hourLabel = getHourLabel(hour); + + result += + `
+ + +
`; + }); + + return result; + }; + + CATEGORIES.forEach((day, idx) => { + if (idx === 0) { + result += hours(day, 11); + } else { + result += hours(day); + } + + return result; + }); + + return result; + }; + + document.body.innerHTML = ` +
+ + When should Notify send these messages? + +
+
+
+ + +
+
+
+ ${options()} +
+
+
`; + + originalOptionsForAllCategories = Array.from(document.querySelector('.radio-select-column:nth-child(2) .multiple-choice')) + .map(option => getDataFromOption(option)); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe("when the page has loaded it should have a button for each category", () => { + + let categoryButtons; + + beforeEach(() => { + + // start module + window.GOVUK.modules.start(); + + categoryButtons = document.querySelectorAll('.radio-select-column:nth-child(2) .js-category-button'); + + }); + + test("the number of buttons should match the categories", () => { + + expect(categoryButtons.length).toBe(CATEGORIES.length); + + }); + + test("each button's text should match their category", () => { + + // check the buttons have the right text + CATEGORIES.forEach((category, idx) => { + expect(categoryButtons[idx].getAttribute('value')).toEqual(category); + }); + + }); + + }); + + describe("category buttons", () => { + + CATEGORIES.forEach((category, idx) => { + + test(`clicking the button for ${category} should show the options for it, with the right label and value`, () => { + + // get all the options in the original page for this category + originalOptionsForCategory = originalOptionsForAllCategories.filter(option => option.label === category); + + // start module + window.GOVUK.modules.start(); + + clickButtonForCategory(category); + + // check options this reveals against those originally in the page for this category + const options = document.querySelector('.radio-select-column:nth-child(2) .multiple-choice'); + + const optionsThatMatchOriginals = Array.from(options).filter((option, idx) => { + const optionData = getDataFromOption(option); + const originalOption = originalOptionsForCategory[idx]; + + return optionData.value === originalOption.value && optionData.label === originalOption.label; + }); + + expect(optionsThatMatchOriginals.length).toEqual(originalOptionsForCategory.length); + + }); + + }); + + test(`clicking the button for a category should add a 'Done' button below its options`, () => { + + // start module + window.GOVUK.modules.start(); + + clickButtonForCategory(CATEGORIES[0]); + + const button = document.querySelector('.radio-select-column:nth-child(2) input[type=button]'); + + expect(button).not.toBeNull(); + expect(button.getAttribute('value')).toEqual('Done'); + + }); + + }); + + describe("after clicking the button to select that category", () => { + + beforeEach(() => { + + // start module + window.GOVUK.modules.start(); + + clickButtonForCategory(CATEGORIES[0]); + + }); + + test("clicking the 'Done' button should reset the module to its initial state, showing the category buttons again", () => { + + const doneButton = document.querySelector('.radio-select-column:nth-child(2) input[type=button]'); + + helpers.triggerEvent(doneButton, 'click'); + + }); + + describe("clicking on an option should", () => { + + let optionsColumn; + let firstOptionLabel; + + beforeEach(() => { + + optionsColumn = document.querySelector('.radio-select-column:nth-child(2)'); + + const firstOption = optionsColumn.querySelector('input[type=radio]'); + firstOptionLabel = firstOption.parentNode.querySelector('label').textContent.trim(); + + helpers.triggerEvent(firstOption, 'click'); + + }); + + test("remove all the other options", () => { + + // module replaces the column node so this needs querying again + optionsColumn = document.querySelector('.radio-select-column:nth-child(2)'); + + expect(optionsColumn.querySelectorAll('input[type=radio]').length).toEqual(1); + expect(optionsColumn.querySelector('label').textContent.trim()).toEqual(firstOptionLabel); + + }); + + test("add a button for choosing a different time", () => { + + const button = document.querySelector('.radio-select-column:nth-child(3) input[type=button]'); + + expect(button).not.toBeNull(); + expect(button.getAttribute('value')).toEqual('Choose a different time'); + + }) + + }); + + describe("after selecting an option", () => { + + test("clicking the 'Choose a different time' button should reset the module", () => { + + // select the first option + const firstOption = document.querySelector('.radio-select-column:nth-child(2) input[type=radio]'); + helpers.triggerEvent(firstOption, 'click'); + + // click the 'Choose a different time' button + const resetButton = document.querySelector('.radio-select-column:nth-child(3) input[type=button]'); + helpers.triggerEvent(resetButton, 'click'); + + categoryButtons = document.querySelectorAll('.radio-select-column:nth-child(2) .js-category-button'); + + expect(categoryButtons.length).toEqual(CATEGORIES.length); + + }); + }); + + }); + +}); From 6854361375f29a575c34423733430ae28363b6a0 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Fri, 24 May 2019 12:48:36 +0100 Subject: [PATCH 3/7] Expand event helpers Extends triggerEvent, allowing the creation of different types of event, and to change the data on its object. Also fakes the positional data browsers add to the event object. Also adds helpers for simulating: - all the events for a mouse click - the events invovled in moving the selection in a radio group --- tests/javascripts/support/helpers.js | 101 ++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 3 deletions(-) diff --git a/tests/javascripts/support/helpers.js b/tests/javascripts/support/helpers.js index 52ce86cda..9e7b056a8 100644 --- a/tests/javascripts/support/helpers.js +++ b/tests/javascripts/support/helpers.js @@ -1,12 +1,104 @@ -const triggerEvent = (el, evtType) => { - const evt = new Event(evtType, { +const triggerEvent = (el, evtType, options) => { + const eventInit = { bubbles: true, cancelable: true - }); + }; + let setPositionData = () => { + const browserUI = { + leftFrameBorder: 0, + topHeight: 100 + }; + const cursorOffset = { + x: 5, + y: 5 + }; + const elBoundingBox = el.getBoundingClientRect(); + + if (!eventInit.clientX) { eventInit.clientX = elBoundingBox.left + cursorOffset.x; } + if (!eventInit.clientY) { eventInit.clientY = elBoundingBox.top + cursorOffset.y; } + if (!eventInit.pageX) { eventInit.pageX = elBoundingBox.left + cursorOffset.x; } + if (!eventInit.pageY) { eventInit.pageY = elBoundingBox.top + cursorOffset.y; } + if (!eventInit.screenX) { eventInit.screenX = eventInit.clientX + browserUI.leftFrameBorder; } + if (!eventInit.screenY) { eventInit.screenY = eventInit.clientY + browserUI.topHeight; } + if (!eventInit.offsetX) { eventInit.offsetX = cursorOffset.x; } + if (!eventInit.offsetY) { eventInit.offsetY = cursorOffset.y; } + }; + let Instance; + + // mixin any specified event properties with the defaults + if (options && ('eventInit' in options)) { + Object.assign(eventInit, options.eventInit); + } + + // use event interface if specified + if (options && ('interface' in options)) { + Instance = options.interface; + } else { + + // otherwise, derive from the event type + switch (evtType) { + case 'click': + // click events are part of the MouseEvent interface + Instance = window.MouseEvent; + break; + case 'mousedown': + Instance = window.MouseEvent; + break; + case 'mouseup': + Instance = window.MouseEvent; + break; + case 'keydown': + Instance = window.KeyboardEvent; + break; + case 'keyup': + Instance = window.KeyboardEvent; + break; + default: + Instance = Event; + } + + } + + if (evtType === 'click') { + // hack for click events to simulate details of pointer interaction + setPositionData(); + } + + const evt = new Instance(evtType, eventInit); el.dispatchEvent(evt); }; +function clickElementWithMouse (el) { + triggerEvent(el, 'mousedown'); + triggerEvent(el, 'mouseup'); + triggerEvent(el, 'click'); +}; + +function moveSelectionToRadio (el, options) { + // movement within a radio group with arrow keys fires no keyboard events + + // click event fired from option radio being activated + triggerEvent(el, 'click', { + eventInit: { pageX: 0 } + }); + +}; + +function activateRadioWithSpace (el) { + + // simulate events for space key press to confirm selection + // event for space key press + triggerEvent(el, 'keydown', { + eventInit: { which: 32 } + }); + // click event fired from option radio being activated + triggerEvent(el, 'click', { + eventInit: { pageX: 0 } + }); + +}; + class ElementQuery { constructor (el) { this.el = el; @@ -141,5 +233,8 @@ const element = function (el) { }; exports.triggerEvent = triggerEvent; +exports.clickElementWithMouse = clickElementWithMouse; +exports.moveSelectionToRadio = moveSelectionToRadio; +exports.activateRadioWithSpace = activateRadioWithSpace; exports.element = element; exports.WindowMock = WindowMock; From 51864029580579ec02b01acc6cf16d49579317f9 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Thu, 4 Jul 2019 18:38:27 +0100 Subject: [PATCH 4/7] Add tests for different selection methods Adds tests for selection by keyboard, involving making a selection with the arrow keys and confirming it with the space or enter keys. --- tests/javascripts/radioSelect.test.js | 120 +++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 10 deletions(-) diff --git a/tests/javascripts/radioSelect.test.js b/tests/javascripts/radioSelect.test.js index 1e93de4e6..e9032c468 100644 --- a/tests/javascripts/radioSelect.test.js +++ b/tests/javascripts/radioSelect.test.js @@ -209,15 +209,7 @@ describe('RadioSelect', () => { }); - test("clicking the 'Done' button should reset the module to its initial state, showing the category buttons again", () => { - - const doneButton = document.querySelector('.radio-select-column:nth-child(2) input[type=button]'); - - helpers.triggerEvent(doneButton, 'click'); - - }); - - describe("clicking on an option should", () => { + describe("clicking on an option with the mouse/trackpad should", () => { let optionsColumn; let firstOptionLabel; @@ -229,7 +221,7 @@ describe('RadioSelect', () => { const firstOption = optionsColumn.querySelector('input[type=radio]'); firstOptionLabel = firstOption.parentNode.querySelector('label').textContent.trim(); - helpers.triggerEvent(firstOption, 'click'); + helpers.clickElementWithMouse(firstOption); }); @@ -254,6 +246,114 @@ describe('RadioSelect', () => { }); + describe("selecting an option with the space key should", () => { + + let optionsColumn; + let secondOptionLabel; + + beforeEach(() => { + + optionsColumn = document.querySelector('.radio-select-column:nth-child(2)'); + + const options = optionsColumn.querySelectorAll('input[type=radio]'); + secondOptionLabel = options[1].parentNode.querySelector('label').textContent.trim(); + + helpers.moveSelectionToRadio(options[1], { 'direction': 'down' }); + helpers.activateRadioWithSpace(options[1]); + }); + + test("remove all the other options", () => { + + // module replaces the column node so this needs querying again + optionsColumn = document.querySelector('.radio-select-column:nth-child(2)'); + + expect(optionsColumn.querySelectorAll('input[type=radio]').length).toEqual(1); + expect(optionsColumn.querySelector('label').textContent.trim()).toEqual(secondOptionLabel); + + }); + + test("add a button for choosing a different time", () => { + + const button = document.querySelector('.radio-select-column:nth-child(3) input[type=button]'); + + expect(button).not.toBeNull(); + expect(button.getAttribute('value')).toEqual('Choose a different time'); + + }) + + }); + + describe("selecting an option with the enter key should", () => { + + let optionsColumn; + let secondOptionLabel; + + beforeEach(() => { + + optionsColumn = document.querySelector('.radio-select-column:nth-child(2)'); + + const options = optionsColumn.querySelectorAll('input[type=radio]'); + secondOptionLabel = options[1].parentNode.querySelector('label').textContent.trim(); + + // simulate events for arrow key press moving selection to 2nd option + // event for down arrow key press + helpers.triggerEvent(options[1], 'keydown', { + eventInit: { which: 40 } + }); + // click event fired from option radio being activated + helpers.triggerEvent(options[1], 'click', { + eventInit: { pageX: 0 } + }); + + // simulate events for enter key press to confirm selection + // event for enter key press + helpers.triggerEvent(options[1], 'keydown', { + eventInit: { which: 13 } + }); + + }); + + test("remove all the other options", () => { + + // module replaces the column node so this needs querying again + optionsColumn = document.querySelector('.radio-select-column:nth-child(2)'); + + expect(optionsColumn.querySelectorAll('input[type=radio]').length).toEqual(1); + expect(optionsColumn.querySelector('label').textContent.trim()).toEqual(secondOptionLabel); + + }); + + test("add a button for choosing a different time", () => { + + const button = document.querySelector('.radio-select-column:nth-child(3) input[type=button]'); + + expect(button).not.toBeNull(); + expect(button.getAttribute('value')).toEqual('Choose a different time'); + + }) + + }); + + test("clicking the 'Done' button should choose whatever time is selected", () => { + + let optionsColumn = document.querySelector('.radio-select-column:nth-child(2)'); + const secondOption = optionsColumn.querySelectorAll('input[type=radio]')[1]; + const secondOptionLabel = document.querySelector('label[for=' + secondOption.getAttribute('id')).textContent.trim(); + const doneButton = document.querySelector('.radio-select-column:nth-child(2) input[type=button]'); + + // select second option + secondOption.checked = true; + secondOption.setAttribute('checked', ''); + + helpers.triggerEvent(doneButton, 'click'); + + optionsColumn = document.querySelector('.radio-select-column:nth-child(2)'); + + expect(optionsColumn.querySelectorAll('input[type=radio]').length).toEqual(1); + expect(optionsColumn.querySelector('label').textContent.trim()).toEqual(secondOptionLabel); + + }); + describe("after selecting an option", () => { test("clicking the 'Choose a different time' button should reset the module", () => { From 64c6d1fbc7f99993aa6f8a44cc824577b5792c36 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Mon, 8 Jul 2019 10:42:54 +0100 Subject: [PATCH 5/7] Make clicking 'Done' preserve any selection made Clicking the 'Done' button resets the module to its default state. 'Done' implies you've completed your selection so this doesn't make sense. This changes it so any selection made will be confirmed when 'Done' is clicked. --- app/assets/javascripts/radioSelect.js | 35 +++++++++++++++---- app/assets/stylesheets/components/radios.scss | 7 ++-- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/radioSelect.js b/app/assets/javascripts/radioSelect.js index 02b2afc18..99c1c8bf3 100644 --- a/app/assets/javascripts/radioSelect.js +++ b/app/assets/javascripts/radioSelect.js @@ -33,7 +33,7 @@
{{/choices}} - + `), 'chosen': Hogan.compile(` @@ -82,6 +82,12 @@ }); let categories = $component.data('categories').split(','); let name = $component.find('input').eq(0).attr('name'); + let reset = () => { + render('initial', { + 'categories': categories, + 'name': name + }); + }; $component .on('click', '.js-category-button', function(event) { @@ -131,15 +137,32 @@ }); focusSelected(); + }) + .on('click', '.js-done-button', function(event) { + + event.preventDefault(); + let $selection = $('input[type=radio]:checked', this.parentNode); + if ($selection.length) { + + render('chosen', { + 'choices': choices.filter( + element => element.value == $selection.eq(0).attr('value') + ), + 'name': name + }); + + } else { + + reset(); + + } + focusSelected(); + }) .on('click', '.js-reset-button', function(event) { event.preventDefault(); - render('initial', { - 'categories': categories, - 'name': name - }); - focusSelected(); + reset(); }); diff --git a/app/assets/stylesheets/components/radios.scss b/app/assets/stylesheets/components/radios.scss index eb632b62f..e0dc263d1 100644 --- a/app/assets/stylesheets/components/radios.scss +++ b/app/assets/stylesheets/components/radios.scss @@ -14,15 +14,16 @@ } - .js-reset-button, - .js-category-button { + .js-done-button, + .js-category-button, + .js-reset-button { @include button($grey-3); margin-right: $gutter-half; } - .js-reset-button-block { + .js-done-button-block { display: block; clear: both; From c11c05432351d53f5bc8a661ed0d2231c5522a83 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Mon, 1 Jul 2019 11:48:09 +0100 Subject: [PATCH 6/7] Fix option selection for keyboard users Keyboard users select a time slot by moving to the radio for that slot, using the arrow keys, and selecting it by pressing 'space' or 'enter', like a `