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/js/step-by-step-nav.js b/src/assets/js/step-by-step-nav.js new file mode 100644 index 00000000..a87fd512 --- /dev/null +++ b/src/assets/js/step-by-step-nav.js @@ -0,0 +1,516 @@ +//= require govuk/vendor/polyfills/Element/prototype/classList.js +//= require ../vendor/polyfills/closest.js +//= require ../vendor/polyfills/indexOf.js + +window.GOVUK = window.GOVUK || {} +window.GOVUK.Modules = window.GOVUK.Modules || {}; + +(function (Modules) { + function Gemstepnav ($module) { + this.$module = $module + this.$module.actions = {} // stores text for JS appended elements 'show' and 'hide' on steps, and 'show/hide all' button + this.$module.rememberShownStep = false + this.$module.stepNavSize = false + this.$module.sessionStoreLink = 'govuk-step-nav-active-link' + this.$module.activeLinkClass = 'gem-c-step-nav__list-item--active' + this.$module.activeStepClass = 'gem-c-step-nav__step--active' + this.$module.activeLinkHref = '#content' + this.$module.uniqueId = false + } + + Gemstepnav.prototype.init = function () { + // Indicate that js has worked + this.$module.classList.add('gem-c-step-nav--active') + + // Prevent FOUC, remove class hiding content + this.$module.classList.remove('js-hidden') + + this.$module.stepNavSize = this.$module.classList.contains('gem-c-step-nav--large') ? 'Big' : 'Small' + this.$module.rememberShownStep = !!this.$module.hasAttribute('data-remember') && this.$module.stepNavSize === 'Big' + + this.$module.steps = this.$module.querySelectorAll('.js-step') + this.$module.stepHeaders = this.$module.querySelectorAll('.js-toggle-panel') + this.$module.totalSteps = this.$module.querySelectorAll('.js-panel').length + this.$module.totalLinks = this.$module.querySelectorAll('.gem-c-step-nav__link').length + this.$module.showOrHideAllButton = false + + this.$module.uniqueId = this.$module.getAttribute('data-id') || false + + if (this.$module.uniqueId) { + this.$module.sessionStoreLink = this.$module.sessionStoreLink + '_' + this.$module.uniqueId + } + + const stepNavTracker = new this.StepNavTracker(this.$module.uniqueId, this.$module.totalSteps, this.$module.totalLinks) + + this.getTextForInsertedElements() + this.addButtonstoSteps() + this.addShowHideAllButton() + this.addShowHideToggle() + this.addAriaControlsAttrForShowHideAllButton() + + this.ensureOnlyOneActiveLink() + this.showPreviouslyOpenedSteps() + + this.bindToggleForSteps(stepNavTracker) + this.bindToggleShowHideAllButton(stepNavTracker) + this.bindComponentLinkClicks(stepNavTracker) + } + + Gemstepnav.prototype.getTextForInsertedElements = function () { + this.$module.actions.showText = this.$module.getAttribute('data-show-text') + this.$module.actions.hideText = this.$module.getAttribute('data-hide-text') + this.$module.actions.showAllText = this.$module.getAttribute('data-show-all-text') + this.$module.actions.hideAllText = this.$module.getAttribute('data-hide-all-text') + } + + Gemstepnav.prototype.addShowHideAllButton = function () { + const showAll = document.createElement('div') + const steps = this.$module.querySelectorAll('.gem-c-step-nav__steps')[0] + + showAll.className = 'gem-c-step-nav__controls govuk-!-display-none-print' + showAll.innerHTML = + '' + + this.$module.insertBefore(showAll, steps) + this.$module.showOrHideAllButton = this.$module.querySelectorAll('.js-step-controls-button')[0] + } + + Gemstepnav.prototype.addShowHideToggle = function () { + for (let i = 0; i < this.$module.stepHeaders.length; i++) { + const thisel = this.$module.stepHeaders[i] + + if (!thisel.querySelectorAll('.js-toggle-link').length) { + const showHideSpan = document.createElement('span') + const showHideSpanText = document.createElement('span') + const showHideSpanIcon = document.createElement('span') + const showHideSpanFocus = document.createElement('span') + const thisSectionSpan = document.createElement('span') + + showHideSpan.className = 'gem-c-step-nav__toggle-link js-toggle-link govuk-!-display-none-print' + showHideSpanText.className = 'gem-c-step-nav__button-text js-toggle-link-text' + showHideSpanIcon.className = 'gem-c-step-nav__chevron js-toggle-link-icon' + showHideSpanFocus.className = 'gem-c-step-nav__toggle-link-focus' + thisSectionSpan.className = 'govuk-visually-hidden' + + showHideSpan.appendChild(showHideSpanFocus) + showHideSpanFocus.appendChild(showHideSpanIcon) + showHideSpanFocus.appendChild(showHideSpanText) + + thisSectionSpan.innerHTML = ' this section' + showHideSpan.appendChild(thisSectionSpan) + + thisel.querySelectorAll('.js-step-title-button')[0].appendChild(showHideSpan) + } + } + } + + Gemstepnav.prototype.headerIsOpen = function (stepHeader) { + return (typeof stepHeader.parentNode.getAttribute('show') !== 'undefined') + } + + Gemstepnav.prototype.addAriaControlsAttrForShowHideAllButton = function () { + const ariaControlsValue = this.$module.querySelectorAll('.js-panel')[0].getAttribute('id') + + this.$module.showOrHideAllButton.setAttribute('aria-controls', ariaControlsValue) + } + + // called by show all/hide all, sets all steps accordingly + Gemstepnav.prototype.setAllStepsShownState = function (isShown) { + const data = [] + + for (let i = 0; i < this.$module.steps.length; i++) { + const stepView = new this.StepView(this.$module.steps[i], this.$module) + stepView.setIsShown(isShown) + + if (isShown) { + data.push(this.$module.steps[i].getAttribute('id')) + } + } + + if (isShown) { + this.saveToSessionStorage(this.$module.uniqueId, JSON.stringify(data)) + } else { + this.removeFromSessionStorage(this.$module.uniqueId) + } + } + + // called on load, determines whether each step should be open or closed + Gemstepnav.prototype.showPreviouslyOpenedSteps = function () { + const data = this.loadFromSessionStorage(this.$module.uniqueId) || [] + + for (let i = 0; i < this.$module.steps.length; i++) { + const thisel = this.$module.steps[i] + const id = thisel.getAttribute('id') + const stepView = new this.StepView(thisel, this.$module) + const shouldBeShown = thisel.hasAttribute('data-show') + + // show the step if it has been remembered or if it has the 'data-show' attribute + if ((this.$module.rememberShownStep && data.indexOf(id) > -1) || (shouldBeShown && shouldBeShown !== 'undefined')) { + stepView.setIsShown(true) + } else { + stepView.setIsShown(false) + } + } + + if (data.length > 0) { + this.$module.showOrHideAllButton.setAttribute('aria-expanded', true) + this.setShowHideAllText() + } + } + + Gemstepnav.prototype.addButtonstoSteps = function () { + for (let i = 0; i < this.$module.steps.length; i++) { + const thisel = this.$module.steps[i] + const title = thisel.querySelectorAll('.js-step-title')[0] + const contentId = thisel.querySelectorAll('.js-panel')[0].getAttribute('id') + const titleText = title.textContent || title.innerText // IE8 fallback + + title.outerHTML = + '' + + '' + + '' + } + } + + Gemstepnav.prototype.bindToggleForSteps = function (stepNavTracker) { + const that = this + const togglePanels = this.$module.querySelectorAll('.js-toggle-panel') + + for (let i = 0; i < togglePanels.length; i++) { + togglePanels[i].addEventListener('click', function (event) { + const stepView = new that.StepView(this.parentNode, that.$module) + stepView.toggle() + + const stepIsOptional = this.parentNode.hasAttribute('data-optional') + const toggleClick = new that.StepToggleClick(event, stepView, stepNavTracker, stepIsOptional, that.$module.stepNavSize) + toggleClick.trackClick() + + that.setShowHideAllText() + that.rememberStepState(this.parentNode) + }) + } + } + + // if the step is open, store its id in session store + // if the step is closed, remove its id from session store + Gemstepnav.prototype.rememberStepState = function (step) { + if (this.$module.rememberShownStep) { + const data = JSON.parse(this.loadFromSessionStorage(this.$module.uniqueId)) || [] + const thisstep = step.getAttribute('id') + const shown = step.classList.contains('step-is-shown') + + if (shown) { + data.push(thisstep) + } else { + const i = data.indexOf(thisstep) + if (i > -1) { + data.splice(i, 1) + } + } + this.saveToSessionStorage(this.$module.uniqueId, JSON.stringify(data)) + } + } + + // tracking click events on links in step content + Gemstepnav.prototype.bindComponentLinkClicks = function (stepNavTracker) { + const jsLinks = this.$module.querySelectorAll('.js-link') + const that = this + + for (let i = 0; i < jsLinks.length; i++) { + jsLinks[i].addEventListener('click', function (event) { + const dataPosition = this.getAttribute('data-position') + const linkClick = new that.ComponentLinkClick(event, stepNavTracker, dataPosition, that.$module.stepNavSize) + linkClick.trackClick() + + if (this.getAttribute('rel') !== 'external') { + that.saveToSessionStorage(that.$module.sessionStoreLink, dataPosition) + } + + if (this.getAttribute('href') === that.$module.activeLinkHref) { + that.setOnlyThisLinkActive(this) + that.setActiveStepClass() + } + }) + } + } + + Gemstepnav.prototype.saveToSessionStorage = function (key, value) { + window.sessionStorage.setItem(key, value) + } + + Gemstepnav.prototype.loadFromSessionStorage = function (key, value) { + return window.sessionStorage.getItem(key) + } + + Gemstepnav.prototype.removeFromSessionStorage = function (key) { + window.sessionStorage.removeItem(key) + } + + Gemstepnav.prototype.setOnlyThisLinkActive = function (clicked) { + const allActiveLinks = this.$module.querySelectorAll('.' + this.$module.activeLinkClass) + for (let i = 0; i < allActiveLinks.length; i++) { + allActiveLinks[i].classList.remove(this.$module.activeLinkClass) + } + clicked.parentNode.classList.add(this.$module.activeLinkClass) + } + + // if a link occurs more than once in a step nav, the backend doesn't know which one to highlight + // so it gives all those links the 'active' attribute and highlights the last step containing that link + // if the user clicked on one of those links previously, it will be in the session store + // this code ensures only that link and its corresponding step have the highlighting + // otherwise it accepts what the backend has already passed to the component + Gemstepnav.prototype.ensureOnlyOneActiveLink = function () { + const activeLinks = this.$module.querySelectorAll('.js-list-item.' + this.$module.activeLinkClass) + + if (activeLinks.length <= 1) { + return + } + + const loaded = this.loadFromSessionStorage(this.$module.sessionStoreLink) + const activeParent = this.$module.querySelectorAll('.' + this.$module.activeLinkClass)[0] + const activeChild = activeParent.firstChild + const foundLink = activeChild.getAttribute('data-position') + let lastClicked = loaded || foundLink // the value saved has priority + + // it's possible for the saved link position value to not match any of the currently duplicate highlighted links + // so check this otherwise it'll take the highlighting off all of them + const checkLink = this.$module.querySelectorAll('[data-position="' + lastClicked + '"]')[0] + + if (checkLink) { + if (!checkLink.parentNode.classList.contains(this.$module.activeLinkClass)) { + lastClicked = checkLink + } + } else { + lastClicked = foundLink + } + + this.removeActiveStateFromAllButCurrent(activeLinks, lastClicked) + this.setActiveStepClass() + } + + Gemstepnav.prototype.removeActiveStateFromAllButCurrent = function (activeLinks, current) { + for (let i = 0; i < activeLinks.length; i++) { + const thisel = activeLinks[i] + if (thisel.querySelectorAll('.js-link')[0].getAttribute('data-position').toString() !== current.toString()) { + thisel.classList.remove(this.$module.activeLinkClass) + const visuallyHidden = thisel.querySelectorAll('.visuallyhidden') + if (visuallyHidden.length) { + visuallyHidden[0].parentNode.removeChild(visuallyHidden[0]) + } + } + } + } + + Gemstepnav.prototype.setActiveStepClass = function () { + // remove the 'active/open' state from all steps + const allActiveSteps = this.$module.querySelectorAll('.' + this.$module.activeStepClass) + for (let i = 0; i < allActiveSteps.length; i++) { + allActiveSteps[i].classList.remove(this.$module.activeStepClass) + allActiveSteps[i].removeAttribute('data-show') + } + + // find the current page link and apply 'active/open' state to parent step + const activeLink = this.$module.querySelectorAll('.' + this.$module.activeLinkClass)[0] + if (activeLink) { + const activeStep = activeLink.closest('.gem-c-step-nav__step') + activeStep.classList.add(this.$module.activeStepClass) + activeStep.setAttribute('data-show', '') + } + } + + Gemstepnav.prototype.bindToggleShowHideAllButton = function (stepNavTracker) { + const that = this + + this.$module.showOrHideAllButton.addEventListener('click', function (event) { + const textContent = this.textContent || this.innerText + const shouldShowAll = textContent === that.$module.actions.showAllText + + // Fire GA click tracking + stepNavTracker.trackClick('pageElementInteraction', (shouldShowAll ? 'stepNavAllShown' : 'stepNavAllHidden'), { + label: (shouldShowAll ? that.$module.actions.showAllText : that.$module.actions.hideAllText) + ': ' + that.$module.stepNavSize + }) + + that.setAllStepsShownState(shouldShowAll) + that.$module.showOrHideAllButton.setAttribute('aria-expanded', shouldShowAll) + that.setShowHideAllText() + + return false + }) + } + + Gemstepnav.prototype.setShowHideAllText = function () { + const shownSteps = this.$module.querySelectorAll('.step-is-shown').length + const showAllChevon = this.$module.showOrHideAllButton.querySelector('.js-step-controls-button-icon') + const showAllButtonText = this.$module.showOrHideAllButton.querySelector('.js-step-controls-button-text') + // Find out if the number of is-opens == total number of steps + const shownStepsIsTotalSteps = shownSteps === this.$module.totalSteps + + if (shownStepsIsTotalSteps) { + showAllButtonText.innerHTML = this.$module.actions.hideAllText + showAllChevon.classList.remove('gem-c-step-nav__chevron--down') + } else { + showAllButtonText.innerHTML = this.$module.actions.showAllText + showAllChevon.classList.add('gem-c-step-nav__chevron--down') + } + } + + Gemstepnav.prototype.StepView = function (stepElement, $module) { + this.stepElement = stepElement + this.stepContent = this.stepElement.querySelectorAll('.js-panel')[0] + this.titleButton = this.stepElement.querySelectorAll('.js-step-title-button')[0] + const textElement = this.stepElement.querySelectorAll('.js-step-title-text')[0] + this.title = textElement.textContent || textElement.innerText + this.title = this.title.replace(/^\s+|\s+$/g, '') // this is 'trim' but supporting IE8 + this.showText = $module.actions.showText + this.hideText = $module.actions.hideText + this.upChevronSvg = $module.upChevronSvg + this.downChevronSvg = $module.downChevronSvg + + this.show = function () { + this.setIsShown(true) + } + + this.hide = function () { + this.setIsShown(false) + } + + this.toggle = function () { + this.setIsShown(this.isHidden()) + } + + this.setIsShown = function (isShown) { + const toggleLink = this.stepElement.querySelectorAll('.js-toggle-link')[0] + const toggleLinkText = toggleLink.querySelector('.js-toggle-link-text') + const stepChevron = toggleLink.querySelector('.js-toggle-link-icon') + + if (isShown) { + this.stepElement.classList.add('step-is-shown') + this.stepContent.classList.remove('js-hidden') + toggleLinkText.innerHTML = this.hideText + stepChevron.classList.remove('gem-c-step-nav__chevron--down') + this.stepContent.style.display = 'block' + } else { + this.stepElement.classList.remove('step-is-shown') + this.stepContent.classList.add('js-hidden') + toggleLinkText.innerHTML = this.showText + stepChevron.classList.add('gem-c-step-nav__chevron--down') + this.stepContent.style.display = 'none' + } + this.titleButton.setAttribute('aria-expanded', isShown) + } + + this.isShown = function () { + return this.stepElement.classList.contains('step-is-shown') + } + + this.isHidden = function () { + return !this.isShown() + } + + this.numberOfContentItems = function () { + return this.stepContent.querySelectorAll('.js-link').length + } + } + + Gemstepnav.prototype.StepToggleClick = function (event, stepView, stepNavTracker, stepIsOptional, stepNavSize) { + this.target = event.target + this.stepIsOptional = stepIsOptional + this.stepNavSize = stepNavSize + + this.trackClick = function () { + const trackingOptions = { label: this.trackingLabel(), dimension28: stepView.numberOfContentItems().toString() } + stepNavTracker.trackClick('pageElementInteraction', this.trackingAction(), trackingOptions) + } + + this.trackingLabel = function () { + const clickedNearbyToggle = this.target.closest('.js-step').querySelectorAll('.js-toggle-panel')[0] + return clickedNearbyToggle.getAttribute('data-position') + ' - ' + stepView.title + ' - ' + this.locateClickElement() + ': ' + this.stepNavSize + this.isOptional() + } + + // returns index of the clicked step in the overall number of steps + this.stepIndex = function () { // eslint-disable-line no-unused-vars + return this.$module.steps.index(stepView.element) + 1 + } + + this.trackingAction = function () { + return (stepView.isHidden() ? 'stepNavHidden' : 'stepNavShown') + } + + this.locateClickElement = function () { + if (this.clickedOnIcon()) { + return this.iconType() + ' click' + } else if (this.clickedOnHeading()) { + return 'Heading click' + } else { + return 'Elsewhere click' + } + } + + this.clickedOnIcon = function () { + return this.target.classList.contains('js-toggle-link') + } + + this.clickedOnHeading = function () { + return this.target.classList.contains('js-step-title-text') + } + + this.iconType = function () { + return (stepView.isHidden() ? 'Minus' : 'Plus') + } + + this.isOptional = function () { + return (this.stepIsOptional ? ' ; optional' : '') + } + } + + Gemstepnav.prototype.ComponentLinkClick = function (event, stepNavTracker, linkPosition, size) { + this.size = size + this.target = event.target + + this.trackClick = function () { + const trackingOptions = { label: this.target.getAttribute('href') + ' : ' + this.size } + const dimension28 = this.target.closest('.gem-c-step-nav__list').getAttribute('data-length') + + if (dimension28) { + trackingOptions.dimension28 = dimension28 + } + + stepNavTracker.trackClick('stepNavLinkClicked', linkPosition, trackingOptions) + } + } + + // A helper that sends a custom event request to Google Analytics if + // the GOVUK module is setup + Gemstepnav.prototype.StepNavTracker = function (uniqueId, totalSteps, totalLinks) { + this.totalSteps = totalSteps + this.totalLinks = totalLinks + this.uniqueId = uniqueId + + this.trackClick = function (category, action, options) { + // dimension26 records the total number of expand/collapse steps in this step nav + // dimension27 records the total number of links in this step nav + // dimension28 records the number of links in the step that was shown/hidden (handled in click event) + if (window.GOVUK.analytics && window.GOVUK.analytics.trackEvent) { + options = options || {} + options.dimension26 = options.dimension26 || this.totalSteps.toString() + options.dimension27 = options.dimension27 || this.totalLinks.toString() + options.dimension96 = options.dimension96 || this.uniqueId + window.GOVUK.analytics.trackEvent(category, action, options) + } + } + } + + Modules.Gemstepnav = Gemstepnav +})(window.GOVUK.Modules) diff --git a/src/assets/scss/index.scss b/src/assets/scss/index.scss index 14c1bd62..3386ff12 100644 --- a/src/assets/scss/index.scss +++ b/src/assets/scss/index.scss @@ -3,6 +3,8 @@ $govuk-global-styles: true; @import "node_modules/govuk-frontend/dist/govuk/all"; @import "node_modules/@x-govuk/govuk-prototype-components/x-govuk/all"; @import "src/assets/scss/_scrollable-container.scss"; +@import "./step-by-step-nav.scss"; + .app-inset-text---error { border-left: 5px solid govuk-colour('red'); @@ -101,4 +103,4 @@ $govuk-global-styles: true; code, code * { font-family: monospace; -} +} \ No newline at end of file diff --git a/src/assets/scss/step-by-step-nav.scss b/src/assets/scss/step-by-step-nav.scss new file mode 100644 index 00000000..40d6318e --- /dev/null +++ b/src/assets/scss/step-by-step-nav.scss @@ -0,0 +1,857 @@ +.gem-c-step-nav { + margin-bottom: 30px +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav.gem-c-step-nav--large { + margin-bottom:60px + } +} + +.js-enabled .gem-c-step-nav.js-hidden { + display: none +} + +.gem-c-step-nav__controls { + padding: 3px 3px 0 0 +} + +.gem-c-step-nav____title-text-focus { + margin-bottom: 13px; + display: inline-block +} + +.gem-c-step-nav__chevron { + box-sizing: border-box; + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + border: .0625rem solid; + border-radius: 50%; + vertical-align: text-top +} + +.gem-c-step-nav--large .gem-c-step-nav__chevron { + vertical-align: top +} + +.gem-c-step-nav__chevron::after { + content: ""; + box-sizing: border-box; + display: block; + position: absolute; + bottom: .3125rem; + left: .375rem; + width: .375rem; + height: .375rem; + -webkit-transform: rotate(-45deg); + -ms-transform: rotate(-45deg); + transform: rotate(-45deg); + border-top: .125rem solid; + border-right: .125rem solid +} + +.gem-c-step-nav__chevron--down { + -webkit-transform: rotate(180deg); + -ms-transform: rotate(180deg); + transform: rotate(180deg) +} + +.gem-c-step-nav__button { + color: #1d70b8; + cursor: pointer; + background: none; + border: 0; + margin: 0 +} + +.gem-c-step-nav__button:hover { + background: #f3f2f1 +} + +.gem-c-step-nav__button:hover .gem-c-step-nav__chevron { + color: #0b0c0c; + background: #0b0c0c +} + +.gem-c-step-nav__button:hover .gem-c-step-nav__chevron::after { + color: #f3f2f1 +} + +.gem-c-step-nav__button:hover .gem-c-step-nav__button-text { + color: #0b0c0c +} + +.gem-c-step-nav__button:focus { + outline: 0 +} + +.gem-c-step-nav__button:focus .gem-c-step-nav__chevron { + color: #0b0c0c; + background: #0b0c0c +} + +.gem-c-step-nav__button:focus .gem-c-step-nav__chevron::after { + color: #fd0 +} + +.gem-c-step-nav__button:focus .gem-c-step-nav____title-text-focus,.gem-c-step-nav__button:focus .gem-c-step-nav__toggle-link-focus { + outline: 3px solid rgba(0,0,0,0); + color: #0b0c0c; + background-color: #fd0; + box-shadow: 0 -2px #fd0,0 4px #0b0c0c; + text-decoration: none; + -webkit-box-decoration-break: clone; + box-decoration-break: clone +} + +.gem-c-step-nav__button:focus .gem-c-step-nav__toggle-link-focus { + padding-bottom: 2px +} + +.gem-c-step-nav__button::-moz-focus-inner { + border: 0 +} + +.gem-c-step-nav__button--title { + display: inline-block; + padding: 5px 0 0; + text-align: left; + color: #0b0c0c; + width: 100%; + font-size: 19px; + font-weight: bold; + line-height: 1.2; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav__button--title { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav__button--title { + font-size:19px; + line-height: 1.2 + } +} + +.gem-c-step-nav--large .gem-c-step-nav__button--title { + font-size: 19px; + font-weight: bold; + line-height: 1.3; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav--large .gem-c-step-nav__button--title { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__button--title { + font-size:24px; + line-height: 1.3 + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__button--title { + padding-top:10px + } +} + +.gem-c-step-nav__button--controls { + position: relative; + z-index: 1; + margin: .5em 0 14px; + padding: 5px 0 5px; + font-size: 15px; + font-weight: normal; + line-height: 1.3; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav__button--controls { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav__button--controls { + font-size:15px; + line-height: 1.3 + } +} + +.gem-c-step-nav--large .gem-c-step-nav__button--controls { + font-size: 15px; + font-weight: normal; + line-height: 1.3; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav--large .gem-c-step-nav__button--controls { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__button--controls { + font-size:19px; + line-height: 1.3 + } +} + +.gem-c-step-nav__button--controls:focus { + outline: 3px solid rgba(0,0,0,0); + color: #0b0c0c; + background-color: #fd0; + box-shadow: 0 -2px #fd0,0 4px #0b0c0c; + text-decoration: none; + -webkit-box-decoration-break: clone; + box-decoration-break: clone +} + +.gem-c-step-nav__button--controls:focus .gem-c-step-nav__button-text { + text-decoration: none +} + +.gem-c-step-nav__button-text { + display: inline-block; + text-align: left; + min-width: 2.5em; + margin-left: 5px +} + +.gem-c-step-nav--large .gem-c-step-nav__button-text { + min-width: 2.5em; + margin-left: 5px +} + +.gem-c-step-nav__button-text--all { + min-width: 6.2142857143em +} + +.gem-c-step-nav--large .gem-c-step-nav__button-text--all { + min-width: 6.25em +} + +.gem-c-step-nav__steps { + padding: 0; + margin: 0 +} + +.gem-c-step-nav__step { + position: relative; + padding-left: 2.8125em; + list-style: none +} + +.gem-c-step-nav__step::after { + content: ""; + position: absolute; + z-index: 2; + width: 0; + height: 100%; + border-left: solid 1px #b1b4b6; + background: #fff; + left: 0; + margin-left: .90625em; + top: .9375em +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__step { + padding-left:3.75em + } + + .gem-c-step-nav--large .gem-c-step-nav__step::after { + left: 0; + margin-left: 1.0625em; + top: 1.875em + } +} + +.gem-c-step-nav__step:last-child::before { + content: ""; + position: absolute; + z-index: 6; + bottom: 0; + left: 0; + margin-left: 7.5px; + width: 15px; + height: 0; + border-bottom: solid 1px #b1b4b6 +} + +.gem-c-step-nav__step:last-child::after { + height: -webkit-calc(100% - 15px); + height: calc(100% - 15px) +} + +.gem-c-step-nav__step:last-child .gem-c-step-nav__help::after { + height: 100% +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__step:last-child::before { + margin-left:8.75px; + width: 17.5px + } + + .gem-c-step-nav--large .gem-c-step-nav__step:last-child::after { + height: calc(100% - 30px) + } +} + +.gem-c-step-nav__step--active:last-child::before,.gem-c-step-nav__step--active .gem-c-step-nav__circle--number,.gem-c-step-nav__step--active::after,.gem-c-step-nav__step--active .gem-c-step-nav__help::after { + border-color: #0b0c0c +} + +.gem-c-step-nav__circle { + box-sizing: border-box; + position: absolute; + z-index: 5; + top: 3px; + left: 0; + width: 1.875em; + height: 1.875em; + color: #0b0c0c; + background: #fff; + border-radius: 100px; + text-align: center +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__circle { + top:11px; + width: 1.8421052632em; + height: 1.8421052632em + } +} + +.gem-c-step-nav__circle--number { + border: solid 1px #b1b4b6; + font-size: 16px; + font-weight: bold; + line-height: 29px; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav__circle--number { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav__circle--number { + font-size:16px; + line-height: 29px + } +} + +.gem-c-step-nav--large .gem-c-step-nav__circle--number { + font-size: 16px; + font-weight: bold; + line-height: 29px; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav--large .gem-c-step-nav__circle--number { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__circle--number { + font-size:19px; + line-height: 34px + } +} + +.gem-c-step-nav__step--active .gem-c-step-nav__circle--number { + background-color: #0b0c0c +} + +.gem-c-step-nav__step--active .gem-c-step-nav__circle--number .gem-c-step-nav__circle-background { + text-shadow: none; + color: #fff +} + +.gem-c-step-nav__circle--logic { + left: 3px; + width: 1.5789473684em; + height: 1.5789473684em; + font-size: 19px; + font-weight: bold; + line-height: 28px; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav__circle--logic { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav__circle--logic { + font-size:19px; + line-height: 28px + } +} + +.gem-c-step-nav--large .gem-c-step-nav__circle--logic { + font-size: 19px; + font-weight: bold; + line-height: 28px; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav--large .gem-c-step-nav__circle--logic { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__circle--logic { + font-size:24px; + line-height: 34px + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__circle--logic { + width:1.4583333333em; + height: 1.4583333333em + } +} + +.gem-c-step-nav__circle-inner { + float: right; + min-width: 100% +} + +.gem-c-step-nav__circle-background { + text-shadow: 0 -0.1em 0 #fff,.1em 0 0 #fff,0 .1em 0 #fff,-0.1em 0 0 #fff +} + +.gem-c-step-nav__circle-step-label,.gem-c-step-nav__circle-step-colon { + position: absolute !important; + width: 1px !important; + height: 1px !important; + margin: 0 !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + border: 0 !important; + white-space: nowrap !important; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none +} + +.gem-c-step-nav__circle-step-label::before,.gem-c-step-nav__circle-step-colon::before { + content: " " +} + +.gem-c-step-nav__circle-step-label::after,.gem-c-step-nav__circle-step-colon::after { + content: " " +} + +.gem-c-step-nav__header { + border-top: solid 1px #b1b4b6; + padding: 5px 0 30px +} + +.gem-c-step-nav--large .gem-c-step-nav__header { + padding-top: 10px +} + +.js-enabled .gem-c-step-nav__header { + padding: 0 +} + +.gem-c-step-nav--active .gem-c-step-nav__header { + cursor: pointer +} + +.gem-c-step-nav__title { + margin: 0; + font-size: 19px; + font-weight: bold; + line-height: 1.4; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: #0b0c0c +} + +@media print { + .gem-c-step-nav__title { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav__title { + font-size:19px; + line-height: 1.4 + } +} + +@media print { + .gem-c-step-nav__title { + color: #000 + } +} + +.gem-c-step-nav--large .gem-c-step-nav__title { + font-size: 19px; + font-weight: bold; + line-height: 1.4; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav--large .gem-c-step-nav__title { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__title { + font-size:24px; + line-height: 1.4 + } +} + +.gem-c-step-nav__toggle-link { + display: block; + color: #1d70b8; + text-transform: capitalize; + padding-bottom: 30px; + font-size: 15px; + font-weight: normal; + line-height: 1.2; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav__toggle-link { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav__toggle-link { + font-size:15px; + line-height: 1.2 + } +} + +.gem-c-step-nav--large .gem-c-step-nav__toggle-link { + font-size: 15px; + font-weight: normal; + line-height: 1.2; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav--large .gem-c-step-nav__toggle-link { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__toggle-link { + font-size:19px; + line-height: 1.2 + } +} + +.gem-c-step-nav__panel { + padding-bottom: 25px; + font-size: 16px; + font-weight: normal; + line-height: 1.3; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: #0b0c0c +} + +@media print { + .gem-c-step-nav__panel { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav__panel { + font-size:16px; + line-height: 1.3 + } +} + +@media print { + .gem-c-step-nav__panel { + color: #000 + } +} + +.gem-c-step-nav--large .gem-c-step-nav__panel { + font-size: 16px; + font-weight: normal; + line-height: 1.3; + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +@media print { + .gem-c-step-nav--large .gem-c-step-nav__panel { + font-family: sans-serif + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__panel { + font-size:19px; + line-height: 1.3 + } +} + +.js-enabled .gem-c-step-nav__panel.js-hidden { + display: none +} + +.gem-c-step-nav__paragraph { + padding-bottom: 15px; + margin: 0; + font-size: inherit +} + +.gem-c-step-nav__paragraph+.gem-c-step-nav__list { + margin-top: -5px +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__paragraph { + padding-bottom:30px + } +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__paragraph+.gem-c-step-nav__list { + margin-top:-15px + } +} + +.gem-c-step-nav__list { + padding: 0; + padding-bottom: 10px; + list-style: none +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__list { + padding-bottom:20px + } +} + +.gem-c-step-nav__list--choice { + margin-left: 20px; + list-style: disc +} + +.gem-c-step-nav__list--choice .gem-c-step-nav__list-item--active::before { + left: -65px +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__list--choice .gem-c-step-nav__list-item--active::before { + left:-80px + } +} + +.gem-c-step-nav__list-item { + margin-bottom: 10px +} + +.gem-c-step-nav__link { + font-family: "GDS Transport",arial,sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-decoration: underline; + text-decoration-thickness: max(1px, .0625rem); + text-underline-offset: .1578em +} + +@media print { + .gem-c-step-nav__link { + font-family: sans-serif + } +} + +.gem-c-step-nav__link:hover { + text-decoration-thickness: max(3px, .1875rem, .12em); + -webkit-text-decoration-skip-ink: none; + text-decoration-skip-ink: none; + -webkit-text-decoration-skip: none; + text-decoration-skip: none +} + +.gem-c-step-nav__link:focus { + outline: 3px solid rgba(0,0,0,0); + color: #0b0c0c; + background-color: #fd0; + box-shadow: 0 -2px #fd0,0 4px #0b0c0c; + text-decoration: none; + -webkit-box-decoration-break: clone; + box-decoration-break: clone +} + +.gem-c-step-nav__link:link { + color: #1d70b8 +} + +.gem-c-step-nav__link:visited { + color: #4c2c92 +} + +.gem-c-step-nav__link:hover { + color: #003078 +} + +.gem-c-step-nav__link:active { + color: #0b0c0c +} + +.gem-c-step-nav__link:focus { + color: #0b0c0c +} + +.gem-c-step-nav__link-active-context { + position: absolute !important; + width: 1px !important; + height: 1px !important; + margin: 0 !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + border: 0 !important; + white-space: nowrap !important; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none +} + +.gem-c-step-nav__link-active-context::before { + content: " " +} + +.gem-c-step-nav__link-active-context::after { + content: " " +} + +.gem-c-step-nav__list-item--active { + position: relative +} + +.gem-c-step-nav__list-item--active::before { + box-sizing: border-box; + content: ""; + position: absolute; + z-index: 5; + top: .6em; + left: -45px; + margin-top: -0.5px; + margin-left: 15px; + width: 15px; + height: 1px; + background: #0b0c0c +} + +@media(min-width: 40.0625em) { + .gem-c-step-nav--large .gem-c-step-nav__list-item--active::before { + left:-60px; + margin-left: 17.5px + } +} + +.gem-c-step-nav__list-item--active .gem-c-step-nav__link:link,.gem-c-step-nav__list-item--active .gem-c-step-nav__link:visited { + color: #0b0c0c +} + +@media print { + .gem-c-step-nav__list-item--active .gem-c-step-nav__link:link,.gem-c-step-nav__list-item--active .gem-c-step-nav__link:visited { + color: #000 + } +} + +.gem-c-step-nav__list-item--active .gem-c-step-nav__link:hover { + color: rgba(11,12,12,.99) +} + +.gem-c-step-nav__list-item--active .gem-c-step-nav__link:active,.gem-c-step-nav__list-item--active .gem-c-step-nav__link:focus { + color: #0b0c0c +} + +@media print { + .gem-c-step-nav__list-item--active .gem-c-step-nav__link:active,.gem-c-step-nav__list-item--active .gem-c-step-nav__link:focus { + color: #000 + } +} + +.gem-c-step-nav__context { + display: inline-block; + font-weight: normal; + color: #505a5f +} + +.gem-c-step-nav__context::before { + content: " – " +} + +@media print { + .gem-c-step-nav__panel { + display: block !important + } +} diff --git a/src/controllers/OrganisationsController.js b/src/controllers/OrganisationsController.js index 05984dd0..3aec523a 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,9 +71,60 @@ 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) { + try { + // get the organisation name + const lpa = req.params.lpa + const organisationResult = await datasette.runQuery(`SELECT name FROM organisation WHERE organisation = '${lpa}'`) + const organisation = organisationResult.formattedData[0] + + // get the dataset name + const datasetId = req.params.dataset + const datasetResult = await datasette.runQuery(`SELECT name FROM dataset WHERE dataset = '${datasetId}'`) + const dataset = datasetResult.formattedData[0] + + const params = { + organisation, + dataset + } + + res.render('organisations/get-started.html', params) + } catch (err) { + logger.error(err) + next(err) + } } + } export default organisationsController diff --git a/src/routes/organisations.js b/src/routes/organisations.js index 250bb13e..88217c1e 100644 --- a/src/routes/organisations.js +++ b/src/routes/organisations.js @@ -7,4 +7,6 @@ router.get('/', OrganisationsController.getOrganisations) router.get('/:lpa/overview', OrganisationsController.getOverview) +router.get('/:lpa/dataset/:dataset/get-started', OrganisationsController.getGetStarted) + export default router 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/src/views/organisations/get-started.html b/src/views/organisations/get-started.html new file mode 100644 index 00000000..b9678e16 --- /dev/null +++ b/src/views/organisations/get-started.html @@ -0,0 +1,230 @@ +{% extends "layouts/main.html" %} + +{% set serviceType = 'Submit' %} +{% set pageName %} + {{organisation.name}} - {{dataset.name}} - Get started +{% endset %} + +{% block beforeContent %} + +{{ super() }} + +{% endblock %} + +{% block content %} + +
+
+ {{ organisation.name }} +

