diff --git a/app/assets/javascripts/templateFolderForm.js b/app/assets/javascripts/templateFolderForm.js index 6aa8f6f74..b85ab7ead 100644 --- a/app/assets/javascripts/templateFolderForm.js +++ b/app/assets/javascripts/templateFolderForm.js @@ -19,10 +19,10 @@ this.states = [ {key: 'nothing-selected-buttons', $el: this.$form.find('#nothing_selected'), cancellable: false}, {key: 'items-selected-buttons', $el: this.$form.find('#items_selected'), cancellable: false}, - {key: 'move-to-existing-folder', $el: this.$form.find('#move_to_folder_radios'), cancellable: true, setFocus: this.getFocusRoutine('#move_to_folder_radios legend', true)}, + {key: 'move-to-existing-folder', $el: this.$form.find('#move_to_folder_radios'), cancellable: true, setFocus: this.getFocusRoutine('#move_to_folder_radios fieldset', true)}, {key: 'move-to-new-folder', $el: this.$form.find('#move_to_new_folder_form'), cancellable: true, setFocus: this.getFocusRoutine('#move_to_new_folder_name', false)}, {key: 'add-new-folder', $el: this.$form.find('#add_new_folder_form'), cancellable: true, setFocus: this.getFocusRoutine('#add_new_folder_name', false)}, - {key: 'add-new-template', $el: this.$form.find('#add_new_template_form'), cancellable: true, setFocus: this.getFocusRoutine('#add_new_template_form legend', true)} + {key: 'add-new-template', $el: this.$form.find('#add_new_template_form'), cancellable: true, setFocus: this.getFocusRoutine('#add_new_template_form fieldset', true)} ]; // cancel/clear buttons only relevant if JS enabled, so diff --git a/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss b/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss index c4d39a2e3..3e5f7eb11 100644 --- a/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss +++ b/app/assets/stylesheets/components/stick-at-top-when-scrolling.scss @@ -48,6 +48,10 @@ $sticky-padding: $gutter * 2 / 3; margin-top: ($sticky-padding * 2) * -1; } + fieldset:focus { + outline: none; + } + .page-footer { margin-bottom: 0; min-height: 50px; diff --git a/tests/javascripts/support/helpers.js b/tests/javascripts/support/helpers.js index 65c7f6a0d..fe5adf08e 100644 --- a/tests/javascripts/support/helpers.js +++ b/tests/javascripts/support/helpers.js @@ -13,6 +13,7 @@ exports.RangeMock = domInterfaces.RangeMock; exports.SelectionMock = domInterfaces.SelectionMock; exports.getRadioGroup = html.getRadioGroup; exports.getRadios = html.getRadios; +exports.templatesAndFoldersCheckboxes = html.templatesAndFoldersCheckboxes; exports.element = elements.element; exports.WindowMock = rendering.WindowMock; exports.ScreenMock = rendering.ScreenMock; diff --git a/tests/javascripts/support/helpers/html.js b/tests/javascripts/support/helpers/html.js index 06dfa758d..7b3d78eb4 100644 --- a/tests/javascripts/support/helpers/html.js +++ b/tests/javascripts/support/helpers/html.js @@ -33,5 +33,31 @@ function getRadioGroup (data) { return radioGroup; }; +function templatesAndFoldersCheckboxes (hierarchy) { + let result = ''; + + hierarchy.forEach((node, idx) => { + + result += ` +
+
+ + +
+

+ + ${node.label} + +

