diff --git a/.github/workflows/featureDeploy.yml b/.github/workflows/featureDeploy.yml index acd59b32..7e77d25c 100644 --- a/.github/workflows/featureDeploy.yml +++ b/.github/workflows/featureDeploy.yml @@ -30,10 +30,10 @@ jobs: strategy: matrix: environment: ${{ fromJSON(needs.detect-environments.outputs.environments) }} - if: ${{ matrix.environment != 'production' }} + if: ${{ inputs.environment != 'production' }} uses: ./.github/workflows/deploy.yml with: - environment: '${{ matrix.environment }}' + environment: '${{ inputs.environment }}' secrets: inherit diff --git a/src/assets/js/application.js b/src/assets/js/application.js index 2a2e5d48..a207f019 100644 --- a/src/assets/js/application.js +++ b/src/assets/js/application.js @@ -3,8 +3,8 @@ as it will be loaded into the base nunjucks template. */ -import hideElementsWithJsHidden from './js-hidden.js' +import initiateJsHiddenChecks from './js-hidden.js' window.addEventListener('load', () => { - hideElementsWithJsHidden() + initiateJsHiddenChecks() }) diff --git a/src/assets/js/js-hidden.js b/src/assets/js/js-hidden.js index d099bc5e..280bad12 100644 --- a/src/assets/js/js-hidden.js +++ b/src/assets/js/js-hidden.js @@ -1,8 +1,43 @@ -const hideElementsWithJsHidden = () => { +/* globals MutationObserver, document */ + +/** + * Initiates checks for elements with the class 'js-hidden' and updates their display and visibility styles accordingly. + * + * When an element gains the 'js-hidden' class, its display and visibility styles are set to 'none' and 'hidden', respectively. + * When an element loses the 'js-hidden' class, its display and visibility styles are reset to their default values. + * + * This function also hides any elements that already have the 'js-hidden' class when it is called. + */ +const initiateJsHiddenChecks = () => { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'class') { + const target = mutation.target + const classList = target.classList + if (classList.contains('js-hidden')) { + // Class js-hidden was added + target.style.display = 'none' + target.style.visibility = 'hidden' + } else { + // Class js-hidden was removed + target.style.display = '' + target.style.visibility = '' + } + } + }) + }) + + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class'], + subtree: true + }) + document.querySelectorAll('.js-hidden').forEach((el, i) => { - console.log(el) + console.log('Hiding element', el) el.style.display = 'none' + el.style.visibility = 'none' }) } -export default hideElementsWithJsHidden +export default initiateJsHiddenChecks diff --git a/src/assets/js/list-filter.js b/src/assets/js/list-filter.js new file mode 100644 index 00000000..02b0a2d9 --- /dev/null +++ b/src/assets/js/list-filter.js @@ -0,0 +1,127 @@ +/** + * This file is taken from https://github.com/alphagov/collections/blob/main/app/assets/javascripts/modules/list-filter.js + */ + +/* eslint-disable no-var */ +//= require govuk_publishing_components/vendor/polyfills/closest + +const keyPauseTime = 20 + +window.GOVUK = window.GOVUK || {} +window.GOVUK.Modules = window.GOVUK.Modules || {}; + +(function (Modules) { + function ListFilter ($module) { + this.$module = $module + this.filterTimeout = null + this.form = this.$module.querySelector('[data-filter="form"]') + this.searchResults = this.$module.querySelector('#search_results') + } + + ListFilter.prototype.init = function () { + this.$module.filterList = this.filterList.bind(this) + // Form should only appear if the JS is working + this.form.classList.add('filter-list__form--active') + this.results = document.createElement('div') + this.results.classList.add('filter-list__results', 'govuk-heading-m', 'js-search-results') + this.results.setAttribute('aria-live', 'polite') + this.results.innerHTML = this.countInitialItems() + ' results found' + this.searchResults.insertBefore(this.results, this.searchResults.firstChild) + + // We don't want the form to submit/refresh the page on enter key + this.form.onsubmit = function () { return false } + + this.form.addEventListener('keyup', function (e) { + var searchTerm = e.target.value + clearTimeout(this.filterTimeout) + this.filterTimeout = setTimeout(function () { + this.$module.filterList(searchTerm) + }.bind(this), keyPauseTime) + }.bind(this)) + } + + ListFilter.prototype.filterList = function (searchTerm) { + var itemsToFilter = this.$module.querySelectorAll('[data-filter="item"]') + var blocksToFilter = this.$module.querySelectorAll('[data-filter="block"]') + for (var i = 0; i <= itemsToFilter.length - 1; i++) { + var currentItem = itemsToFilter[i] + if (!this.matchSearchTerm(currentItem, searchTerm)) { + currentItem.classList.add('js-hidden') + } + } + this.updateItemCount(blocksToFilter) + } + + ListFilter.prototype.matchSearchTerm = function (item, term) { + var normaliseWhitespace = function (string) { + return string + .trim() // Removes spaces at beginning and end of string. + .replace(/\r?\n|\r/g, ' ') // Replaces line breaks with one space. + .replace(/\s+/g, ' ') // Squashes multiple spaces to one space. + } + + var searchTerms = item.getAttribute('data-filter-terms') || '' + var normalisedTerms = normaliseWhitespace(searchTerms) + + item.classList.remove('js-hidden') + + var searchTermRegexp = new RegExp(term.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') + return searchTermRegexp.exec(normalisedTerms) !== null + } + + ListFilter.prototype.countInitialItems = function () { + return this.$module.querySelectorAll('[data-filter="item"]').length + } + + ListFilter.prototype.updateItemCount = function (blocksToFilter) { + var totalMatchingItems = 0 + + for (var i = 0; i < blocksToFilter.length; i++) { + var block = blocksToFilter[i].closest('[data-filter="block"]') + block.classList.remove('js-hidden') + + var matchingItems = block.querySelectorAll('[data-filter="item"]') + var matchingItemCount = 0 + + var innerBlocks = block.querySelectorAll('[data-filter="inner-block"]') + for (var r = 0; r < innerBlocks.length; r++) { + innerBlocks[r].classList.add('js-hidden') + } + + for (var j = 0; j < matchingItems.length; j++) { + if (!matchingItems[j].classList.contains('js-hidden')) { + matchingItemCount++ + + if (matchingItems[j].closest('[data-filter="inner-block"]') !== null) { matchingItems[j].closest('[data-filter="inner-block"]').classList.remove('js-hidden') } + } + } + + var itemCount = block.querySelectorAll('[data-item-count="true"]') + var accessibleItemCount = block.querySelectorAll('.js-accessible-item-count') + + if (matchingItemCount === 0) { + block.classList.toggle('js-hidden') + } + + if (matchingItemCount > 0) { + for (var l = 0; l < itemCount.length; l++) { + itemCount[l].textContent = matchingItemCount + } + + for (var k = 0; k < accessibleItemCount.length; k++) { + accessibleItemCount[k].textContent = matchingItemCount + } + } + + totalMatchingItems += matchingItemCount + } + + var text = ' results found' + if (totalMatchingItems === 1) { + text = ' result found' + } + this.results.innerHTML = totalMatchingItems + text + } + + Modules.ListFilter = ListFilter +})(window.GOVUK.Modules) diff --git a/src/assets/scss/index.scss b/src/assets/scss/index.scss index 14c1bd62..bd86cf6d 100644 --- a/src/assets/scss/index.scss +++ b/src/assets/scss/index.scss @@ -101,4 +101,4 @@ $govuk-global-styles: true; code, code * { font-family: monospace; -} +} \ No newline at end of file diff --git a/src/controllers/OrganisationsController.js b/src/controllers/OrganisationsController.js index 00a0cfae..e3d7e517 100644 --- a/src/controllers/OrganisationsController.js +++ b/src/controllers/OrganisationsController.js @@ -1,3 +1,4 @@ +import datasette from '../services/datasette.js' import performanceDbApi from '../services/performanceDbApi.js' // Assume you have an API service module import logger from '../utils/logger.js' import { dataSubjects } from '../utils/utils.js' @@ -70,13 +71,40 @@ const organisationsController = { } }, + /** + * Handles the GET /organisations request + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ async getOrganisations (req, res, next) { - res.render('organisations/find.html') + try{ + const sql = 'select name, organisation from organisation' + const result = await datasette.runQuery(sql) + + const sortedResults = result.formattedData.sort((a, b) => { + return a.name.localeCompare(b.name) + }) + + const alphabetisedOrgs = sortedResults.reduce((acc, current) => { + const firstLetter = current.name.charAt(0).toUpperCase() + acc[firstLetter] = acc[firstLetter] || [] + acc[firstLetter].push(current) + return acc + }, {}) + + res.render('organisations/find.html', { alphabetisedOrgs }) + } catch (err) { + logger.warn(err) + next(err) + } }, async getGetStarted (req, res, next) { res.render('organisations/get-started.html') } + } export default organisationsController diff --git a/src/views/organisations/find.html b/src/views/organisations/find.html index c6d190d9..ffd58b8e 100644 --- a/src/views/organisations/find.html +++ b/src/views/organisations/find.html @@ -1,9 +1,9 @@ {% extends "layouts/main.html" %} -{% from "govuk/components/breadcrumbs/macro.njk" import govukBreadcrumbs %} -{% from "govuk/components/tag/macro.njk" import govukTag %} +{% set pageName = "Find your organisation" %} {% block beforeContent %} + {{ super() }} {% endblock %} @@ -12,15 +12,59 @@
+

{{ pageName }}

+
+
-

- {{ pageName }} -

+
+ +
+
+
+ + +
+
+ +
+
+ {% for letter, orgs in alphabetisedOrgs %} +
+
+

{{ letter }}

+
+
+ +
+
+
+
+
+ {% endfor %} + + + +
+
+
-

Find page placeholder

+{% endblock %} +{% block scripts %} + {{ super() }} + + {% endblock %} \ No newline at end of file diff --git a/test/unit/findPage.test.js b/test/unit/findPage.test.js new file mode 100644 index 00000000..0eba8e95 --- /dev/null +++ b/test/unit/findPage.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest' +import nunjucks from 'nunjucks' +import addFilters from '../../src/filters/filters' +import jsdom from 'jsdom' +import { runGenericPageTests } from './generic-page.js' + +const nunjucksEnv = nunjucks.configure([ + 'src/views', + 'src/views/check', + 'src/views/submit', + 'node_modules/govuk-frontend/dist/', + 'node_modules/@x-govuk/govuk-prototype-components/' +], { + dev: true, + noCache: true, + watch: true +}) + +addFilters(nunjucksEnv, {}) + +describe('Organisations Find Page', () => { + const params = { + alphabetisedOrgs: { + A: [ + { + name: 'Aberdeen' + }, + { + name: 'Aylesbury' + }, + { + name: 'Ashford' + } + ], + B: [ + { + name: 'Bath' + }, + { + name: 'Birmingham' + }, + { + name: 'Brighton' + } + ] + }, + serviceName: 'mock service name' + } + + const html = nunjucks.render('organisations/find.html', params) + + const dom = new jsdom.JSDOM(html) + const document = dom.window.document + + runGenericPageTests(html, { + pageTitle: 'Find your organisation - mock service name', + serviceName: 'mock service name' + }) + + it('correct has a form element with the correct data-filter attribute', () => { + const formElement = document.querySelector('form') + expect(formElement.getAttribute('data-filter')).toBe('form') + }) + + it('correctly has elements with the data-filter=block and data-filter=inner block attributes', () => { + const blockElements = document.querySelectorAll('[data-filter="block"]') + expect(blockElements.length).toBeGreaterThan(0) + + const innerBlockElements = document.querySelectorAll('[data-filter="inner-block"]') + expect(innerBlockElements.length).toBeGreaterThan(0) + + expect(blockElements.length).toEqual(innerBlockElements.length) + }) + + it('Renders the correct organisation list with appropriate attributes', () => { + const organisationList = document.querySelector('#search_results') + expect(organisationList.children.length).toBe(Object.keys(params.alphabetisedOrgs).length) + + Object.keys(params.alphabetisedOrgs).forEach((letter, i) => { + const organisationSection = organisationList.children[i] + expect(organisationSection.querySelector('.blockHeading').textContent).toBe(letter) + const organisationListItems = organisationSection.querySelector('.govuk-list').children + params.alphabetisedOrgs[letter].forEach((organisation, j) => { + expect(organisationListItems[j].textContent).toContain(organisation.name) + expect(organisationListItems[j].getAttribute('data-filter')).toEqual('item') + expect(organisationListItems[j].getAttribute('data-filter-terms')).toEqual(organisation.name) + }) + }) + }) +}) diff --git a/test/unit/organisationsController.test.js b/test/unit/organisationsController.test.js index 6e757fc9..8f8f6652 100644 --- a/test/unit/organisationsController.test.js +++ b/test/unit/organisationsController.test.js @@ -1,6 +1,7 @@ import { describe, it, vi, expect, beforeEach } from 'vitest' -import LpaOverviewController from '../../src/controllers/OrganisationsController.js' +import organisationsController from '../../src/controllers/OrganisationsController.js' import performanceDbApi from '../../src/services/performanceDbApi.js' +import datasette from '../../src/services/datasette.js' vi.mock('../../src/services/performanceDbApi.js') vi.mock('../../src/utils/utils.js', () => { @@ -8,6 +9,7 @@ vi.mock('../../src/utils/utils.js', () => { dataSubjects: {} } }) +vi.mock('../../src/services/datasette.js') describe('OrganisationsController.js', () => { beforeEach(() => { @@ -31,7 +33,7 @@ describe('OrganisationsController.js', () => { performanceDbApi.getLpaOverview = vi.fn().mockResolvedValue(expectedResponse) - await LpaOverviewController.getOverview(req, res, next) + await organisationsController.getOverview(req, res, next) expect(res.render).toHaveBeenCalledTimes(1) expect(res.render).toHaveBeenCalledWith('organisations/overview.html', expect.objectContaining({ @@ -57,7 +59,7 @@ describe('OrganisationsController.js', () => { vi.mocked(performanceDbApi.getLpaOverview).mockRejectedValue(error) - await LpaOverviewController.getOverview(req, res, next) + await organisationsController.getOverview(req, res, next) expect(next).toHaveBeenCalledTimes(1) expect(next).toHaveBeenCalledWith(error) @@ -65,11 +67,76 @@ describe('OrganisationsController.js', () => { }) describe('find', () => { - it.todo('should render the find page', () => { + it('should call render with the find page', async () => { + const req = {} + const res = { render: vi.fn() } + const next = vi.fn() + + vi.mocked(datasette.runQuery).mockResolvedValue({ formattedData: [] }) + + await organisationsController.getOrganisations(req, res, next) + + expect(res.render).toHaveBeenCalledTimes(1) + expect(res.render).toHaveBeenCalledWith('organisations/find.html', expect.objectContaining({ + alphabetisedOrgs: {} + })) + }) + + it('should correctly sort and restructure the data recieved from datasette, then pass it on to the template', async () => { + const req = {} + const res = { render: vi.fn() } + const next = vi.fn() + + const datasetteResponse = [ + { name: 'Aardvark Healthcare', organisation: 'Aardvark Healthcare' }, + { name: 'Bath NHS Trust', organisation: 'Bath NHS Trust' }, + { name: 'Bristol Hospital', organisation: 'Bristol Hospital' }, + { name: 'Cardiff Health Board', organisation: 'Cardiff Health Board' }, + { name: 'Derbyshire Healthcare', organisation: 'Derbyshire Healthcare' }, + { name: 'East Sussex NHS Trust', organisation: 'East Sussex NHS Trust' } + ] + + vi.mocked(datasette.runQuery).mockResolvedValue({ formattedData: datasetteResponse }) + await organisationsController.getOrganisations(req, res, next) + + expect(res.render).toHaveBeenCalledTimes(1) + expect(res.render).toHaveBeenCalledWith('organisations/find.html', expect.objectContaining({ + alphabetisedOrgs: { + A: [ + { name: 'Aardvark Healthcare', organisation: 'Aardvark Healthcare' } + ], + B: [ + { name: 'Bath NHS Trust', organisation: 'Bath NHS Trust' }, + { name: 'Bristol Hospital', organisation: 'Bristol Hospital' } + ], + C: [ + { name: 'Cardiff Health Board', organisation: 'Cardiff Health Board' } + ], + D: [ + { name: 'Derbyshire Healthcare', organisation: 'Derbyshire Healthcare' } + ], + E: [ + { name: 'East Sussex NHS Trust', organisation: 'East Sussex NHS Trust' } + ] + } + })) }) - it.todo('should catch errors and pass them onto the next function') + it('should catch errors and pass them onto the next function', async () => { + const req = {} + const res = {} + const next = vi.fn() + + const error = new Error('Test error') + + vi.mocked(datasette.runQuery).mockRejectedValue(error) + + await organisationsController.getOrganisations(req, res, next) + + expect(next).toHaveBeenCalledTimes(1) + expect(next).toHaveBeenCalledWith(error) + }) }) describe('get-started', () => { diff --git a/webpack.config.mjs b/webpack.config.mjs index 0a01a9e9..3b30f334 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -23,7 +23,8 @@ export default { entry: { map: '/src/assets/js/map.js', application: '/src/assets/js/application.js', - statusPage: '/src/assets/js/statusPage.js' + statusPage: '/src/assets/js/statusPage.js', + 'list-filter': '/src/assets/js/list-filter.js' }, output: { filename: '[name].bundle.js',