{{ dataset.name }}

+
+
+ +
+
+ +

How to prepare and submit your {{ dataset.name }} data

+ +
+
    +
  1. +
    +

    + + + + Step 1 + + + + + + Prepare your data + +

    +
    + +
    +

    Read the specifications to understand what + data is required. Start with + the fields you already have, you can update and + improve your data over time.

    +
    + +
  2. + +
  3. +
    +

    + + + + Step 2 + + + + + + Check your data meets the specifications + +

    +
    + +
    +
      +
    1. +

      + The check service can help you understand + if + your data is ready to submit or if you need to change anything before you publish it on your website. +

      + +

      You need to choose to provide your data in one of these file formats: +

      + +
        +
      • CSV
      • +
      • GeoJSON
      • +
      • GML
      • +
      • GeoPackage
      • +
      + +

      Alternatively you can provide us with a URL.

      + +
        +
      1. + Check your data +
      2. +
      +
    2. +
    +
    + +
  4. + +
  5. +
    +

    + + + + Step 3 + + + + + + Publish your data + +

    +
    + +
    +

    Your data must be hosted on a URL the public can access (this is your + data URL).

    + +

    You must link to that URL from a webpage about the data (your + documentation URL).

    + +

    Your documentation URL needs to be on your official planning authority + website, usually ending in gov.uk.

    + +

    It should also include a statement that the data is provided under the + Open Government Licence.

    +
    + +
  6. + +
  7. +
    +

    + + + + Step 4 + + + + + + Submit your data + +

    +
    + +
    +

    Use the submit + data service to submit your + data URL, your documentation URL + and that the data is provided under the Open Government Licence.

    + +

    We’ll process your submission and add your dataset to the planning data + platform.

    + +
      +
    1. + Submit + your data +
    2. +
    +
    + +
  8. + +
  9. +
    +

    + + + + Step 5 + + + + + + Update your data + +

    +
    + +
    +

    + Whenever the data changes, update it on your endpoint URL. We will collect data from your endpoint URL + every day. +

    + +

    + Your endpoint URL needs to remain the same, don’t change it when you make updates. +

    + +
    + +
  10. + +