+ ${node.meta} +
`; + + }); + + return result; + +}; + exports.getRadios = getRadios; exports.getRadioGroup = getRadioGroup; +exports.templatesAndFoldersCheckboxes = templatesAndFoldersCheckboxes; diff --git a/tests/javascripts/templateFolderForm.test.js b/tests/javascripts/templateFolderForm.test.js new file mode 100644 index 000000000..4439d45c5 --- /dev/null +++ b/tests/javascripts/templateFolderForm.test.js @@ -0,0 +1,753 @@ +const helpers = require('./support/helpers'); + +function setFixtures (hierarchy) { + + const foldersCheckboxesHTML = function (filter) { + let count = 0; + + // use closure to give all calls access to count + return function (nodes) { + let result = ''; + + nodes + .filter(node => node.type === 'folder') + .forEach(node => { + result += `
  • + + + ${node.children ? foldersCheckboxesHTML(node.children) : ''} +
  • `; + count++; + }); + + return ``; + }; + + }(); + + function controlsHTML () { + + return `
    + +
    +
    +
    +
    + + Choose a folder + +
    + ${foldersCheckboxesHTML(hierarchy)} +
    +
    +
    +
    +
    + +
    +
    +
    +
    + Add to new folder +
    + + +
    + +
    +
    +
    +
    + Add new folder +
    + + +
    + +
    +
    +
    +
    +
    +
    + + New template + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    + Nothing selected +
    +
    ` + }; + + document.body.innerHTML = ` +
    + ${helpers.templatesAndFoldersCheckboxes(hierarchy)} + ${controlsHTML()} +
    `; + +}; + +function resetStickyMocks () { + + GOVUK.stickAtBottomWhenScrolling.recalculate.mockClear(); + GOVUK.stickAtBottomWhenScrolling.setMode.mockClear(); + +}; + +beforeAll(() => { + require('../../app/assets/javascripts/templateFolderForm.js'); +}); + +afterAll(() => { + require('./support/teardown.js'); +}); + +describe('TemplateFolderForm', () => { + + const hierarchy = [ + { + 'label': 'Folder 1', + 'type': 'folder', + 'meta': '1 template, 1 folder', + 'children': [ + { + 'label': 'Template 3', + 'type': 'template', + 'meta': 'Email template' + }, + { + 'label': 'Folder 2', + 'type': 'folder', + 'meta': 'Empty', + 'children': [] + } + ] + }, + { + 'label': 'Template 1', + 'type': 'Email template', + 'meta': 'Email template' + }, + { + 'label': 'Template 2', + 'type': 'template', + 'meta': 'Email template' + } + ]; + + let templateFolderForm; + let formControls; + let visibleCounter; + let hiddenCounter; + + beforeAll(() => { + + // stub out calls to sticky JS + GOVUK.stickAtBottomWhenScrolling = { + setMode: jest.fn(), + recalculate: jest.fn() + }; + + }); + + afterAll(() => { + + GOVUK.stickAtBottomWhenScrolling = undefined; + + }); + + beforeEach(() => { + + setFixtures(hierarchy); + + templateFolderForm = document.querySelector('form[data-module=template-folder-form]'); + + }); + + afterEach(() => { + + document.body.innerHTML = ''; + + }); + + function getTemplateFolderCheckboxes () { + return templateFolderForm.querySelectorAll('input[type=checkbox]'); + }; + + function getVisibleCounter () { + return formControls.querySelector('.template-list-selected-counter__count'); + }; + + function getHiddenCounter () { + return formControls.querySelector('[role=status]'); + }; + + describe("Before the page loads", () => { + + // We need parts of the module to be made sticky, but by the module code, + // not the sticky JS code that operates on the HTML at page load. + // Because of this, they will need to be marked with classes + test("the HTML for the module should contain placeholder classes on each part that needs to be sticky", () => { + + expect(templateFolderForm.querySelectorAll('#move_to_folder_radios > .js-will-stick-at-bottom-when-scrolling').length).toEqual(2); + expect(templateFolderForm.querySelector('#move_to_new_folder_form > .js-will-stick-at-bottom-when-scrolling')).not.toBeNull(); + expect(templateFolderForm.querySelector('#add_new_folder_form > .js-will-stick-at-bottom-when-scrolling')).not.toBeNull(); + expect(templateFolderForm.querySelectorAll('#add_new_template_form > .js-will-stick-at-bottom-when-scrolling').length).toEqual(2); + + }); + + }); + + describe("When the page loads", () => { + + beforeEach(() => { + + // start module + window.GOVUK.modules.start(); + + formControls = templateFolderForm.querySelector('#sticky_template_forms'); + visibleCounter = getVisibleCounter(); + + }); + + afterEach(() => resetStickyMocks()); + + test("the default controls and the counter should be showing", () => { + + expect(document.querySelector('button[value=add-new-template]')).not.toBeNull(); + expect(document.querySelector('button[value=add-new-folder]')).not.toBeNull(); + expect(visibleCounter).not.toBeNull(); + + }); + + // Our counter needs to be wrapped in an ARIA live region so changes to its content are + // communicated to assistive tech'. + // ARIA live regions need to be in the HTML before JS loads. + // Because of this, we have a counter, in a live region, in the page when it loads, and + // a duplicate, visible, one in the HTML the module adds to the page. + // We hide the one in the live region to avoid duplication of it's content. + describe("Selection counter", () => { + + beforeEach(() => { + + hiddenCounter = getHiddenCounter(); + + }) + + test("the visible counter should be hidden from assistive tech", () => { + + expect(visibleCounter.getAttribute('aria-hidden')).toEqual("true"); + + }); + + test("the content of both visible and hidden counters should match", () => { + + expect(visibleCounter.textContent.trim()).toEqual(hiddenCounter.textContent.trim()); + + }); + + }); + + test("should make the current controls sticky", () => { + + // the class the sticky JS hooks into should be present + expect(formControls.querySelector('#nothing_selected .js-stick-at-bottom-when-scrolling')).not.toBeNull(); + + // .recalculate should have been called so the sticky JS picks up the controls + expect(GOVUK.stickAtBottomWhenScrolling.recalculate.mock.calls.length).toEqual(1); + + // mode should have been set to 'default' as the controls only have one part + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls.length).toEqual(1); + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls[0][0]).toEqual('default'); + + }); + + }); + + describe("Clicking 'New template'", () => { + + beforeEach(() => { + + // start module + window.GOVUK.modules.start(); + + formControls = templateFolderForm.querySelector('#sticky_template_forms'); + + // reset sticky JS mocks called when the module starts + resetStickyMocks(); + + helpers.triggerEvent(formControls.querySelector('[value=add-new-template]'), 'click'); + + }); + + afterEach(() => resetStickyMocks()); + + test("should show options for all the types of template", () => { + + const options = [ + 'Email', 'Text message', 'Letter', 'Copy an existing template' + ]; + + const labels = Array.from(formControls.querySelectorAll('label')); + const radios = Array.from(formControls.querySelectorAll('input[type=radio]')); + + options.forEach(option => { + let matchingLabels = labels.filter(label => label.textContent.trim() === option); + + expect(matchingLabels.length > 0).toBe(true); + + let matchingRadio = formControls.querySelector(`#${matchingLabels[0].getAttribute('for')}`) + + expect(matchingRadio).not.toBeNull(); + }); + + }); + + test("should show a 'Cancel' link", () => { + + const cancelLink = formControls.querySelector('.js-cancel'); + + expect(cancelLink).not.toBeNull(); + + }); + + test("should focus the fieldset", () => { + + expect(document.activeElement).toBe(formControls.querySelector('fieldset')); + + }); + + test("should make the current controls sticky", () => { + + // the classes the sticky JS hooks into should be present for both parts + expect(formControls.querySelectorAll('#add_new_template_form .js-stick-at-bottom-when-scrolling').length).toEqual(2); + + // .recalculate should have been called so the sticky JS picks up the controls + expect(GOVUK.stickAtBottomWhenScrolling.recalculate.mock.calls.length).toEqual(1); + + // the mode should be set to 'dialog' so both parts can be sticky + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls.length).toEqual(1); + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls[0][0]).toEqual('dialog'); + + }); + + describe("When the 'Cancel' link is clicked after choosing to add a new template", () => { + + let addNewTemplateButton; + + beforeEach(() => { + + // reset sticky JS mocks called when the new template state loaded + resetStickyMocks(); + + helpers.triggerEvent(formControls.querySelector('.js-cancel'), 'click'); + + addNewTemplateButton = formControls.querySelector('[value=add-new-template]'); + + }); + + test("the controls should reset", () => { + + expect(addNewTemplateButton).not.toBeNull(); + + }); + + test("the add new template control should be focused", () => { + + expect(document.activeElement).toBe(addNewTemplateButton); + + }); + + }); + + }); + + describe("Clicking 'New folder'", () => { + + let textbox; + + afterEach(() => resetStickyMocks()); + + beforeEach(() => { + + // start module + window.GOVUK.modules.start(); + + formControls = templateFolderForm.querySelector('#sticky_template_forms'); + + // reset sticky JS mocks called when the module starts + resetStickyMocks(); + + helpers.triggerEvent(formControls.querySelector('[value=add-new-folder]'), 'click'); + + textbox = formControls.querySelector('input[type=text]'); + + }); + + test("should show a textbox for the folder name", () => { + + expect(textbox).not.toBeNull(); + + // check textbox has a label + expect(formControls.querySelector(`label[for=${textbox.getAttribute('id')}]`)).not.toBeNull(); + + }); + + test("should focus the textbox", () => { + + expect(document.activeElement).toBe(textbox); + + }); + + test("should make the current controls sticky", () => { + + // the class the sticky JS hooks into should be present + expect(formControls.querySelector('#add_new_folder_form .js-stick-at-bottom-when-scrolling')).not.toBeNull(); + + // .recalculate should have been called so the sticky JS picks up the controls + expect(GOVUK.stickAtBottomWhenScrolling.recalculate.mock.calls.length).toEqual(1); + + // mode should have been set to 'default' as the controls only have one part + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls.length).toEqual(1); + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls[0][0]).toEqual('default'); + + }); + + describe("When the 'Cancel' link is clicked after choosing to add a new folder", () => { + + let addNewFolderButton; + + beforeEach(() => { + + helpers.triggerEvent(formControls.querySelector('.js-cancel'), 'click'); + + addNewFolderButton = formControls.querySelector('button[value=add-new-folder]'); + + }); + + test("the controls should reset", () => { + + expect(addNewFolderButton).not.toBeNull(); + + }); + + test("the control for adding a new folder should be focused", () => { + + expect(document.activeElement).toBe(addNewFolderButton); + + }); + + }); + + }); + + describe("When some templates/folders are selected", () => { + + let templateFolderCheckboxes; + + beforeEach(() => { + + // start module + window.GOVUK.modules.start(); + + templateFolderCheckboxes = getTemplateFolderCheckboxes(); + + formControls = templateFolderForm.querySelector('#sticky_template_forms'); + + // reset sticky JS mocks called when the module starts + resetStickyMocks(); + + helpers.triggerEvent(templateFolderCheckboxes[0], 'click'); + helpers.triggerEvent(templateFolderCheckboxes[2], 'click'); + + }); + + afterEach(() => resetStickyMocks()); + + test("the buttons for moving to a new or existing folder are showing", () => { + + expect(formControls.querySelector('button[value=move-to-new-folder]')).not.toBeNull(); + expect(formControls.querySelector('button[value=move-to-existing-folder]')).not.toBeNull(); + + }); + + test("should make the current controls sticky", () => { + + // the class the sticky JS hooks into should be present + expect(formControls.querySelector('#items_selected .js-stick-at-bottom-when-scrolling')).not.toBeNull(); + + // .recalculate should have been called so the sticky JS picks up the controls + expect(GOVUK.stickAtBottomWhenScrolling.recalculate.mock.calls.length).toEqual(1); + + // mode should have been set to 'default' as the controls only have one part + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls.length).toEqual(1); + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls[0][0]).toEqual('default'); + + }); + + describe("'Clear selection' link", () => { + + let clearLink; + + beforeEach(() => { + + clearLink = formControls.querySelector('.js-cancel'); + + }); + + test("the link has been added with the right text", () => { + + expect(clearLink).not.toBeNull(); + expect(clearLink.textContent.trim()).toEqual('Clear selection'); + + }); + + test("clicking the link clears the selection", () => { + + helpers.triggerEvent(clearLink, 'click'); + + const checkedCheckboxes = Array.from(templateFolderCheckboxes).filter(checkbox => checkbox.checked); + + expect(checkedCheckboxes.length === 0).toBe(true); + + }); + + }); + + describe("Selection counter", () => { + + let visibleCounterText; + let hiddenCounterText; + + beforeEach(() => { + + visibleCounterText = getVisibleCounter().textContent.trim(); + hiddenCounterText = getHiddenCounter().textContent.trim(); + + }); + + test("the content of both visible and hidden counters should match", () => { + + expect(visibleCounterText).toEqual(hiddenCounterText); + + }); + + test("the content of the counter should reflect the selection", () => { + + expect(visibleCounterText).toEqual('2 selected'); + + }); + + }); + + describe("Clicking the 'Move' button", () => { + + beforeEach(() => { + + // reset sticky JS mocks called when a selection was made + resetStickyMocks(); + + helpers.triggerEvent(formControls.querySelector('[value=move-to-existing-folder]'), 'click'); + + }); + + test("should show radios for all the folders in the hierarchy", () => { + + const foldersInHierarchy = []; + + function getFolders (nodes) { + + nodes.forEach(node => { + if (node.type === 'folder') { + + foldersInHierarchy.push(node.label); + if (node.children.length) { getFolders(node.children) } + + } + }); + + }; + + getFolders(hierarchy); + + const folderLabels = Array.from(formControls.querySelectorAll('#move_to label')) + .filter(label => label.textContent.trim() !== 'Templates'); + + expect(folderLabels.map(label => label.textContent.trim())).toEqual(foldersInHierarchy); + + const radiosForLabels = folderLabels + .map(label => formControls.querySelector(`#${label.getAttribute('for')}`)) + .filter(radio => radio !== null); + + expect(radiosForLabels.length).toEqual(foldersInHierarchy.length); + + }); + + test("focus the 'Choose a folder' fieldset", () => { + + expect(document.activeElement).toBe(formControls.querySelector('#move_to')); + + }); + + test("should make the current controls sticky", () => { + + // the classes the sticky JS hooks into should be present for both parts + expect(formControls.querySelectorAll('#move_to_folder_radios .js-stick-at-bottom-when-scrolling').length).toEqual(2); + + // .recalculate should have been called so the sticky JS picks up the controls + expect(GOVUK.stickAtBottomWhenScrolling.recalculate.mock.calls.length).toEqual(1); + + // the mode should be set to 'dialog' so both parts can be sticky + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls.length).toEqual(1); + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls[0][0]).toEqual('dialog'); + + }); + + describe("When the 'Cancel' link is clicked after choosing to move a template or folder", () => { + + let moveToFolderButton; + + beforeEach(() => { + + helpers.triggerEvent(formControls.querySelector('.js-cancel'), 'click'); + + moveToFolderButton = formControls.querySelector('button[value=move-to-existing-folder]'); + + }); + + test("the controls should reset", () => { + + expect(moveToFolderButton).not.toBeNull(); + + }); + + test("the control for moving to an existing folder should be focused", () => { + + expect(document.activeElement).toBe(moveToFolderButton); + + }); + + }); + + }); + + describe("Clicking the 'Add to new folder' button", () => { + + let textbox; + + beforeEach(() => { + + // reset sticky JS mocks called when a selection was made + resetStickyMocks(); + + helpers.triggerEvent(formControls.querySelector('[value=move-to-new-folder]'), 'click'); + + textbox = formControls.querySelector('input[type=text]'); + + }); + + test("should show a textbox for the folder name", () => { + + expect(textbox).not.toBeNull(); + + // check textbox has a label + expect(formControls.querySelector(`label[for=${textbox.getAttribute('id')}]`)).not.toBeNull(); + + }); + + test("should focus the textbox", () => { + + expect(document.activeElement).toBe(textbox); + + }); + + test("should make the current controls sticky", () => { + + // the class the sticky JS hooks into should be present + expect(formControls.querySelector('#move_to_new_folder_form .js-stick-at-bottom-when-scrolling')).not.toBeNull(); + + // .recalculate should have been called so the sticky JS picks up the controls + expect(GOVUK.stickAtBottomWhenScrolling.recalculate.mock.calls.length).toEqual(1); + + // mode should have been set to 'default' as the controls only have one part + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls.length).toEqual(1); + expect(GOVUK.stickAtBottomWhenScrolling.setMode.mock.calls[0][0]).toEqual('default'); + + }); + + describe("When the 'Cancel' link is clicked after choosing to add a template or folder to a new folder", () => { + + let moveToNewFolderButton; + + beforeEach(() => { + + helpers.triggerEvent(formControls.querySelector('.js-cancel'), 'click'); + + moveToNewFolderButton = formControls.querySelector('button[value=move-to-new-folder]'); + + }); + + test("the controls should reset", () => { + + expect(moveToNewFolderButton).not.toBeNull(); + + }); + + test("the control for adding a new folder should be focused", () => { + + expect(document.activeElement).toBe(moveToNewFolderButton); + + }); + + }); + + }); + + }); + +});