+
+ +
+
+ +{% 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/get-startedPage.test.js b/test/unit/get-startedPage.test.js new file mode 100644 index 00000000..ee0d00a8 --- /dev/null +++ b/test/unit/get-startedPage.test.js @@ -0,0 +1,55 @@ +// getStartedPage.test.js + +import { describe, it, expect } from 'vitest' +import config from '../../config/index.js' +import nunjucks from 'nunjucks' +import addFilters from '../../src/filters/filters' +import { runGenericPageTests } from './generic-page.js' +import jsdom from 'jsdom' + +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 +}) + +const datasetNameMapping = new Map([ + ['article-4-direction', 'Article 4 Direction'], + ['article-4-direction-area', 'Article 4 Direction Area'] + // ... +]) + +addFilters(nunjucksEnv, { datasetNameMapping }) + +describe('Get Started Page', () => { + const params = { + organisation: { + name: 'mock org' + }, + dataset: { + name: 'World heritage site buffer zone' + }, + serviceName: config.serviceName + } + const html = nunjucks.render('organisations/get-started.html', params) + + const dom = new jsdom.JSDOM(html) + const document = dom.window.document + + runGenericPageTests(html, { + pageTitle: 'mock org - World heritage site buffer zone - Get started - Submit planning and housing data for England', + serviceName: config.serviceName + }) + + it('Renders the correct headings', () => { + expect(document.querySelector('span.govuk-caption-xl').textContent).toEqual('mock org') + expect(document.querySelector('h1').textContent).toContain('World heritage site buffer zone') + expect(document.querySelector('h2').textContent).toContain('How to prepare and submit your World heritage site buffer zone data') + }) +}) diff --git a/test/unit/organisationsController.test.js b/test/unit/organisationsController.test.js index a36c3be7..9038d19e 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', () => { @@ -9,6 +10,12 @@ vi.mock('../../src/utils/utils.js', () => { } }) +vi.mock('../../src/services/datasette.js', () => ({ + default: { + runQuery: vi.fn() + } +})) + describe('OrganisationsController.js', () => { beforeEach(() => { vi.resetAllMocks() @@ -31,7 +38,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 +64,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,10 +72,123 @@ 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', () => { + it('should render the get-started template with the correct params', async () => { + const req = { params: { lpa: 'example-lpa', dataset: 'example-dataset' } } + const res = { render: vi.fn() } + const next = vi.fn() + + datasette.runQuery.mockImplementation((query) => { + if (query.includes('example-lpa')) { + return { + formattedData: [ + { name: 'Example LPA' } + ] + } + } else if (query.includes('example-dataset')) { + return { + formattedData: [ + { name: 'Example Dataset' } + ] + } + } + }) + + await organisationsController.getGetStarted(req, res, next) + + expect(res.render).toHaveBeenCalledTimes(1) + expect(res.render).toHaveBeenCalledWith('organisations/get-started.html', { + organisation: { name: 'Example LPA' }, + dataset: { name: 'Example Dataset' } + }) + }) + + it('should catch and pass errors to the next function', async () => { + const req = { params: { lpa: 'example-lpa', dataset: 'example-dataset' } } + const res = { render: vi.fn() } + const next = vi.fn() + + // Mock the datasette.runQuery method to throw an error + datasette.runQuery.mockImplementation(() => { + throw new Error('example error') + }) + + await organisationsController.getGetStarted(req, res, next) + + expect(next).toHaveBeenCalledTimes(1) + expect(next).toHaveBeenCalledWith(expect.any(Error)) + }) }) }) diff --git a/webpack.config.mjs b/webpack.config.mjs index 0a01a9e9..7586480d 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -23,7 +23,9 @@ 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', + 'step-by-step-nav': '/src/assets/js/step-by-step-nav.js', + 'list-filter': '/src/assets/js/list-filter.js' }, output: { filename: '[name].bundle.js',