diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..00f195a4f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# dont include the following files in the zip +.gitattributes export-ignore +README.md export-ignore +CHANGELOG.md export-ignore +CONTRIBUTING.md export-ignore +create-release.sh export-ignore +travis.yml export-ignore +.travis export-ignore +app.json export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index f2b7a43bdb..022498ba1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # ignore tracking usage data config - we want a different one for each user usage-data-config.json +# ignore extension SCSS because it's dynamically generated +lib/extensions/_extensions.scss .env .sass-cache .DS_Store diff --git a/.travis.yml b/.travis.yml index 2c421e9528..4c56fd1bb6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,20 +1,37 @@ language: node_js + node_js: -- "lts/*" -before_deploy: -- test $TRAVIS_TEST_RESULT = 0 -before_install: -- npm install -g mocha -deploy: -- provider: script # (If version.txt is updated) - create a new tag and push to Github, update the latest-release branch - script: ./create-release.sh - on: master - # Automatic deploys are enabled in Heroku for this app - # Every push to master will deploy a new version of this app. Deploys happen automatically. - # Heroku will wait for CI to pass before deploying. + - "lts/*" + - "8" + +os: + - linux + - osx + - windows + +jobs: + include: + # If version.txt is updated on the master branch: + # - create a new tag and push it to Github + # - update the latest-release branch + - stage: deploy + name: "Create release on GitHub đ" + os: linux + script: echo "Checking whether we should release..." + deploy: + - provider: script + script: ./create-release.sh + on: + branch: master + env: + # DEPLOY_KEY + - secure: qpuH/3v+wsMLy9C3bGjallB6KxFQAtDlyZf3jmX8UQ0703tIvBnZoC1h9EJXOqxZbirLPHon7g902nOuhqUfrsPQgV7XEmXMGZsp31PuK/1lkyTCG3fAdeg8wcghwZn10Gw8jYZpOfWIf2GPDL+6utIU6Aog+odcls7tHWLVT80= + +# Automatic deploys are also enabled in Heroku for this app +# Every push to master will automatically deploy a new version of this app. +# Heroku will wait for CI to pass before deploying. + notifications: email: false + sudo: false -env: - global: - - secure: esj3ut+TwKX25QzU1koJyTVIX+x1V55graI65X8LiYXKcOP040VPJ9lIC0HeRs5UViBM0Ur1F93rGdgW2zYVzu5MLZoH7KjpzMcG30x+FDqLChT0JfNcLLtLm/wIhx/OAAmsBxXlvjPp092BeEKd3Jug8d329TxwwK5G5XxAatg= diff --git a/.travis/README.md b/.travis/README.md index f81b54c6d1..c585c90e48 100644 --- a/.travis/README.md +++ b/.travis/README.md @@ -1,18 +1,48 @@ # Travis encrypted files -This directory contains a public/private keypair generated just for this repository. +This directory contains an encrypted private deploy key with write access to the +Prototype Kit repository. -The public key is a deploy key which has been added to the GitHub repo for push access. +It has been encrypted using a key stored in the DEPLOY_KEY environment +variable, which is itself encrypted using `travis encrypt`. -The private key is encrypted using `travis encrypt-file` and then committed to this repo. +The deploy key is decrypted in create-release.sh. -The decrypt commands are in `.travis.yml`. +To update the key: -To regenerate a key: +1. Generate a new keypair using ssh-keygen + + ``` + ssh-keygen -b 4096 -f .travis/prototype-kit-deploy-key + ``` -``` -ssh-keygen -b 4096 -f .travis/govuk_prototype_kit # Make a new keypair -travis encrypt-file .travis/govuk_prototype_kit # Encrypt the private key -mv govuk_prototype_kit.enc .travis/ # Move the private key to the right place -rm .travis/govuk_prototype_kit # Remove the unencrypted private key -``` +2. Add the *public* key as a new [deploy key], with write access to the + repository + + ``` + cat .travis/prototype-kit-deploy-key.pub + ``` + +2. Generate a new random string which we can use as an encryption key + +3. Encrypt the private key using ssh-keygen + + ``` + openssl aes-256-cbc -k [encryption key here] \ + -in prototype-kit-deploy-key \ + -out prototype-kit-deploy-key.enc + ``` + +4. Remove the unencrypted private key and the public key + +5. Encrypt the private key using the encryption key + + ``` + travis encrypt DEPLOY_KEY=[encryption key] + ``` + +6. Add the encrypted variable to the environment variables for the deploy job + in .travis.yml + + +[deploy key]: https://github.com/alphagov/govuk-prototype-kit/settings/keys diff --git a/.travis/govuk_prototype_kit.enc b/.travis/govuk_prototype_kit.enc deleted file mode 100644 index 328d986903..0000000000 Binary files a/.travis/govuk_prototype_kit.enc and /dev/null differ diff --git a/.travis/govuk_prototype_kit.pub b/.travis/govuk_prototype_kit.pub deleted file mode 100644 index 0c934e9428..0000000000 --- a/.travis/govuk_prototype_kit.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDqbULxuN/WRr4EH3opb5e1K+VSR3ZlJagYS/D1JP7wrRA0drjs3ioR6FcxmHGrv3gAIeRPhwQJpqHC8jHpdNCsXc8IjzRGpB78CdV1LRI8XrBJO4FBHjp+w5uzrKM38fnRyR50R4iuXIUFQUh7JFCGd0URzoCljc/ts3ZWF6XDnSUK0zu/JhPjLuSGDsZxay4eZxR3pkPKEM9zznkn33m1/KzN8g/8JncnPinAucVCGHlZ5d/gggnrgVESob50MDMDy3AZI4fDwltlLVlZG5XLfi5uIou73mG8pDrGISiEz4xvA4aYgaKvng/3SrV5AzEV2O2N43c+1bc2T7E4NTl7H0QDn6lqxN9K2dtOK999J7s0xtmFe8yQZht1EWLWJQ462pCLRz8aGK6THbX4Mb/fOVjKj5OerOr+BJFwRXGr7ielXSLiPscjoLr3Ey7WYb5a7Bp3eiAtK/6BkXTGUcq0J2OMRA4rvdvvlCcL1q0oXORHhLqD87Mf+v0n9pGS4d0z7nlVFHC7i+eIiO+D2AV3/kU3awAcnVE487SYn+tUsPRYjXxQ033mZFAWqmYdzX1bT5fT0iRRcFuO2n0b0olUrlEKY3T+eMszIkE1NQPdZFuhJolk+mgaw70k1W5MPaVFXbXX19uUwqdoUZUvzvfeSQi0pGKNtr7DmAkg/r3PLw== gemmaleigh@gemma-mbp.local diff --git a/.travis/prototype-kit-deploy-key.enc b/.travis/prototype-kit-deploy-key.enc new file mode 100644 index 0000000000..c20c6f718f Binary files /dev/null and b/.travis/prototype-kit-deploy-key.enc differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0336547782..567f4eb471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,81 @@ +# 8.7.0 + +Features: +- [#613 Update to GOV.UK Frontend version 2.7.0 and adds experimental extensions feature](https://github.com/alphagov/govuk-prototype-kit/pull/613) (See GOV.UK Frontend 2.7.0 [release notes](https://github.com/alphagov/govuk-frontend/releases/tag/v2.7.0)). Big thanks @matcarey (https://github.com/matcarey) + As this is an **experimental** feature it should be used at your own risk, and is likely to change. Please contact us if you're interested in trying it out. + +- [#687 Update docs and package.json to Node 10 LTS](https://github.com/alphagov/govuk-prototype-kit/pull/687) + +- [#683 add guidance for CSS, JavaScript and images](https://github.com/alphagov/govuk-prototype-kit/pull/683) + +# 8.6.0 + +Features: +- [#680 Update to GOV.UK Frontend version 2.6.0](https://github.com/alphagov/govuk-prototype-kit/pull/680) (See GOV.UK Frontend 2.6.0 [release notes](https://github.com/alphagov/govuk-frontend/releases/tag/v2.6.0)) + +# 8.5.0 + +Features: +- [#672 Replace âcheck answersâ pattern with updated code](https://github.com/alphagov/govuk-prototype-kit/pull/672) +- [#671 Update to GOV.UK Frontend version 2.5.0](https://github.com/alphagov/govuk-prototype-kit/pull/671) + Allows use of new components Accordion and Summary List + +Fixes: + +- [#667 Add acorn dependency to fix npm warning](https://github.com/alphagov/govuk-prototype-kit/pull/667) +- [#647 Fix link context in step-by-step templates](https://github.com/alphagov/govuk-prototype-kit/pull/647) + +Internal: + +- [#663 update Standard to 12.0.1](https://github.com/alphagov/govuk-prototype-kit/pull/663) +- [#640 Replace Mocha with Jest](https://github.com/alphagov/govuk-prototype-kit/pull/640) +- [#659 Upgrade kit to use Gulp 4](https://github.com/alphagov/govuk-prototype-kit/pull/659) +- [#664 Remove deprecated gulp-util](https://github.com/alphagov/govuk-prototype-kit/pull/664) + +# 8.4.0 + +New features: + +- [#642 Update GOV.UK Frontend to v2.4.0](https://github.com/alphagov/govuk-prototype-kit/pull/642) + +Bug fixes: + +- [#634 Avoid double-nested buttons in step-by-step navigation](https://github.com/alphagov/govuk-prototype-kit/pull/634) + +- [#638 Make unbranded template available for use in app/views](https://github.com/alphagov/govuk-prototype-kit/pull/638) + +# 8.3.0 + +New features: + +- [#628 Update GOV.UK Frontend to v2.3.0](https://github.com/alphagov/govuk-prototype-kit/pull/628) + +- [#574 Add Notify integration guidance](https://github.com/alphagov/govuk-prototype-kit/pull/574) + +- [Add npm install reminder when prototype crashes](https://github.com/alphagov/govuk-prototype-kit/pull/598) + +- [#539 Add step by step navigation](https://github.com/alphagov/govuk-prototype-kit/pull/539) + +# 8.2.0 + +New Features: + +- [#609 Update GOV.UK Frontend to v2.2.0](https://github.com/alphagov/govuk-prototype-kit/pull/609) + +Also includes a new character-count component + +Bug fixes: + +- [#605 Set stylesheet media to "all" to allow print styles](https://github.com/alphagov/govuk-prototype-kit/pull/605) + +- [#608 Clearing session data now uses a POST request rather than a destructive GET request](https://github.com/alphagov/govuk-prototype-kit/pull/608) + +# 8.1.0 + +New features: + +- [#600 Update GOV.UK Frontend to v2.1.0](https://github.com/alphagov/govuk-prototype-kit/pull/600) + # 8.0.0 Breaking changes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab8b1ecdf2..1b6b1358c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,12 +3,13 @@ We really like contributions and bug reports, in fact the project wouldn't have got to this stage without them. We do have a few guidelines to bear in mind. -## Community +## Contributing -We have two Slack channels for the Prototype Kit. You'll need a government email address to join them. +If youâve got an idea or suggestion you can: -* [Slack channel for users of the Prototype Kit](https://ukgovernmentdigital.slack.com/messages/prototype-kit/) -* [Slack channel for developers of the Prototype Kit](https://ukgovernmentdigital.slack.com/messages/prototype-kit-dev/) +* email [govuk-design-system-support@digital.cabinet-office.gov.uk](mailto:govuk-design-system-support@digital.cabinet-office.gov.uk) +* [get in touch on developer Slack channel](https://ukgovernmentdigital.slack.com/messages/prototype-kit-dev)([open in app](slack://channel?team=T04V6EBTR&id=C0E1063DW)) +* [create a GitHub issue](https://github.com/alphagov/govuk-prototype-kit/issues) ## Raising bugs @@ -51,16 +52,43 @@ which describes how we prefer git history and commit messages to read. ## To release a new version -Checkout a new branch for the release. +1. Checkout master and pull latest changes. -Update [CHANGELOG.md](https://github.com/alphagov/govuk-prototype-kit/blob/master/CHANGELOG.md) to summarise the changes made since the last release. +2. Decide on a new version number. Do this by looking at the [current "Unreleased" CHANGELOG](../../CHANGELOG.md) changes and updating the previous release number depending on the kind of entries: -To see the commits to be summarised in the changelog since the last release, [compare the latest-release branch with master](https://github.com/alphagov/govuk-prototype-kit/compare/latest-release...master). +When you fix a bug and your code stays backwards compatible, increase the patch component: -Propose a new version number in [VERSION.txt](https://github.com/alphagov/govuk-prototype-kit/blob/master/VERSION.txt) and update line 4 in [package.json](https://github.com/alphagov/govuk-prototype-kit/blob/master/package.json#L4) with the new version number. +``` +v0.0.3 // Before bugfix +v0.0.4 // After bugfix +``` -Open a new pull request with a single commit including the above changes. +When you add functionality and your code stays backwards compatible, increase the minor component and reset the patch component to zero: -[Here is an example for v6.1.0](https://github.com/alphagov/govuk-prototype-kit/commit/53e36d79a994ce3649b53f4008370cd75068c27c). +``` +v0.2.4 // Before addition of new functionality +v0.3.0 // After addition of new functionality +``` -Once merged into master a new version will be built. +When you implement changes and your code becomes backwards incompatible, increase the major component and reset the minor and patch components to zero: + +``` +v7.3.5 // Before implementing backwards incompatible changes +v8.0.0 // After implementing backwards incompatible changes +``` + +(From [jvandemo.com](https://www.jvandemo.com/a-simple-guide-to-semantic-versioning/)) + +3. Checkout a new branch called release-[new version number]. + +4. Update the version number in [VERSION.txt](https://github.com/alphagov/govuk-prototype-kit/blob/master/VERSION.txt) and update "version" in [package.json](https://github.com/alphagov/govuk-prototype-kit/blob/master/package.json#L4). + +5. Commit your changes and open a new pull request on GitHub - copy the relevant Changelog section into the description. + +6. Once someone has merged the pull request, [draft a new release on GitHub](https://github.com/alphagov/govuk-prototype-kit/releases) + +7. In Tag version and Release title, put v[version number], for example `v7.0.0`. + +8. In description, paste the relevant section from the Changelog. + +9. Click 'Publish release'. diff --git a/Procfile b/Procfile index 1b9f7acc84..08938d90e6 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: node ./node_modules/gulp/bin/gulp generate-assets && node server.js +web: node ./node_modules/gulp/bin/gulp generate-assets && node listen-on-port.js diff --git a/README.md b/README.md index 27fee0d964..8c2b0b906b 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,17 @@ You must protect user privacy at all times, even when using prototypes. Prototyp - [Installation guide for new users (non technical)](https://govuk-prototype-kit.herokuapp.com/docs/install/introduction) - [Installation guide for developers (technical)](https://govuk-prototype-kit.herokuapp.com/docs/install/developer-install-instructions) -## Community +## Support -We have two Slack channels for the Prototype Kit. You'll need a government email address to join them. +The GOV.UK Prototype Kit is maintained by the Government Digital Service. If youâve got a question or need support you can: -* [Slack channel for users of the Prototype Kit](https://ukgovernmentdigital.slack.com/messages/prototype-kit/) -* [Slack channel for developers of the Prototype Kit](https://ukgovernmentdigital.slack.com/messages/prototype-kit-dev/) +* email [govuk-design-system-support@digital.cabinet-office.gov.uk](mailto:govuk-design-system-support@digital.cabinet-office.gov.uk) +* [get in touch on Slack](https://ukgovernmentdigital.slack.com/messages/prototype-kit)([open in app](slack://channel?team=T04V6EBTR&id=C0647LW4R)) +* [view known issues on GitHub](https://github.com/alphagov/govuk-prototype-kit/issues) + +## Contributing + +If youâve got an idea or suggestion you can: + +* [get in touch on the developer Slack channel](https://ukgovernmentdigital.slack.com/messages/prototype-kit-dev)([open in app](slack://channel?team=T04V6EBTR&id=C0E1063DW)) +* [create a GitHub issue](https://github.com/alphagov/govuk-prototype-kit/issues) diff --git a/VERSION.txt b/VERSION.txt index ae9a76b924..df5119ec64 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -8.0.0 +8.7.0 diff --git a/__tests__/spec/force-https-redirect.js b/__tests__/spec/force-https-redirect.js new file mode 100644 index 0000000000..7a2e2233df --- /dev/null +++ b/__tests__/spec/force-https-redirect.js @@ -0,0 +1,27 @@ +/* eslint-env jest */ +const request = require('supertest') +let app + +/* Setup Environment Variables before setting App */ +process.env.NODE_ENV = 'production' +process.env.USE_HTTPS = 'true' +app = require('../../server.js') + +describe('The Prototype Kit - force HTTPS redirect functionality', () => { + describe('should in a production environment', () => { + it('have HTTP header "location" field that begins with https', async () => { + const response = await request(app).get('/docs') + expect(response.header['location'].startsWith('https://')).toBeTruthy() + }) + + it('redirect to the same HTTPS url', async () => { + const response = await request(app).get('/docs') + expect(response.header['location'].endsWith('/docs')).toBeTruthy() + }) + + it('have HTTP header "status" field that indicates a redirect', async () => { + const response = await request(app).get('/docs') + expect(response.statusCode).toBe(302) + }) + }) +}) diff --git a/__tests__/spec/sanity-checks.js b/__tests__/spec/sanity-checks.js new file mode 100644 index 0000000000..7536039a97 --- /dev/null +++ b/__tests__/spec/sanity-checks.js @@ -0,0 +1,108 @@ +/* eslint-env jest */ +var request = require('supertest') +var app = require('../../server.js') +var path = require('path') +var fs = require('fs') +var assert = require('assert') + +function readFile (pathFromRoot) { + return fs.readFileSync(path.join(__dirname, '../../' + pathFromRoot), 'utf8') +} +/** + * Basic sanity checks on the dev server + */ +describe('The Prototype Kit', () => { + it('should generate assets into the /public folder', () => { + assert.doesNotThrow(function () { + fs.accessSync(path.resolve(__dirname, '../../public/javascripts/application.js')) + fs.accessSync(path.resolve(__dirname, '../../public/images/unbranded.ico')) + fs.accessSync(path.resolve(__dirname, '../../public/stylesheets/application.css')) + }) + }) + + describe('index page', () => { + it('should send a well formed response', async () => { + const response = await request(app).get('/') + expect(response.statusCode).toBe(200) + }) + + it('should return html file', async () => { + const response = await request(app).get('/') + expect(response.type).toBe('text/html') + }) + }) + + describe('docs index page', () => { + it('should send a well formed response', async () => { + const response = await request(app).get('/docs') + expect(response.statusCode).toBe(200) + }) + + it('should return html file', async () => { + const response = await request(app).get('/docs') + expect(response.type).toBe('text/html') + }) + }) + + describe('extensions', () => { + it('should allow known assets to be loaded from node_modules', (done) => { + request(app) + .get('/extension-assets/govuk-frontend/all.js') + .expect('Content-Type', /application\/javascript; charset=UTF-8/) + .expect(200) + .end(function (err, res) { + if (err) { + done(err) + } else { + assert.strictEqual('' + res.text, readFile('node_modules/govuk-frontend/all.js')) + done() + } + }) + }) + + it('should allow known assets to be loaded from node_modules', (done) => { + request(app) + .get('/assets/images/favicon.ico') + .expect('Content-Type', /image\/x-icon/) + .expect(200) + .end(function (err, res) { + if (err) { + done(err) + } else { + assert.strictEqual('' + res.body, readFile('node_modules/govuk-frontend/assets/images/favicon.ico')) + done() + } + }) + }) + + it('should not expose everything', function (done) { + request(app) + .get('/assets/common.js') + .expect(404) + .end(function (err, res) { + if (err) { + done(err) + } else { + done() + } + }) + }) + + describe('misconfigured prototype kit - while upgrading kit developer did not copy over changes in /app folder', () => { + it('should still allow known assets to be loaded from node_modules', (done) => { + request(app) + .get('/node_modules/govuk-frontend/all.js') + .expect('Content-Type', /application\/javascript; charset=UTF-8/) + .expect(200) + .end(function (err, res) { + if (err) { + done(err) + } else { + assert.strictEqual('' + res.text, readFile('node_modules/govuk-frontend/all.js')) + done() + } + }) + }) + }) + }) +}) diff --git a/app.json b/app.json new file mode 100644 index 0000000000..a85e811f33 --- /dev/null +++ b/app.json @@ -0,0 +1,21 @@ +{ + "name": "govuk-prototype-kit", + "scripts": {}, + "env": { + "USE_AUTH": { + "required": true + } + }, + "formation": { + "web": { + "quantity": 1 + } + }, + "addons": [], + "buildpacks": [ + { + "url": "heroku/nodejs" + } + ] + } + \ No newline at end of file diff --git a/app/assets/javascripts/step-by-step-navigation.js b/app/assets/javascripts/step-by-step-navigation.js new file mode 100644 index 0000000000..7408a04249 --- /dev/null +++ b/app/assets/javascripts/step-by-step-navigation.js @@ -0,0 +1,357 @@ +/* global $, GOVUK, history, sessionStorage */ + +// based on https://github.com/alphagov/govuk_publishing_components/blob/v9.3.6/app/assets/javascripts/govuk_publishing_components/components/step-by-step-nav.js + +window.GOVUK = window.GOVUK || {} +window.GOVUK.Modules = window.GOVUK.Modules || {} +window.GOVUK.support = window.GOVUK.support || {} + +window.GOVUK.support.history = function () { + return window.history && window.history.pushState && window.history.replaceState +} + +window.GOVUK.getCurrentLocation = function () { + return window.location +}; + +(function (Modules) { + 'use strict' + + Modules.StepByStepNavigation = function () { + var actions = {} // stores text for JS appended elements 'show' and 'hide' on steps, and 'show/hide all' button + var rememberShownStep = false + var sessionStoreLink = 'govuk-step-nav-active-link' + var activeLinkClass = 'app-step-nav__list-item--active' + var activeLinkHref = '#content' + + this.start = function ($element) { + $(window).unload(storeScrollPosition) + + // Indicate that js has worked + $element.addClass('app-step-nav--active') + + // Prevent FOUC, remove class hiding content + $element.removeClass('js-hidden') + + rememberShownStep = !!$element.filter('[data-remember]').length + var $steps = $element.find('.js-step') + var $stepHeaders = $element.find('.js-toggle-panel') + var totalSteps = $element.find('.js-panel').length + + var $showOrHideAllButton + + getTextForInsertedElements() + addButtonstoSteps() + addShowHideAllButton() + addShowHideToggle() + addAriaControlsAttrForShowHideAllButton() + + hideAllSteps() + showLinkedStep() + ensureOnlyOneActiveLink() + + bindToggleForSteps() + bindToggleShowHideAllButton() + bindComponentLinkClicks() + + function getTextForInsertedElements () { + actions.showText = $element.attr('data-show-text') + actions.hideText = $element.attr('data-hide-text') + actions.showAllText = $element.attr('data-show-all-text') + actions.hideAllText = $element.attr('data-hide-all-text') + } + + // When navigating back in browser history to the step nav, the browser will try to be "clever" and return + // the user to their previous scroll position. However, since we collapse all but the currently-anchored + // step, the content length changes and the user is returned to the wrong position (often the footer). + // In order to correct this behaviour, as the user leaves the page, we anticipate the correct height we wish the + // user to return to by forcibly scrolling them to that height, which becomes the height the browser will return + // them to. + // If we can't find an element to return them to, then reset the scroll to the top of the page. This handles + // the case where the user has expanded all steps, so they are not returned to a particular step, but + // still could have scrolled a long way down the page. + function storeScrollPosition () { + hideAllSteps() + var $step = getStepForAnchor() + + document.body.scrollTop = $step && $step.length + ? $step.offset().top + : 0 + } + + function addShowHideAllButton () { + $element.prepend('
') + } + + function addShowHideToggle () { + $stepHeaders.each(function () { + var linkText = actions.showText + + if (headerIsOpen($(this))) { + linkText = actions.hideText + } + if (!$(this).find('.js-toggle-link').length) { + $(this).find('.js-step-title-button').append('') + } + }) + } + + function headerIsOpen ($stepHeader) { + return (typeof $stepHeader.closest('.js-step').data('show') !== 'undefined') + } + + function addAriaControlsAttrForShowHideAllButton () { + var ariaControlsValue = $element.find('.js-panel').first().attr('id') + + $showOrHideAllButton = $element.find('.js-step-controls-button') + $showOrHideAllButton.attr('aria-controls', ariaControlsValue) + } + + function hideAllSteps () { + setAllStepsShownState(false) + } + + function setAllStepsShownState (isShown) { + $.each($steps, function () { + var stepView = new StepView($(this)) + stepView.preventHashUpdate() + stepView.setIsShown(isShown) + }) + } + + function showLinkedStep () { + var $step + if (rememberShownStep) { + $step = getStepForAnchor() + } else { + $step = $steps.filter('[data-show]') + } + + if ($step && $step.length) { + var stepView = new StepView($step) + stepView.show() + } + } + + function getStepForAnchor () { + var anchor = getActiveAnchor() + + return anchor.length + ? $element.find('#' + escapeSelector(anchor.substr(1))) + : null + } + + function getActiveAnchor () { + return GOVUK.getCurrentLocation().hash + } + + function addButtonstoSteps () { + $.each($steps, function () { + var $step = $(this) + var $title = $step.find('.js-step-title') + var contentId = $step.find('.js-panel').first().attr('id') + + $title.wrapInner( + '' + ) + + $title.wrapInner( + '' + ) + }) + } + + function bindToggleForSteps () { + $element.find('.js-toggle-panel').click(function (event) { + var $step = $(this).closest('.js-step') + + var stepView = new StepView($step) + stepView.toggle() + + setShowHideAllText() + }) + } + + // tracking click events on links in step content + function bindComponentLinkClicks () { + $element.find('.js-link').click(function (event) { + var thisLinkHref = $(this).attr('href') + + if ($(this).attr('rel') !== 'external') { + saveToSessionStorage(sessionStoreLink, $(this).data('position')) + } + + if (thisLinkHref === activeLinkHref) { + setOnlyThisLinkActive($(this)) + } + }) + } + + function saveToSessionStorage (key, value) { + sessionStorage.setItem(key, value) + } + + function loadFromSessionStorage (key) { + return sessionStorage.getItem(key) + } + + function removeFromSessionStorage (key) { + sessionStorage.removeItem(key) + } + + function setOnlyThisLinkActive (clicked) { + $element.find('.' + activeLinkClass).removeClass(activeLinkClass) + clicked.parent().addClass(activeLinkClass) + } + + function ensureOnlyOneActiveLink () { + var $activeLinks = $element.find('.js-list-item.' + activeLinkClass) + + if ($activeLinks.length <= 1) { + return + } + + var lastClicked = loadFromSessionStorage(sessionStoreLink) + + if (lastClicked) { + removeActiveStateFromAllButCurrent($activeLinks, lastClicked) + removeFromSessionStorage(sessionStoreLink) + } else { + var activeLinkInActiveStep = $element.find('.app-step-nav__step--active').find('.' + activeLinkClass).first() + + if (activeLinkInActiveStep.length) { + $activeLinks.removeClass(activeLinkClass) + activeLinkInActiveStep.addClass(activeLinkClass) + } else { + $activeLinks.slice(1).removeClass(activeLinkClass) + } + } + } + + function removeActiveStateFromAllButCurrent ($links, current) { + $links.each(function () { + if ($(this).find('.js-link').data('position').toString() !== current.toString()) { + $(this).removeClass(activeLinkClass) + } + }) + } + + function bindToggleShowHideAllButton () { + $showOrHideAllButton = $element.find('.js-step-controls-button') + $showOrHideAllButton.on('click', function () { + var shouldshowAll + + if ($showOrHideAllButton.text() === actions.showAllText) { + $showOrHideAllButton.text(actions.hideAllText) + $element.find('.js-toggle-link').text(actions.hideText) + shouldshowAll = true + } else { + $showOrHideAllButton.text(actions.showAllText) + $element.find('.js-toggle-link').text(actions.showText) + shouldshowAll = false + } + + setAllStepsShownState(shouldshowAll) + $showOrHideAllButton.attr('aria-expanded', shouldshowAll) + setShowHideAllText() + setHash(null) + + return false + }) + } + + function setShowHideAllText () { + var shownSteps = $element.find('.step-is-shown').length + // Find out if the number of is-opens == total number of steps + if (shownSteps === totalSteps) { + $showOrHideAllButton.text(actions.hideAllText) + } else { + $showOrHideAllButton.text(actions.showAllText) + } + } + + // Ideally we'd use jQuery.escapeSelector, but this is only available from v3 + // See https://github.com/jquery/jquery/blob/2d4f53416e5f74fa98e0c1d66b6f3c285a12f0ce/src/selector-native.js#L46 + function escapeSelector (s) { + var cssMatcher = /([\x00-\x1f\x7f]|^-?\d)|^-$|[^\x80-\uFFFF\w-]/g // eslint-disable-line no-control-regex + return s.replace(cssMatcher, '\\$&') + } + } + + function StepView ($stepElement) { + var $titleLink = $stepElement.find('.js-step-title-button') + var $stepContent = $stepElement.find('.js-panel') + var shouldUpdateHash = rememberShownStep + + this.title = $stepElement.find('.js-step-title-text').text().trim() + this.element = $stepElement + + this.show = show + this.hide = hide + this.toggle = toggle + this.setIsShown = setIsShown + this.isShown = isShown + this.isHidden = isHidden + this.preventHashUpdate = preventHashUpdate + this.numberOfContentItems = numberOfContentItems + + function show () { + setIsShown(true) + } + + function hide () { + setIsShown(false) + } + + function toggle () { + setIsShown(isHidden()) + } + + function setIsShown (isShown) { + $stepElement.toggleClass('step-is-shown', isShown) + $stepContent.toggleClass('js-hidden', !isShown) + $titleLink.attr('aria-expanded', isShown) + $stepElement.find('.js-toggle-link').text(isShown ? actions.hideText : actions.showText) + + if (shouldUpdateHash) { + updateHash($stepElement) + } + } + + function isShown () { + return $stepElement.hasClass('step-is-shown') + } + + function isHidden () { + return !isShown() + } + + function preventHashUpdate () { + shouldUpdateHash = false + } + + function numberOfContentItems () { + return $stepContent.find('.js-link').length + } + } + + function updateHash ($stepElement) { + var stepView = new StepView($stepElement) + var hash = stepView.isShown() && '#' + $stepElement.attr('id') + setHash(hash) + } + + // Sets the hash for the page. If a falsy value is provided, the hash is cleared. + function setHash (hash) { + if (!GOVUK.support.history()) { + return + } + + var newLocation = hash || GOVUK.getCurrentLocation().pathname + history.replaceState({}, '', newLocation) + } + } +})(window.GOVUK.Modules) diff --git a/app/assets/sass/application.scss b/app/assets/sass/application.scss index d54f828b6d..242336389c 100644 --- a/app/assets/sass/application.scss +++ b/app/assets/sass/application.scss @@ -1,11 +1,11 @@ // global styles for and tags
$govuk-global-styles: true;
-// Import GOV.UK Frontend
-@import "node_modules/govuk-frontend/all";
+// Import GOV.UK Frontend and any extension styles if extensions have been configured
+@import "lib/extensions/extensions";
// Patterns that aren't in Frontend
-@import "patterns/check-your-answers";
+@import "patterns/step-by-step-navigation";
@import "patterns/task-list";
@import "patterns/related-items";
diff --git a/app/assets/sass/patterns/_check-your-answers.scss b/app/assets/sass/patterns/_check-your-answers.scss
deleted file mode 100644
index e3aef13fc9..0000000000
--- a/app/assets/sass/patterns/_check-your-answers.scss
+++ /dev/null
@@ -1,83 +0,0 @@
-// Recommended - Use these styles for the check your answers pattern
-.app-check-your-answers {
- @include govuk-font(19);
- margin-top: 0;
- @include govuk-responsive-margin(9, "bottom");
- @include govuk-media-query($from: desktop) {
- display: table;
- }
-}
-
-.app-check-your-answers--short {
- @include govuk-media-query($from: desktop) {
- // to make group of q&a line up horizontally (unless there is just one group)
- width: 100%;
- // recommended for mostly short questions
- .app-check-your-answers__question {
- width: 30%;
- }
- }
-}
-
-.app-check-your-answers--long {
- @include govuk-media-query($from: desktop) {
- // to make group of q&a line up horizontally (unless there is just one group)
- width: 100%;
- // recommended for mostly long questions
- .app-check-your-answers__question {
- width: 50%;
- }
- }
-}
-
-.app-check-your-answers__contents {
- position: relative;
- border-bottom: 1px solid $govuk-border-colour;
-
- @include govuk-media-query($from: desktop) {
- display: table-row;
- border-bottom-width: 0;
- }
-}
-
-.app-check-your-answers__contents:first-child .app-check-your-answers__question,
-.app-check-your-answers__contents:first-child .app-check-your-answers__answer,
-.app-check-your-answers__contents:first-child .app-check-your-answers__change {
- @include govuk-media-query($from: desktop) {
- padding-top: 0;
- }
-}
-
-.app-check-your-answers__question,
-.app-check-your-answers__answer,
-.app-check-your-answers__change {
- display: block;
- margin: 0;
- @include govuk-media-query($from: desktop) {
- display: table-cell;
- border-bottom: 1px solid $govuk-border-colour;
- padding: govuk-em(12, 19) govuk-em(20, 19) govuk-em(9, 19) 0;
- }
-}
-
-.app-check-your-answers__question {
- font-weight: bold;
- margin: govuk-em(12, 19) 4em govuk-em(4,19) 0;
- // using margin instead of padding because of easier absolutely positioning of .app-check-your-answers__change
-}
-
-.app-check-your-answers__answer {
- padding-bottom: govuk-em(9, 19);
-}
-
-.app-check-your-answers__change {
- text-align: right;
- position: absolute;
- top: 0;
- right: 0;
-
- @include govuk-media-query($from: desktop) {
- position: static;
- padding-right: 0;
- }
-}
diff --git a/app/assets/sass/patterns/_step-by-step-navigation.scss b/app/assets/sass/patterns/_step-by-step-navigation.scss
new file mode 100644
index 0000000000..e167ee82d3
--- /dev/null
+++ b/app/assets/sass/patterns/_step-by-step-navigation.scss
@@ -0,0 +1,568 @@
+// Based on https://github.com/alphagov/govuk_publishing_components/blob/v9.3.6/app/assets/stylesheets/govuk_publishing_components/components/_step-by-step-nav.scss
+// Note - this code for prototype purposes only. It is not production code,
+// nor an example of the best way to use GOV.UK Frontend
+
+.app-step-nav-header {
+ position: relative;
+ padding: 10px;
+ background: govuk-colour("grey-4");
+ border-top: solid 1px $govuk-border-colour;
+ border-bottom: solid 1px $govuk-border-colour;
+ @include govuk-media-query($from: tablet) {
+ padding: 15px;
+ }
+}
+
+.app-step-nav-header__part-of {
+ @include govuk-font(16, $weight: bold)
+ display: block;
+ padding-bottom: 0.2em;
+}
+
+.app-step-nav-header__title {
+ @include govuk-font(24, $weight: bold)
+}
+
+.app-step-nav-related {
+ border-top: 2px solid $govuk-link-colour;
+ margin-bottom: 30px;
+}
+
+.app-step-nav-related__heading {
+ @include govuk-font(19, $weight: bold)
+ margin-top: 15px;
+ margin-bottom: 10px;
+}
+
+.app-step-nav-related__links {
+ @include govuk-font(16, $weight: bold)
+ margin: 0;
+ padding: 0;
+}
+
+.app-step-nav-related--singular {
+ margin-bottom: 13px;
+
+ .app-step-nav-related__heading {
+ @include govuk-font(19, $weight: bold)
+ margin-top: 20px;
+ }
+
+ .app-step-nav-related__pretitle {
+ margin-bottom: 5px;
+ }
+}
+
+.app-step-nav-related__pretitle {
+ display: block;
+ margin-bottom: 15px;
+ @include govuk-media-query($from: tablet) {
+ margin-bottom: 5px;
+ }
+}
+
+.app-step-nav-related__links {
+ list-style: none;
+}
+
+.app-step-nav-related__link-item {
+ margin-top: 15px;
+ @include govuk-media-query($from: tablet) {
+ margin-top: 5px;
+ }
+}
+
+.app-step-nav {
+ @include govuk-font(19);
+ margin-bottom: 30px;
+ &.app-step-nav--large {
+ @include govuk-media-query($from: tablet) {
+ margin-bottom: 60px;
+ }
+ }
+}
+
+.app-step-nav__controls {
+ padding: 3px 3px 0 0;
+ text-align: right;
+}
+
+.app-step-nav__button {
+ color: $govuk-link-colour;
+ cursor: pointer;
+ background: none;
+ border: 0;
+ margin: 0;
+}
+
+// removes extra dotted outline from buttons in Firefox
+// on focus (standard yellow outline unaffected)
+.app-step-nav__button::-moz-focus-inner {
+ border: 0;
+}
+
+.app-step-nav__button--title {
+ @include govuk-font(19, $weight: bold)
+ display: inline-block;
+ padding: 0;
+ text-align: left;
+ color: govuk-colour("black");
+
+ .app-step-nav--large & {
+ @include govuk-font(24, $weight: bold)
+ }
+
+}
+
+.app-step-nav__button--controls {
+ @include govuk-font(14)
+ position: relative;
+ z-index: 1; // this and relative position stops focus outline underlap with border of accordion
+ padding: 0.5em 0;
+ text-decoration: underline;
+
+ &:hover {
+ color: $govuk-link-hover-colour;
+ }
+
+ .app-step-nav--large & {
+ @include govuk-font(16)
+ }
+}
+
+.app-step-nav__steps {
+ padding: 0;
+ margin: 0;
+}
+
+.app-step-nav__step {
+ position: relative;
+ padding-left: 45px;
+ list-style: none;
+}
+
+.app-step-nav__step:after {
+ content: "";
+ position: absolute;
+ z-index: 2;
+ width: 0;
+ height: 100%;
+ border-left: solid 2px $govuk-border-colour;
+ background: #fff;
+ left: 0;
+ margin-left: 12px;
+ top: 15px;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__step {
+ padding-left: 60px;
+ }
+
+ .app-step-nav--large .app-step-nav__step:after {
+ left: 0;
+ margin-left: 16px;
+ border-width: 3px;
+ top: 30px;
+ }
+}
+
+.app-step-nav__step:last-child:before {
+ content: "";
+ position: absolute;
+ z-index: 6;
+ bottom: 0;
+ left: 0;
+ margin-left: 6.5px;
+ width: 13px;
+ height: 0;
+ border-bottom: solid 2px $govuk-border-colour;
+}
+
+.app-step-nav__step:last-child:after {
+ height: -webkit-calc(100% - 15px);
+ height: calc(100% - 15px);
+}
+
+.app-step-nav__step:last-child .app-step-nav__help:after {
+ height: 100%;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__step:last-child:before {
+ margin-left: 8.75px;
+ width: 17.5px;
+ border-width: 3px;
+ }
+
+ .app-step-nav--large .app-step-nav__step:last-child:after {
+ height: calc(100% - 30px);
+ }
+}
+
+.app-step-nav__step--active .app-step-nav__circle--number,
+.app-step-nav__step--active .app-step-nav__help:after,
+.app-step-nav__step--active .app-step-nav__substep:after,
+.app-step-nav__step--active:after,
+.app-step-nav__step--active:last-child:before {
+ border-color: govuk-colour("black");
+}
+
+.app-step-nav__circle {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ position: absolute;
+ z-index: 5;
+ top: 15px;
+ left: 0;
+ width: 26px;
+ height: 26px;
+ color: govuk-colour("black");
+ background: #fff;
+ border-radius: 100px;
+ text-align: center;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__circle {
+ top: 30px;
+ width: 35px;
+ height: 35px;
+ }
+}
+
+.app-step-nav__circle--number {
+ @include govuk-font(16, $weight: bold)
+ line-height: 23px;
+ border: solid 2px $govuk-border-colour;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav__circle--number {
+ font-size: 16px;
+ line-height: 23px;
+ }
+}
+
+.app-step-nav--large .app-step-nav__circle--number {
+ @include govuk-font(16, $weight: bold)
+ line-height: 23px;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__circle--number {
+ font-size: 19px;
+ line-height: 30px;
+ }
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__circle--number {
+ border-width: 3px;
+ }
+}
+
+.app-step-nav__circle--logic {
+ @include govuk-font(16, $weight: bold)
+ line-height: 28px;
+}
+@include govuk-media-query($from: tablet) {
+ .app-step-nav__circle--logic {
+ font-size: 16px;
+ line-height: 28px;
+ }
+}
+
+.app-step-nav--large .app-step-nav__circle--logic {
+ @include govuk-font(16, $weight: bold)
+ line-height: 28px;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__circle--logic {
+ font-size: 19px;
+ line-height: 34px;
+ }
+}
+
+.app-step-nav__circle-inner {
+ float: right;
+ min-width: 100%;
+}
+
+.app-step-nav__circle-background {
+ text-shadow: 0 -0.1em 0 #fff, 0.1em 0 0 #fff, 0 0.1em 0 #fff, -0.1em 0 0 #fff;
+}
+
+.app-step-nav__header {
+ padding: 15px 0;
+ border-top: solid 2px govuk-colour("grey-3");
+}
+
+.app-step-nav--active .app-step-nav__header {
+ cursor: pointer;
+}
+
+.app-step-nav__header:hover .app-step-nav__button,
+.app-step-nav__header:hover .app-step-nav__circle {
+ color: $govuk-link-colour;
+}
+
+.app-step-nav__header:hover .app-step-nav__toggle-link {
+ text-decoration: underline;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__header {
+ padding: 30px 0;
+ }
+}
+
+.app-step-nav__title {
+ @include govuk-font(19, $weight: bold)
+ line-height: 1.4;
+ margin: 0;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav__title {
+ font-size: 19px;
+ line-height: 1.4;
+ }
+}
+
+.app-step-nav--large .app-step-nav__title {
+ @include govuk-font(19, $weight: bold)
+ line-height: 1.4;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__title {
+ font-size: 24px;
+ line-height: 1.4;
+ }
+}
+
+.app-step-nav__toggle-link {
+ @include govuk-font(16)
+ line-height: 1.2;
+ display: block;
+ color: $govuk-link-colour;
+}
+
+.app-step-nav--large .app-step-nav__toggle-link {
+ @include govuk-font(16)
+ line-height: 1.2;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__toggle-link {
+ font-size: 16px;
+ line-height: 1.2;
+ }
+}
+
+.app-step-nav__panel {
+ @include govuk-font(16)
+ line-height: 1.25;
+ .js-enabled &.js-hidden {
+ display: none;
+ }
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav__panel {
+ font-size: 16px;
+ }
+}
+
+.app-step-nav--large .app-step-nav__panel {
+ @include govuk-font(19)
+}
+
+.app-step-nav__heading,
+.app-step-nav__paragraph {
+ padding-bottom: 15px;
+ margin: 0;
+ font-size: inherit;
+}
+
+.app-step-nav__heading + .app-step-nav__list,
+.app-step-nav__paragraph + .app-step-nav__list {
+ margin-top: -5px;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__heading + .app-step-nav__list,
+ .app-step-nav--large .app-step-nav__paragraph + .app-step-nav__list {
+ margin-top: -15px;
+ }
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__heading,
+ .app-step-nav--large .app-step-nav__paragraph {
+ padding-bottom: 30px;
+ }
+}
+
+.app-step-nav__heading {
+ font-weight: bold;
+}
+
+.app-step-nav__list {
+ padding: 0 0 10px;
+}
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__list {
+ padding-bottom: 20px;
+ }
+}
+
+.app-step-nav__list--choice {
+ margin-left: 20px;
+ list-style: disc;
+}
+
+.app-step-nav__list--choice .app-step-nav__list-item--active:before {
+ left: -45px;
+}
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__list--choice .app-step-nav__list-item--active:before {
+ left: -80px;
+ }
+}
+
+.app-step-nav__list-item {
+ list-style: none;
+ margin-bottom: 10px;
+}
+
+.app-step-nav__list-item--active {
+ position: relative;
+}
+
+.app-step-nav__list-item--active:before {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ content: "";
+ position: absolute;
+ z-index: 5;
+ top: 0.6em;
+ left: -45px;
+ margin-left: 5px;
+ width: 16px;
+ height: 16px;
+ margin-top: -8px;
+ background: govuk-colour("black");
+ border: solid 2px #fff;
+ border-radius: 100px;
+}
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__list-item--active:before {
+ left: -60px;
+ margin-left: 9.5px;
+ }
+}
+
+.app-step-nav__list-item--active .app-step-nav__link {
+ color: govuk-colour("black");
+ text-decoration: none;
+}
+
+.app-step-nav__list-item--active .app-step-nav__link:active,
+.app-step-nav__list-item--active .app-step-nav__link:link,
+.app-step-nav__list-item--active .app-step-nav__link:visited {
+ color: govuk-colour("black");
+}
+
+.app-step-nav__context {
+ display: inline-block;
+ font-weight: normal;
+ color: $govuk-secondary-text-colour;
+}
+
+.app-step-nav__context:before {
+ content: " \2013\00a0";
+}
+
+.app-step-nav__help {
+ position: relative;
+ padding: 15px 0;
+ border-top: solid 2px govuk-colour("grey-3");
+}
+
+.app-step-nav__help:after {
+ content: "";
+ position: absolute;
+ z-index: 2;
+ width: 0;
+ height: 100%;
+ border-left: dotted 2px $govuk-border-colour;
+ background: #fff;
+ left: 0;
+ margin-left: 12px;
+ z-index: 3;
+ top: 0;
+ left: -45px;
+ height: calc(100% + 30px);
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__help:after {
+ left: 0;
+ margin-left: 16px;
+ border-width: 3px;
+ left: -60px;
+ height: calc(100% + 30px + 15px);
+ }
+}
+
+.app-step-nav__help-link {
+ text-decoration: none;
+ font-weight: bold;
+}
+
+.app-step-nav__help-link:hover {
+ text-decoration: underline;
+}
+
+.app-step-nav__substep {
+ position: relative;
+ padding-top: 15px;
+ border-top: solid 2px govuk-colour("grey-3");
+}
+
+.app-step-nav__substep:after {
+ content: "";
+ position: absolute;
+ z-index: 2;
+ width: 0;
+ height: 100%;
+ border-left: solid 2px $govuk-border-colour;
+ background: #fff;
+ left: 0;
+ margin-left: 12px;
+ z-index: 3;
+ top: 0;
+ left: -45px;
+}
+
+@include govuk-media-query($from: tablet) {
+ .app-step-nav--large .app-step-nav__substep {
+ padding-top: 30px;
+ }
+
+ .app-step-nav--large .app-step-nav__substep:after {
+ left: 0;
+ margin-left: 16px;
+ border-width: 3px;
+ left: -60px;
+ }
+}
+
+.app-step-nav__substep--optional:after {
+ border-left-style: dotted;
+}
diff --git a/app/assets/sass/unbranded.scss b/app/assets/sass/unbranded.scss
index 2f46b65d9f..a64e40d5ee 100644
--- a/app/assets/sass/unbranded.scss
+++ b/app/assets/sass/unbranded.scss
@@ -1,4 +1,5 @@
@import "node_modules/govuk-frontend/settings/all";
+$govuk-global-styles: true;
// Override the govuk-frontend font stack
$govuk-font-family: "HelveticaNeue", "Helvetica Neue", "Arial", "Helvetica", sans-serif;
diff --git a/app/views/includes/head.html b/app/views/includes/head.html
index 88e56ebeee..05fcc71f56 100644
--- a/app/views/includes/head.html
+++ b/app/views/includes/head.html
@@ -1,2 +1,6 @@
-
+
+
+{% for stylesheetUrl in extensionConfig.stylesheets %}
+
+{% endfor %}
diff --git a/app/views/includes/scripts.html b/app/views/includes/scripts.html
index aa86cc5ab9..46aff854ff 100644
--- a/app/views/includes/scripts.html
+++ b/app/views/includes/scripts.html
@@ -1,6 +1,10 @@
-
+
+{% for scriptUrl in extensionConfig.scripts %}
+
+{% endfor %}
+
{% if useAutoStoreData %}
diff --git a/app/views/layout.html b/app/views/layout.html
index a4884221cd..e93b0df763 100644
--- a/app/views/layout.html
+++ b/app/views/layout.html
@@ -1,27 +1,30 @@
{% extends "template.njk" %}
-{% from "back-link/macro.njk" import govukBackLink %}
-{% from "breadcrumbs/macro.njk" import govukBreadcrumbs %}
-{% from "button/macro.njk" import govukButton %}
-{% from "checkboxes/macro.njk" import govukCheckboxes %}
-{% from "date-input/macro.njk" import govukDateInput %}
-{% from "details/macro.njk" import govukDetails %}
-{% from "error-message/macro.njk" import govukErrorMessage %}
-{% from "error-summary/macro.njk" import govukErrorSummary %}
-{% from "fieldset/macro.njk" import govukFieldset %}
-{% from "file-upload/macro.njk" import govukFileUpload %}
-{% from "input/macro.njk" import govukInput %}
-{% from "inset-text/macro.njk" import govukInsetText %}
-{% from "panel/macro.njk" import govukPanel %}
-{% from "phase-banner/macro.njk" import govukPhaseBanner %}
-{% from "radios/macro.njk" import govukRadios %}
-{% from "select/macro.njk" import govukSelect %}
-{% from "skip-link/macro.njk" import govukSkipLink %}
-{% from "table/macro.njk" import govukTable %}
-{% from "tabs/macro.njk" import govukTabs %}
-{% from "tag/macro.njk" import govukTag %}
-{% from "textarea/macro.njk" import govukTextarea %}
-{% from "warning-text/macro.njk" import govukWarningText %}
+{% from "accordion/macro.njk" import govukAccordion %}
+{% from "back-link/macro.njk" import govukBackLink %}
+{% from "breadcrumbs/macro.njk" import govukBreadcrumbs %}
+{% from "button/macro.njk" import govukButton %}
+{% from "character-count/macro.njk" import govukCharacterCount %}
+{% from "checkboxes/macro.njk" import govukCheckboxes %}
+{% from "date-input/macro.njk" import govukDateInput %}
+{% from "details/macro.njk" import govukDetails %}
+{% from "error-message/macro.njk" import govukErrorMessage %}
+{% from "error-summary/macro.njk" import govukErrorSummary %}
+{% from "fieldset/macro.njk" import govukFieldset %}
+{% from "file-upload/macro.njk" import govukFileUpload %}
+{% from "input/macro.njk" import govukInput %}
+{% from "inset-text/macro.njk" import govukInsetText %}
+{% from "panel/macro.njk" import govukPanel %}
+{% from "phase-banner/macro.njk" import govukPhaseBanner %}
+{% from "radios/macro.njk" import govukRadios %}
+{% from "select/macro.njk" import govukSelect %}
+{% from "skip-link/macro.njk" import govukSkipLink %}
+{% from "summary-list/macro.njk" import govukSummaryList %}
+{% from "table/macro.njk" import govukTable %}
+{% from "tabs/macro.njk" import govukTabs %}
+{% from "tag/macro.njk" import govukTag %}
+{% from "textarea/macro.njk" import govukTextarea %}
+{% from "warning-text/macro.njk" import govukWarningText %}
{% block head %}
{% include "includes/head.html" %}
@@ -52,7 +55,7 @@
text: "GOV.UK Prototype Kit " + releaseVersion
},
{
- href: "/prototype-admin/clears-data",
+ href: "/prototype-admin/clear-data",
text: "Clear data"
}
]
diff --git a/app/views/layout_unbranded.html b/app/views/layout_unbranded.html
new file mode 100644
index 0000000000..77387dc65f
--- /dev/null
+++ b/app/views/layout_unbranded.html
@@ -0,0 +1,21 @@
+{% extends "template.njk" %}
+
+{% block headIcons %}
+
+
+
+
+
+
+{% endblock %}
+
+{% block head %}
+
+{% endblock %}
+
+{% block header %}{% endblock %}
+{% block footer %}{% endblock %}
+
+{% block bodyEnd %}
+ {% include "includes/scripts.html" %}
+{% endblock %}
diff --git a/create-release.sh b/create-release.sh
index 65ade28839..4d65f0db88 100755
--- a/create-release.sh
+++ b/create-release.sh
@@ -1,41 +1,42 @@
#!/bin/bash
set -e
-REPO_PATH='alphagov/govuk-prototype-kit'
-
-echo "Add config for alphagov/$REPO_PATH"
+# Check for the TRAVIS environment variable
+if [[ -z "${TRAVIS}" ]]; then
+ echo "âď¸ Refusing to run outside of Travis..."
+ exit 1
+fi
+# Configure git...
git config --global user.name "Travis CI"
git config --global user.email "travis@travis-ci.org"
-git remote add origin_ssh git@github.com:$REPO_PATH.git
+git remote add origin_ssh git@github.com:alphagov/govuk-prototype-kit.git
-# This openssl command was generated automatically by `travis encrypt-file`, see `.travis/README.md` for more details
-openssl aes-256-cbc -K $encrypted_a0ab9bc5246b_key -iv $encrypted_a0ab9bc5246b_iv -in .travis/govuk_prototype_kit.enc -out ~/.ssh/id_rsa -d
-chmod 600 ~/.ssh/id_rsa
+# Decrypt deploy key.
+#
+# See `.travis/README.md` for more details
+openssl aes-256-cbc -d -k $DEPLOY_KEY \
+ -in .travis/prototype-kit-deploy-key.enc \
+ -out ~/.ssh/id_rsa
-echo "Check to see if the version file has been updated"
+chmod 600 ~/.ssh/id_rsa
# Get the version from the version file
VERSION_TAG="v`cat VERSION.txt`"
-# Create a new tag - if the version file has been updated and a tag for that
-# version doesn't already exist
-
-# Check to make sure the tag doesn't already exist
+# Check that there's not a tag for the current version already
if ! git rev-parse $VERSION_TAG >/dev/null 2>&1; then
- echo "Creating new tag: $VERSION_TAG"
-
- # Create a new tag and push to Github
+ # Create a new tag and push to GitHub.
+ #
+ # GitHub will automatically create a release for the tag, ignoring any files
+ # specified in the .gitattributes file
+ echo "đˇ Creating new tag: $VERSION_TAG"
git tag $VERSION_TAG
git push origin_ssh $VERSION_TAG
- # This tag will trigger the builds for the deploy providers marked "# For tagged commits" in .travis.yml
-
- # Alias branch for the most recently released tag, for easier diffing
- # Force push local `master` branch to the `latest-release` branch on Github
+ # Force push the latest-release branch to this commit
+ echo "đ¨ Pushing latest-release branch to GitHub"
git push --force origin_ssh master:latest-release
- echo "Pushed latest-release branch to GitHub"
-
else
- echo "Not creating a new tag, or updating the latest-release branch as the tag already exists..."
+ echo "đ´ Current version already exists as a tag on GitHub. Nothing to do."
fi
diff --git a/docs/documentation/adding-css-javascript-and-images.md b/docs/documentation/adding-css-javascript-and-images.md
new file mode 100644
index 0000000000..394b40c418
--- /dev/null
+++ b/docs/documentation/adding-css-javascript-and-images.md
@@ -0,0 +1,84 @@
+# Adding CSS, JavaScript and Images
+
+The Prototype Kit comes with standard GOV.UK Frontend styles and components for you to use in your prototypes. However if you need to add your own CSS (Cascading Style Sheets), JavaScript or images, use the `/app/assets` folder.
+
+The Prototype Kit processes all the files in the `/app/assets` folder, and puts the processed files in `/public`.
+
+Do not change files in the `/public` folder because itâs deleted and rebuilt every time you make a change to your prototype.
+
+## CSS
+
+CSS lets you change how web pages look, for example text sizes, colours or spacing.
+
+To add styles use:
+
+```
+/app/assets/sass/application.scss
+```
+
+Do not edit the file `/public/styles/application.css` because itâs deleted and rebuilt every time you make a change to your prototype.
+
+The Prototype Kit uses [Sass](https://sass-lang.com/guide), which adds extra features to CSS.
+
+### Using import
+
+If you have a very long application.scss file, you can split it up into multiple files and import those into `application.scss`. Use an underscore (_) at the start of the import file filenames, for example:
+
+```
+/app/assets/sass/_admin.scss
+```
+
+Import this file into your `application.scss` file without the underscore:
+
+```
+@import "admin";
+```
+
+## JavaScript
+
+You can use JavaScript to make changes to a web page without loading a new one. For example a user could enter some numbers, then JavaScript displays the results of a calculation without loading a new page.
+
+To add JavaScript use:
+
+```
+/app/assets/javascripts/application.js
+```
+
+Do not edit the file `/public/javascript/application.js` because itâs deleted and rebuilt every time you make a change to your prototype.
+
+## Images
+
+If you add images to `/app/assets/images` the Prototype Kit will copy them to `/public`.
+
+For example if you add an image:
+
+```
+/app/assets/images/user.png
+```
+
+Use it in your page like this:
+
+```
+
+```
+
+Use âaltâ text to describe the image for screen readers.
+
+Do not put files directly in `/public` because itâs deleted and rebuilt every time you make a change to your prototype.
+
+## Other files
+
+If you need to use other files in your prototype, you can add them to `/app/assets` and the Prototype Kit will copy them to `/public`. You can use sub-folders in the assets folder.
+
+For example if you add a PDF:
+
+```
+/app/assets/downloads/report.pdf
+```
+
+Link to it like this:
+
+```
+Download the report
+```
+Do not put files directly in `/public` because itâs deleted and rebuilt every time you make a change to your prototype.
diff --git a/docs/documentation/extension-system.md b/docs/documentation/extension-system.md
new file mode 100644
index 0000000000..c979d88217
--- /dev/null
+++ b/docs/documentation/extension-system.md
@@ -0,0 +1,3 @@
+# Extension system
+
+The extension system information should go here before it's adopted into GOVUK.
diff --git a/docs/documentation/install/developer-install-instructions.md b/docs/documentation/install/developer-install-instructions.md
index 1dabb88225..816cca7d31 100644
--- a/docs/documentation/install/developer-install-instructions.md
+++ b/docs/documentation/install/developer-install-instructions.md
@@ -4,7 +4,7 @@ It's built on the [Express](http://expressjs.com/) framework, and uses [GOV.UK F
## Requirements
-node.js - version 8.x.x
+node.js - version 10.x.x
## Install dependencies
diff --git a/docs/documentation/install/requirements.md b/docs/documentation/install/requirements.md
index 3e3c539ccc..0a57250411 100644
--- a/docs/documentation/install/requirements.md
+++ b/docs/documentation/install/requirements.md
@@ -14,7 +14,7 @@ GDS staff can install the software themselves with Managed Software Centre (MSC)
You'll need:
-* Node.js 8.x.x
+* Node.js 10.x.x
* Atom (text editor)
* Command line tools (Mac)
* Git bash (Windows)
@@ -48,9 +48,9 @@ command to be typed in to terminal
Once youâve typed the command, press enter to send it.
-## Node.js version 8 LTS
+## Node.js version 10 LTS
-The kit is designed to work with Node.js version 8 LTS. The kit works with any 8.x.x version.
+The kit is designed to work with Node.js version 10 LTS. The kit works with any 10.x.x version.
### Check if you have Node.js
@@ -60,22 +60,22 @@ node --version
```
If it says `command not found` or `Error 0x2 starting node.exe --version` you donât have node and will need to download and install it.
-If the version number starts with 8 you have the correct version installed.
+If the version number starts with 10 you have the correct version installed.
-If it says another number such as `0.12` or `5.x.x`, you need to download and install version 8.
+If it says another number such as `0.12` or `5.x.x`, you need to download and install version 10.
### Download and install Node.js
#### Mac / Windows users
-Download version 8 from [nodejs.org](https://nodejs.org/en/).
+Download version 10 from [nodejs.org](https://nodejs.org/en/).
Run the installer with all default options.
#### Linux users
Follow the instructions on the [Node.js](https://nodejs.org/en/download/package-manager/#debian-and-ubuntu-based-linux-distributions
-) site. Make sure you get version 8, not 9.
+) site. Make sure you get version 10.
### Once Node is installed
@@ -86,7 +86,7 @@ To check it is installed correctly you can again run:
node --version
```
-If itâs installed correctly it should show a number starting with 8.
+If itâs installed correctly it should show a number starting with 10.
## Atom (text editor)
diff --git a/docs/documentation/using-notify.md b/docs/documentation/using-notify.md
new file mode 100644
index 0000000000..07021e0489
--- /dev/null
+++ b/docs/documentation/using-notify.md
@@ -0,0 +1,142 @@
+# Using GOV.UK Notify to prototype emails and text messages
+
+You can use GOV.UK Notify to send text messages or emails when users
+interact with your prototype. For example you could send users a
+confirmation email at the end of a journey.
+
+## Sign up for a GOV.UK Notify account
+
+If you have a government email address you can sign up for an account at
+https://www.gov.uk/notify
+
+You need an account before you can use GOV.UK Notify to send text
+messages or emails.
+
+## Getting an API key
+
+An API key is like a password that lets your prototype talk to Notify.
+Your prototype sends this key every time it asks Notify to do something.
+
+To get a key:
+- sign into GOV.UK Notify
+- go to the âAPI integrationâ page
+- click âAPI keysâ
+- click the âCreate an APIâ button
+- choose the âTeam and whitelistâ option
+- copy the key to your clipboard
+
+### Saving your key on your computer
+
+This will let your prototype talk to Notify while itâs running on your
+computer.
+
+To save the key on your computer, add this line to the end of the `.env`
+file in your prototype (where `xxxxxxx` is the key youâve copied from
+Notify):
+```shell
+NOTIFYAPIKEY=xxxxxxx
+```
+Your prototype will load the key from your `.env` file. If you donât
+have a `.env` file then run your prototype (with the `npm start`
+command) and it will create one for you.
+
+### Saving the key on Heroku
+
+This will let your prototype talk to Notify while itâs running on
+Heroku.
+
+To save the key on Heroku, go to the settings page of your app, click
+âReveal config varsâ and fill in the two textboxes like this (where
+xxxxxxx is the key youâve copied from Notify):
+```
+KEY | VALUE
+-------------|----------
+NOTIFYAPIKEY | xxxxxxx
+```
+
+### Keeping your key safe
+
+Itâs really important that you keep your key secret. If you put it in
+the `.env` file itâs safe â that file isnât published on GitHub. If you
+do accidentally publish your key somewhere you must sign into Notify and
+revoke it.
+
+## Add the Notify code to your prototype
+
+Add this code to the top of routes.js:
+
+```javascript
+var NotifyClient = require('notifications-node-client').NotifyClient,
+ notify = new NotifyClient(process.env.NOTIFYAPIKEY);
+```
+
+## Sending your first email
+
+Make a page with a form to collect the userâs email address. For
+example:
+```
+{% extends "layout.html" %}
+
+{% block content %}
+
+
The GOV.UK Prototype Kit provides a simple way to make interactive prototypes that look like pages on GOV.UK. These prototypes can be used to show ideas to people you work with, and to do user research
- -We have two Slack channels for the GOV.UK Prototype Kit. You'll need a government email address to join.
+ +The GOV.UK Prototype Kit is maintained by the Government Digital Service. If youâve got a question or need support you can:
If youâve got an idea or suggestion you can:
+07700 900457
+sarah.phillips@example.com
- By submitting this notification you are confirming that, to the best of your knowledge, the details you are providing are correct. -
+By submitting this notification you are confirming that, to the best of your knowledge, the details you are providing are correct.
+ ++ Check what you need to do to learn to drive. +
+ + + +- Use these as the basis for your prototypes. We recommend making copies of the files rather than directly editing them. + You can find them in your prototype folder, in /docs/views/templates
- You can find them in your prototype folder, in /docs/views/examples + Copy and paste the pages you need to your /app/views folder to make your prototypes.
+ Your prototype folder also contains the following page templates. +
++ You cannot adjust the design of these pages, but you can use them to help + you prototype realistic journeys connecting your service with GOV.UK content. +
+ ++ Read more about how to get a + Start page or + Step by step navigation for your service in the GOV.UK Design System. +
+diff --git a/gulp/clean.js b/gulp/clean.js index 6587689497..7983c2cb09 100644 --- a/gulp/clean.js +++ b/gulp/clean.js @@ -5,13 +5,12 @@ - public */ +const del = require('del') const gulp = require('gulp') -const clean = require('gulp-clean') const config = require('./config.json') -gulp.task('clean', function () { - return gulp.src([config.paths.public + '/*', - '.port.tmp'], {read: false}) - .pipe(clean()) +gulp.task('clean', function (done) { + return del([config.paths.public + '/*', + '.port.tmp']) }) diff --git a/gulp/copy-assets.js b/gulp/copy-assets.js index fbe57c6988..3c6503710e 100644 --- a/gulp/copy-assets.js +++ b/gulp/copy-assets.js @@ -5,6 +5,7 @@ */ const gulp = require('gulp') + const config = require('./config.json') gulp.task('copy-assets', function () { diff --git a/gulp/nodemon.js b/gulp/nodemon.js index 2f08392437..dd6fd778c2 100644 --- a/gulp/nodemon.js +++ b/gulp/nodemon.js @@ -8,23 +8,36 @@ const fs = require('fs') const path = require('path') const gulp = require('gulp') +const colour = require('ansi-colors') +const log = require('fancy-log') const nodemon = require('gulp-nodemon') const config = require('./config.json') +// Warn about npm install on crash +const onCrash = () => { + log(colour.cyan('[nodemon] For missing modules try running `npm install`')) +} + +// Remove .port.tmp if it exists +const onQuit = () => { + try { + fs.unlinkSync(path.join(__dirname, '/../.port.tmp')) + } catch (e) {} + + process.exit(0) +} + gulp.task('server', function () { nodemon({ watch: ['.env', '**/*.js', '**/*.json'], - script: 'server.js', - ignore: [config.paths.public + '*', + script: 'listen-on-port.js', + ignore: [ + config.paths.public + '*', config.paths.assets + '*', - config.paths.nodeModules + '*'] - }).on('quit', function () { - // remove .port.tmp if it exists - try { - fs.unlinkSync(path.join(__dirname, '/../.port.tmp')) - } catch (e) {} - - process.exit(0) + config.paths.nodeModules + '*' + ] }) + .on('crash', onCrash) + .on('quit', onQuit) }) diff --git a/gulp/sass.js b/gulp/sass.js index 022e9e59fb..1b33eed74c 100644 --- a/gulp/sass.js +++ b/gulp/sass.js @@ -8,13 +8,23 @@ const gulp = require('gulp') const sass = require('gulp-sass') const sourcemaps = require('gulp-sourcemaps') +const path = require('path') +const fs = require('fs') +const extensions = require('../lib/extensions/extensions') const config = require('./config.json') +gulp.task('sass-extensions', function (done) { + const fileContents = '$govuk-extensions-url-context: "/extension-assets"; ' + extensions.getFileSystemPaths('sass') + .map(filePath => `@import "${filePath.split(path.sep).join('/')}";`) + .join('\n') + fs.writeFile(path.join(config.paths.lib + 'extensions', '_extensions.scss'), fileContents, done) +}) + gulp.task('sass', function () { return gulp.src(config.paths.assets + '/sass/*.scss') .pipe(sourcemaps.init()) - .pipe(sass({outputStyle: 'expanded'}).on('error', sass.logError)) + .pipe(sass({ outputStyle: 'expanded' }).on('error', sass.logError)) .pipe(sourcemaps.write()) .pipe(gulp.dest(config.paths.public + '/stylesheets/')) }) @@ -22,7 +32,7 @@ gulp.task('sass', function () { gulp.task('sass-documentation', function () { return gulp.src(config.paths.docsAssets + '/sass/*.scss') .pipe(sourcemaps.init()) - .pipe(sass({outputStyle: 'expanded'}).on('error', sass.logError)) + .pipe(sass({ outputStyle: 'expanded' }).on('error', sass.logError)) .pipe(sourcemaps.write()) .pipe(gulp.dest(config.paths.public + '/stylesheets/')) }) diff --git a/gulp/tasks.js b/gulp/tasks.js deleted file mode 100644 index 24980f2694..0000000000 --- a/gulp/tasks.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - tasks.js - =========== - defaults wraps generate-assets, watch and server -*/ - -const gulp = require('gulp') -const mocha = require('gulp-mocha') -const runSequence = require('run-sequence') - -gulp.task('default', function (done) { - runSequence('generate-assets', - 'watch', - 'server', done) -}) - -gulp.task('generate-assets', function (done) { - runSequence('clean', - 'sass', - 'copy-assets', - 'sass-documentation', - 'copy-assets-documentation', - 'sass-v6', - 'copy-assets-v6', done) -}) - -gulp.task('watch', function (done) { - runSequence('watch-sass', - 'watch-assets', - 'watch-sass-v6', - 'watch-assets-v6', done) -}) - -gulp.task('test', function () { - runSequence('generate-assets', - 'mocha') -}) - -gulp.task('mocha', function () { - return gulp.src(['test/**/*.js'], { read: false }) - .pipe(mocha({ reporter: 'spec', exit: true })) - .on('error', console.error) -}) diff --git a/gulp/watch.js b/gulp/watch.js index 2c3e15fedb..47fa6faf0e 100644 --- a/gulp/watch.js +++ b/gulp/watch.js @@ -5,24 +5,25 @@ */ const gulp = require('gulp') + const config = require('./config.json') gulp.task('watch-sass', function () { - return gulp.watch(config.paths.assets + 'sass/**', {cwd: './'}, ['sass']) + return gulp.watch(config.paths.assets + 'sass/**', { cwd: './' }, gulp.task('sass')) }) gulp.task('watch-assets', function () { return gulp.watch([config.paths.assets + 'images/**', - config.paths.assets + 'javascripts/**'], {cwd: './'}, ['copy-assets']) + config.paths.assets + 'javascripts/**'], { cwd: './' }, gulp.task('copy-assets')) }) // Backward compatibility with Elements gulp.task('watch-sass-v6', function () { - return gulp.watch(config.paths.v6Assets + 'sass/**', {cwd: './'}, ['sass-v6']) + return gulp.watch(config.paths.v6Assets + 'sass/**', { cwd: './' }, gulp.task('sass-v6')) }) gulp.task('watch-assets-v6', function () { return gulp.watch([config.paths.v6Assets + 'images/**', - config.paths.v6Assets + 'javascripts/**'], {cwd: './'}, ['copy-assets-v6']) + config.paths.v6Assets + 'javascripts/**'], { cwd: './' }, gulp.task('copy-assets-v6')) }) diff --git a/gulpfile.js b/gulpfile.js index fb9ba12649..3b4702b22a 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -6,11 +6,38 @@ its own file in `/gulp`. Any files in that directory get automatically required below. To add a new task, simply add a new task file that directory. - `/gulp/tasks.js` specifies the default set of - tasks to run when you run `gulp`. */ +const gulp = require('gulp') const requireDir = require('require-dir') // Require all tasks in gulp/tasks, including subfolders -requireDir('./gulp', {recurse: true}) +requireDir('./gulp', { recurse: true }) + +// gulp 4 requires dependency tasks to be defined before they are called. +// We'll keep our top-level tasks in this file so that they are defined at the end of the chain, after their dependencies. +gulp.task('generate-assets', gulp.series( + 'clean', + 'sass-extensions', + gulp.parallel( + 'sass', + 'copy-assets', + 'sass-documentation', + 'copy-assets-documentation', + 'sass-v6', + 'copy-assets-v6' + ) +)) +gulp.task('watch', gulp.parallel( + 'watch-sass', + 'watch-assets', + 'watch-sass-v6', + 'watch-assets-v6' +)) +gulp.task('default', gulp.series( + 'generate-assets', + gulp.parallel( + 'watch', + 'server' + ) +)) diff --git a/lib/extensions/extensions.js b/lib/extensions/extensions.js new file mode 100644 index 0000000000..0d18746945 --- /dev/null +++ b/lib/extensions/extensions.js @@ -0,0 +1,174 @@ +/** + * Extensions.js (Use with caution) + * + * Experimental feature which is likely to change. + * This file returns helper methods to enable services to include + * their own departmental frontend(Styles, Scripts, nunjucks etc) + * + * Module.exports + * getPublicUrls: + * Params: (type | string ) eg. 'scripts', 'stylesheets' + * Description: + * returns array of urls for a type (script, stylesheet, nunjucks etc). + * getFileSystemPaths: + * Params: (type | string ) eg. 'scripts', 'stylesheets' + * Description: + * returns array paths to the file in the filesystem for a type (script, stylesheet, nunjucks etc) + * getPublicUrlAndFileSystemPaths: + * Params: (type | string ) eg. 'scripts', 'stylesheets' + * Description: + * returns Array of objects, each object is an extension and each obj has the filesystem & public url for the given type + * getAppConfig: + * Params: (type | string ) eg. 'scripts', 'stylesheets' + * Description: + * Returns an object containing two keys(scripts & stylesheets), each item contains an array of full paths to specific files. + * This is used in the views to output links and scripts each file. + * getAppViews: + * Params: (additionalViews | Array ) eg.extensions.getAppViews([path.join(__dirname, '/app/views/'),path.join(__dirname, '/lib/')]) + * Description: + * Returns an array of paths to nunjucks templates which is used to configure nunjucks in server.js + * setExtensionsByType + * Params: N/A + * Description: only used for test purposes to reset mocked extensions items to ensure they are up-to-date when the tests run + * + * * + */ + +// Core dependencies +const fs = require('fs') +const path = require('path') + +// Local dependencies +const appConfig = require('../../app/config') + +// Generic utilities +const removeDuplicates = arr => [...new Set(arr)] +const filterOutParentAndEmpty = part => part && part !== '..' +const objectMap = (object, mapFn) => Object.keys(object).reduce((result, key) => { + result[key] = mapFn(object[key], key) + return result +}, {}) + +// File utilities +const getPathFromProjectRoot = (...all) => { + return path.join.apply(null, [__dirname, '..', '..'].concat(all)) +} +const pathToPackageConfigFile = packageName => getPathFromProjectRoot('node_modules', packageName, 'govuk-prototype-kit.config.json') + +const readJsonFile = (filePath) => { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} +const getPackageConfig = packageName => { + if (fs.existsSync(pathToPackageConfigFile(packageName))) { + return readJsonFile(pathToPackageConfigFile(packageName)) + } else { + return {} + } +} + +// Handle errors to do with extension paths +// Example of `subject`: { packageName: 'govuk-frontend', item: '/all.js' } +const throwIfBadFilepath = subject => { + if (('' + subject.item).indexOf('\\') > -1) { + throw new Error(`Can't use backslashes in extension paths - "${subject.packageName}" used "${subject.item}".`) + } + if (!('' + subject.item).startsWith('/')) { + throw new Error(`All extension paths must start with a forward slash - "${subject.packageName}" used "${subject.item}".`) + } +} + +// Check for `baseExtensions` in config.js. If it's not there, default to `govuk-frontend` +const getBaseExtensions = () => appConfig.baseExtensions || ['govuk-frontend'] + +// Get all npm dependencies +// Get baseExtensions in the order defined in `baseExtensions` in config.js +// Then place baseExtensions before npm dependencies (and remove duplicates) +const getPackageNamesInOrder = () => { + const dependencies = readJsonFile(getPathFromProjectRoot('package.json')).dependencies || {} + const allNpmDependenciesInAlphabeticalOrder = Object.keys(dependencies).sort() + const installedBaseExtensions = getBaseExtensions() + .filter(packageName => allNpmDependenciesInAlphabeticalOrder.includes(packageName)) + + return removeDuplicates(installedBaseExtensions.concat(allNpmDependenciesInAlphabeticalOrder)) +} + +// Extensions provide items such as sass scripts, asset paths etc. +// This function groups them by type in a format which can used by getList +// Example of return +// { +// nunjucksPaths: [ +// { packageName: 'govuk-frontend', item: '/' }, +// { packageName: 'govuk-frontend', item: '/components'} +// ], +// scripts: [ +// { packageName: 'govuk-frontend', item: '/all.js' } +// ] +// assets: [ +// { packageName: 'govuk-frontend', item: '/assets' } +// ], +// sass: [ +// { packageName: 'govuk-frontend', item: '/all.scss' } +// ]} +const getExtensionsByType = () => { + return getPackageNamesInOrder() + .reduce((accum, packageName) => Object.assign({}, accum, objectMap( + getPackageConfig(packageName), + (listOfItemsForType, type) => (accum[type] || []) + .concat([].concat(listOfItemsForType).map(item => ({ + packageName, + item + }))) + )), {}) +} + +let extensionsByType + +const setExtensionsByType = () => { + extensionsByType = getExtensionsByType() +} + +setExtensionsByType() + +// The hard-coded reference to govuk-frontend allows us to soft launch without a breaking change. After a hard launch +// govuk-frontend assets will be served on /extension-assets/govuk-frontend +const getPublicUrl = config => { + if (config.item === '/assets' && config.packageName === 'govuk-frontend') { + return '/assets' + } else { + return ['', 'extension-assets', config.packageName] + .concat(config.item.split('/').filter(filterOutParentAndEmpty)) + .map(encodeURIComponent) + .join('/') + } +} + +const getFileSystemPath = config => { + throwIfBadFilepath(config) + return getPathFromProjectRoot('node_modules', + config.packageName, + config.item.split('/').filter(filterOutParentAndEmpty).join(path.sep)) +} + +const getPublicUrlAndFileSystemPath = config => ({ + fileSystemPath: getFileSystemPath(config), + publicUrl: getPublicUrl(config) +}) + +const getList = type => extensionsByType[type] || [] + +// Exports +const self = module.exports = { + getPublicUrls: type => getList(type).map(getPublicUrl), + getFileSystemPaths: type => getList(type).map(getFileSystemPath), + getPublicUrlAndFileSystemPaths: type => getList(type).map(getPublicUrlAndFileSystemPath), + getAppConfig: _ => ({ + scripts: self.getPublicUrls('scripts'), + stylesheets: self.getPublicUrls('stylesheets') + }), + getAppViews: additionalViews => self + .getFileSystemPaths('nunjucksPaths') + .reverse() + .concat(additionalViews || []), + + setExtensionsByType // exposed only for testing purposes +} diff --git a/lib/extensions/extensions.test.js b/lib/extensions/extensions.test.js new file mode 100644 index 0000000000..7a443dc233 --- /dev/null +++ b/lib/extensions/extensions.test.js @@ -0,0 +1,474 @@ +/* eslint-env jest */ +/* global spyOn */ +// NPM dependencies +const path = require('path') +const fs = require('fs') +const appConfig = require('../../app/config') + +// Local dependencies +const extensions = require('./extensions.js') + +// Local variables +const rootPath = path.join(__dirname, '..', '..') +let testScope + +// helpers +const joinPaths = arr => arr.map(x => path.join.apply(null, [rootPath].concat(x))) + +describe('extensions', () => { + beforeEach(() => { + testScope = { + originalValues: { + appConfigBaseExtensions: appConfig.baseExtensions + }, + fileSystem: {} + } + addFileToMockFileSystem(['package.json'], fs.readFileSync('package.json', 'utf8')) + addFileToMockFileSystem(['node_modules', 'govuk-frontend', 'govuk-prototype-kit.config.json'], '{"nunjucksPaths": ["/","/components"],"scripts": ["/all.js"],"assets": ["/assets"],"sass": ["/all.scss"]}') + setupFakeFilesystem() + extensions.setExtensionsByType() + }) + afterEach(() => { + jest.clearAllMocks() + appConfig.baseExtensions = testScope.originalValues.appConfigBaseExtensions + }) + + describe('Lookup file system paths', () => { + it('should lookup asset paths as file system paths', () => { + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'govuk-frontend', 'assets'] + ])) + }) + it('should not allow traversing the file system', () => { + mockExtensionConfig('govuk-frontend', { assets: ['/abc/../../../../../def'] }) + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'govuk-frontend', 'abc', 'def'] + ])) + }) + it('should show installed extensions asset paths as file system paths', () => { + delete appConfig.baseExtensions + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'govuk-frontend', 'assets'], + ['node_modules', 'another-frontend', 'abc'], + ['node_modules', 'another-frontend', 'def'], + ['node_modules', 'hmrc-frontend', 'ghi'], + ['node_modules', 'hmrc-frontend', 'jkl'] + ])) + }) + it('should follow strict alphabetical order when no base extensions used', () => { + appConfig.baseExtensions = [] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'another-frontend', 'abc'], + ['node_modules', 'another-frontend', 'def'], + ['node_modules', 'govuk-frontend', 'assets'], + ['node_modules', 'hmrc-frontend', 'ghi'], + ['node_modules', 'hmrc-frontend', 'jkl'] + ])) + }) + it('should put specified baseExtensions at the top', () => { + appConfig.baseExtensions = ['hmrc-frontend', 'govuk-frontend'] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'hmrc-frontend', 'ghi'], + ['node_modules', 'hmrc-frontend', 'jkl'], + ['node_modules', 'govuk-frontend', 'assets'], + ['node_modules', 'another-frontend', 'abc'], + ['node_modules', 'another-frontend', 'def'] + ])) + }) + it('should show installed extensions asset paths as file system paths', () => { + mockExtensionConfig('hmrc-frontend', { + assets: ['/abc', '/def'] + }) + expect(extensions.getFileSystemPaths('assets')).toEqual(joinPaths([ + ['node_modules', 'govuk-frontend', 'assets'], + ['node_modules', 'hmrc-frontend', 'abc'], + ['node_modules', 'hmrc-frontend', 'def'] + ])) + }) + it('should lookup scripts paths as file system paths', () => { + expect(extensions.getFileSystemPaths('scripts')).toEqual(joinPaths([ + 'node_modules/govuk-frontend/all.js' + ])) + }) + it('should not break when asking for an extension key which isn\'t used', function () { + expect(extensions.getFileSystemPaths('thisListDoesNotExist')).toEqual([]) + }) + }) + + describe('Lookup public URLs', () => { + it('should show installed extensions asset paths as file system paths', () => { + delete appConfig.baseExtensions + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrls('assets')).toEqual([ + '/assets', + '/extension-assets/another-frontend/abc', + '/extension-assets/another-frontend/def', + '/extension-assets/hmrc-frontend/ghi', + '/extension-assets/hmrc-frontend/jkl' + ]) + }) + it('should follow strict alphabetical order when no base extensions used', () => { + appConfig.baseExtensions = [] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrls('assets')).toEqual([ + '/extension-assets/another-frontend/abc', + '/extension-assets/another-frontend/def', + '/assets', + '/extension-assets/hmrc-frontend/ghi', + '/extension-assets/hmrc-frontend/jkl' + ]) + }) + it('should put specified baseExtensions at the top', () => { + appConfig.baseExtensions = ['hmrc-frontend', 'govuk-frontend'] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrls('assets')).toEqual([ + '/extension-assets/hmrc-frontend/ghi', + '/extension-assets/hmrc-frontend/jkl', + '/assets', + '/extension-assets/another-frontend/abc', + '/extension-assets/another-frontend/def' + ]) + }) + it('should url encode each part', () => { + mockExtensionConfig('mine', { assets: ['/abc:def'] }) + mockUninstallExtension('govuk-frontend') + + expect(extensions.getPublicUrls('assets')).toEqual(['/extension-assets/mine/abc%3Adef']) + }) + }) + + describe('Lookup public URLs with file system paths', () => { + it('should show installed extensions asset paths as file system paths', () => { + delete appConfig.baseExtensions + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrlAndFileSystemPaths('assets')).toEqual([ + { + publicUrl: '/assets', + fileSystemPath: path.join(rootPath, 'node_modules', 'govuk-frontend', 'assets') + }, + { + publicUrl: '/extension-assets/another-frontend/abc', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'abc') + }, + { + publicUrl: '/extension-assets/another-frontend/def', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'def') + }, + { + publicUrl: '/extension-assets/hmrc-frontend/ghi', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'ghi') + }, + { + publicUrl: '/extension-assets/hmrc-frontend/jkl', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'jkl') + } + ]) + }) + it('should follow strict alphabetical order when no base extensions used', () => { + appConfig.baseExtensions = [] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrlAndFileSystemPaths('assets')).toEqual([ + { + publicUrl: '/extension-assets/another-frontend/abc', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'abc') + }, + { + publicUrl: '/extension-assets/another-frontend/def', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'def') + }, + { + publicUrl: '/assets', + fileSystemPath: path.join(rootPath, 'node_modules', 'govuk-frontend', 'assets') + }, + { + publicUrl: '/extension-assets/hmrc-frontend/ghi', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'ghi') + }, + { + publicUrl: '/extension-assets/hmrc-frontend/jkl', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'jkl') + } + ]) + }) + it('should put specified baseExtensions at the top', () => { + appConfig.baseExtensions = ['hmrc-frontend', 'govuk-frontend'] + mockExtensionConfig('another-frontend', { + assets: ['/abc', '/def'] + }) + mockExtensionConfig('hmrc-frontend', { + assets: ['/ghi', '/jkl'] + }) + expect(extensions.getPublicUrlAndFileSystemPaths('assets')).toEqual([ + { + publicUrl: '/extension-assets/hmrc-frontend/ghi', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'ghi') + }, + { + publicUrl: '/extension-assets/hmrc-frontend/jkl', + fileSystemPath: path.join(rootPath, 'node_modules', 'hmrc-frontend', 'jkl') + }, + { + publicUrl: '/assets', + fileSystemPath: path.join(rootPath, 'node_modules', 'govuk-frontend', 'assets') + }, + { + publicUrl: '/extension-assets/another-frontend/abc', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'abc') + }, + { + publicUrl: '/extension-assets/another-frontend/def', + fileSystemPath: path.join(rootPath, 'node_modules', 'another-frontend', 'def') + } + ]) + }) + it('should url encode each part', () => { + mockExtensionConfig('mine', { assets: ['/abc:def'] }) + mockUninstallExtension('govuk-frontend') + + expect(extensions.getPublicUrls('assets')).toEqual(['/extension-assets/mine/abc%3Adef']) + }) + it('should not break when asking for an extension key which isn\'t used', function () { + expect(extensions.getPublicUrls('anotherListThatDoesntExist')).toEqual([]) + }) + }) + + describe('getAppViews', () => { + it('should be a function', () => { + expect(extensions.getAppViews).toBeInstanceOf(Function) + }) + + it('should output govuk-frontend nunjucks paths as an array', () => { + expect(extensions.getAppViews()).toEqual(joinPaths([ + 'node_modules/govuk-frontend/components', + 'node_modules/govuk-frontend' + ])) + }) + + it('should also output hmcts-frontend nunjucks paths after it is installed', () => { + mockExtensionConfig('hmcts-frontend', { + nunjucksPaths: [ + '/my-components', + '/my-layouts' + ] + }) + + expect(extensions.getAppViews()).toEqual(joinPaths([ + 'node_modules/hmcts-frontend/my-layouts', + 'node_modules/hmcts-frontend/my-components', + 'node_modules/govuk-frontend/components', + 'node_modules/govuk-frontend' + ])) + }) + + it('should not output any nunjucks paths when frontends are uninstalled', () => { + mockUninstallExtension('govuk-frontend') + + expect(extensions.getAppViews()).toEqual([]) + }) + + it('should also output provided paths in the array', () => { + expect(extensions.getAppViews(joinPaths([ + '/app/views', + '/lib' + ]))).toEqual(joinPaths([ + 'node_modules/govuk-frontend/components', + 'node_modules/govuk-frontend', + '/app/views', + '/lib' + ])) + }) + + it('should output any provided paths in the array', () => { + expect(extensions.getAppViews([ + '/my-new-views-directory' + ])).toEqual([ + path.join(rootPath, 'node_modules/govuk-frontend/components'), + path.join(rootPath, 'node_modules/govuk-frontend'), + '/my-new-views-directory' + ]) + }) + }) + + describe('getAppConfig', () => { + it('returns an object', () => { + expect(extensions.getAppConfig()).toBeInstanceOf(Object) + }) + + it('should have script and stylesheet keys', () => { + expect(Object.keys(extensions.getAppConfig())).toEqual(['scripts', 'stylesheets']) + }) + + it('should return a list of public urls for the scripts', () => { + expect(extensions.getAppConfig().scripts).toEqual([ + '/extension-assets/govuk-frontend/all.js' + ]) + }) + + it('should return a list of public urls for the stylesheets', () => { + expect(extensions.getAppConfig().stylesheets).toEqual([]) + }) + + it('should include installed extensions', () => { + mockExtensionConfig('my-extension', { scripts: ['/abc/def/ghi.js'] }) + expect(extensions.getAppConfig().scripts).toEqual([ + '/extension-assets/govuk-frontend/all.js', + '/extension-assets/my-extension/abc/def/ghi.js' + ]) + }) + + it('should return a list of public urls for the stylesheets', () => { + expect(extensions.getAppConfig().stylesheets).toEqual([]) + }) + + it('should include installed extensions', () => { + mockExtensionConfig('my-extension', { stylesheets: ['/abc/def/ghi.css'] }) + expect(extensions.getAppConfig().stylesheets).toEqual([ + '/extension-assets/my-extension/abc/def/ghi.css' + ]) + }) + }) + + describe('error handling', () => { + it('should cope with keys which aren\'t arrays', () => { + mockExtensionConfig('my-fixable-extension', { stylesheets: '/abc.css' }) + mockExtensionConfig('another-fixable-extension', { stylesheets: '/abc.css' }) + + expect(extensions.getAppConfig().stylesheets).toEqual([ + '/extension-assets/another-fixable-extension/abc.css', + '/extension-assets/my-fixable-extension/abc.css' + ]) + }) + it('should throw if paths use backslashes', () => { + mockExtensionConfig('my-unfixable-extension', { stylesheets: '\\abc\\def.css' }) + mockExtensionConfig('another-fixable-extension', { stylesheets: ['/abc.css'] }) + + const expectedError = new Error('Can\'t use backslashes in extension paths - "my-unfixable-extension" used "\\abc\\def.css".') + + expect(() => { + extensions.getFileSystemPaths('stylesheets') + }).toThrow(expectedError) + + expect(() => { + extensions.getPublicUrlAndFileSystemPaths('stylesheets') + }).toThrow(expectedError) + }) + it('should throw if paths use backslashes further into the path', () => { + mockExtensionConfig('my-other-unfixable-extension', { stylesheets: ['/abc\\def.css'] }) + const expectedError2 = new Error('Can\'t use backslashes in extension paths - "my-other-unfixable-extension" used "/abc\\def.css".') + + expect(() => { + extensions.getFileSystemPaths('stylesheets') + }).toThrow(expectedError2) + + expect(() => { + extensions.getPublicUrlAndFileSystemPaths('stylesheets') + }).toThrow(expectedError2) + }) + it('should throw if it doesn\'t start with a forward slash', () => { + mockExtensionConfig('yet-another-unfixable-extension', { stylesheets: ['abc.css'] }) + + const noLeadingForwardSlashError = new Error('All extension paths must start with a forward slash - "yet-another-unfixable-extension" used "abc.css".') + + expect(() => { + extensions.getFileSystemPaths('stylesheets') + }).toThrow(noLeadingForwardSlashError) + + expect(() => { + extensions.getPublicUrlAndFileSystemPaths('stylesheets') + }).toThrow(noLeadingForwardSlashError) + }) + }) + + const setupFakeFilesystem = () => { + const prepFilePath = filePath => { + return (filePath).replace(rootPath + path.sep, '').split(path.sec) + } + + spyOn(fs, 'readFileSync').and.callFake(function (filePath) { + const trimmedPath = prepFilePath(filePath) + if (doesFileExitInMockFileSystem(trimmedPath)) { + return readFileFromMockFileSystem(trimmedPath) + } else { + const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`) + err.code = 'ENOENT' + throw err + } + }) + spyOn(fs, 'existsSync').and.callFake(filePath => doesFileExitInMockFileSystem(prepFilePath(filePath))) + } + + const addFileToMockFileSystem = (pathParts, content) => { + testScope.fileSystem[path.join(...pathParts)] = content + } + + const readFileFromMockFileSystem = (pathParts) => testScope.fileSystem[path.join(...pathParts)] + + const doesFileExitInMockFileSystem = (pathParts) => { + return testScope.fileSystem.hasOwnProperty(path.join(...pathParts)) + } + + const mockInstallExtension = (packageName, version = '^0.0.1') => { + const existingPackageJson = JSON.parse(readFileFromMockFileSystem(['package.json'])) + existingPackageJson.dependencies[packageName] = version + addFileToMockFileSystem(['package.json'], JSON.stringify(existingPackageJson)) + extensions.setExtensionsByType() + } + + const mockUninstallExtension = (packageName) => { + const existingPackageJson = JSON.parse(readFileFromMockFileSystem(['package.json'])) + if (!existingPackageJson.dependencies.hasOwnProperty(packageName)) { + throw new Error(`Could not uninstall '${packageName}' as it is not installed`) + } + delete existingPackageJson.dependencies[packageName] + addFileToMockFileSystem(['package.json'], JSON.stringify(existingPackageJson)) + extensions.setExtensionsByType() + } + + const mockExtensionConfig = (packageName, config = {}, version) => { + addFileToMockFileSystem(['node_modules', packageName, 'govuk-prototype-kit.config.json'], JSON.stringify(config, null, 2)) + mockInstallExtension(packageName, version) + } +}) diff --git a/lib/middleware/authentication/authentication.js b/lib/middleware/authentication/authentication.js new file mode 100644 index 0000000000..f7f04ebaa9 --- /dev/null +++ b/lib/middleware/authentication/authentication.js @@ -0,0 +1,42 @@ +/** + * Simple basic auth middleware for use with Express 4.x. + * + * Based on template found at: http://www.danielstjules.com/2014/08/03/basic-auth-with-express-4/ + * + * @example + * const authentication = required('authentication.js') + * app.use(authentication) + * + * @param {string} req Express Request object + * @param {string} res Express Response object + * @returns {function} Express 4 middleware requiring the given credentials + */ + +module.exports = function (req, res, next) { + // NPM Dependencies + const basicAuth = require('basic-auth') + + // Local dependencies + const config = require('../../../app/config.js') + + // Local Variables + const env = (process.env.NODE_ENV || 'development').toLowerCase() + const useAuth = (process.env.USE_AUTH || config.useAuth).toLowerCase() + const username = process.env.USERNAME + const password = process.env.PASSWORD + + if (env === 'production' && useAuth === 'true') { + if (!username || !password) { + console.error('Username or password is not set.') + return res.send('
Username or password not set. See guidance for setting these.
') + } + + const user = basicAuth(req) + + if (!user || user.name !== username || user.pass !== password) { + res.set('WWW-Authenticate', 'Basic realm=Authorization Required') + return res.sendStatus(401) + } + } + next() +} diff --git a/lib/middleware/authentication/authentication.test.js b/lib/middleware/authentication/authentication.test.js new file mode 100644 index 0000000000..cd3997f643 --- /dev/null +++ b/lib/middleware/authentication/authentication.test.js @@ -0,0 +1,162 @@ +/* eslint-env jest */ +// NPM dependencies +jest.mock('basic-auth') +jest.mock('express/lib/response') +const basicAuth = require('basic-auth') + +// Local dependencies +let authentication = require('./authentication.js') + +// Local variables +const userDetails = { + name: 'secret-username', + pass: 'secure-password' +} +const next = jest.fn() + +const res = require('express/lib/response') + +// Mock console.log so we can check any output +console.error = jest.fn() + +describe('authentication', () => { + it('should be a function', () => { + expect(authentication).toBeInstanceOf(Function) + }) + + describe('when it runs in production', () => { + beforeAll(() => { + process.env.NODE_ENV = 'production' + process.env.USE_AUTH = 'true' + }) + + describe('server with no username/password set', () => { + beforeAll(() => { + delete process.env.USERNAME + delete process.env.PASSWORD + }) + + beforeEach(() => { + // Jest mocks stores each call to the mocked function + // so we want to clear them before running the authentication again. + console.error.mockClear() + authentication({}, res, next) + }) + + it('should return a console error', () => { + let consoleErrorMessage = console.error.mock.calls[0][0] + expect(consoleErrorMessage).toBe('Username or password is not set.') + }) + + it('should send marked up error message', () => { + let errorDisplayedToUser = res.send.mock.calls[0][0] + expect(errorDisplayedToUser).toBe('Username or password not set. See guidance for setting these.
') + }) + }) + + describe('server with username/password set', () => { + beforeAll(() => { + process.env.USERNAME = 'secret-username' + process.env.PASSWORD = 'secure-password' + }) + + describe('when a user supplies correct username/password', () => { + beforeEach(() => { + jest.clearAllMocks() + basicAuth.mockReturnValue(userDetails) + authentication({}, res, next) + }) + + it('should not return a console error', () => { + expect(console.error).not.toBeCalled() + }) + + it('should call basic-auth and return username/password', () => { + expect(basicAuth).toReturnWith(userDetails) + }) + + it('should not set authentication header', () => { + expect(res.set).not.toBeCalled() + }) + + it('should not return a status', () => { + expect(res.sendStatus).not.toBeCalled() + }) + + it('should progress to the next middleware', () => { + expect(next).toBeCalled() + }) + }) + + describe('when a user supplies incorrect username/password', () => { + beforeEach(() => { + jest.clearAllMocks() + basicAuth.mockReturnValue(undefined) + authentication({}, res, next) + }) + + it('should not return a console error', () => { + expect(console.error).not.toBeCalled() + }) + + it('should not send error message to the browser', () => { + expect(res.send).not.toBeCalled() + }) + + it('should call basic-auth and return username/password', () => { + expect(basicAuth).toReturnWith(undefined) + }) + + it('should set authentication header', () => { + expect(res.set).toHaveBeenCalledWith('WWW-Authenticate', 'Basic realm=Authorization Required') + }) + + it('should return 401/UnAuthorized', () => { + expect(res.sendStatus).toHaveBeenCalledWith(401) + }) + + it('should not progress to the next middleware', () => { + expect(next).not.toBeCalled() + }) + }) + }) + }) + + describe('when it runs in non-production enviroment (dev by default)', () => { + beforeAll(() => { + // Jest automatically sets NODE_ENV to 'test' + // but we want to test when there is no NODE_ENV and it defaults + // to development + delete process.env.NODE_ENV + }) + + beforeEach(() => { + jest.clearAllMocks() + authentication({}, res, next) + }) + + it('should not call console error', () => { + expect(console.error).not.toBeCalled() + }) + + it('should not send marked up error message', () => { + expect(res.send).not.toBeCalled() + }) + + it('should not call basic-auth', () => { + expect(basicAuth).not.toBeCalled() + }) + + it('should not set authentication header', () => { + expect(res.set).not.toBeCalled() + }) + + it('should not return 401/UnAuthorized', () => { + expect(res.sendStatus).not.toBeCalled() + }) + + it('should progress to the next middleware', () => { + expect(next).toBeCalled() + }) + }) +}) diff --git a/lib/middleware/extensions/extensions.js b/lib/middleware/extensions/extensions.js new file mode 100644 index 0000000000..9475ae9cd5 --- /dev/null +++ b/lib/middleware/extensions/extensions.js @@ -0,0 +1,17 @@ +const express = require('express') +const extensions = require('../../extensions/extensions') +const router = express.Router() + +// Serve assets from extensions +function setupPathsFor (item) { + extensions.getPublicUrlAndFileSystemPaths(item) + .forEach(paths => { + router.use(paths.publicUrl, express.static(paths.fileSystemPath)) + }) +} + +setupPathsFor('scripts') +setupPathsFor('stylesheets') +setupPathsFor('assets') + +module.exports = router diff --git a/lib/prototype-admin/clear-data-success.html b/lib/prototype-admin/clear-data-success.html new file mode 100644 index 0000000000..cc80f50364 --- /dev/null +++ b/lib/prototype-admin/clear-data-success.html @@ -0,0 +1,23 @@ +{% extends "layout.html" %} + +{% block pageTitle %} + Data cleared - GOV.UK Prototype Kit +{% endblock %} + +{% block content %} + ++ The session data has been cleared. +
+ + + +{% endblock %} diff --git a/lib/prototype-admin/clear-data.html b/lib/prototype-admin/clear-data.html index 14750f8484..005fa65f3c 100644 --- a/lib/prototype-admin/clear-data.html +++ b/lib/prototype-admin/clear-data.html @@ -1,23 +1,36 @@ + {% extends "layout.html" %} {% block pageTitle %} - Clear data + Clear data? - GOV.UK Prototype Kit {% endblock %} -{% block content %} +{% block beforeContent %} + {{ govukBackLink({ + "text": "Back", + "href": "javascript: window.history.go(-1)" + }) + }} +{% endblock %} -- The session data has been cleared. -
+ {% endblock %} diff --git a/lib/prototype-admin/clears-data.html b/lib/prototype-admin/clears-data.html deleted file mode 100644 index 0e6a77906b..0000000000 --- a/lib/prototype-admin/clears-data.html +++ /dev/null @@ -1,36 +0,0 @@ - -{% extends "layout.html" %} - -{% block pageTitle %} - Clear data? | GOV.UK Prototype Kit -{% endblock %} - -{% block beforeContent %} - {{ govukBackLink({ - "text": "Back", - "href": "javascript: window.history.go(-1)" - }) - }} -{% endblock %} - -{% block content %} - -Username or password not set. See guidance for setting these.
') - } - - var user = basicAuth(req) - - if (!user || user.name !== username || user.pass !== password) { - res.set('WWW-Authenticate', 'Basic realm=Authorization Required') - return res.sendStatus(401) - } - - next() - } -} - // Find an available port to run the server on exports.findAvailablePort = function (app, callback) { var port = null @@ -180,7 +148,7 @@ exports.getLatestRelease = function () { 'GET', 'https://api.github.com/repos/alphagov/govuk-prototype-kit/releases/latest', { - headers: {'user-agent': 'node.js'} + headers: { 'user-agent': 'node.js' } } ) var data = JSON.parse(res.getBody('utf8')) @@ -245,7 +213,7 @@ exports.matchMdRoutes = function (req, res) { if (fs.existsSync(path.join(__dirname, docsPath, req.params[0] + '.md'), 'utf8')) { var doc = fs.readFileSync(path.join(__dirname, docsPath, req.params[0] + '.md'), 'utf8') var html = marked(doc) - res.render('documentation_template', {'document': html}) + res.render('documentation_template', { 'document': html }) return true } return false diff --git a/listen-on-port.js b/listen-on-port.js new file mode 100644 index 0000000000..dbcb5434c7 --- /dev/null +++ b/listen-on-port.js @@ -0,0 +1,32 @@ + +// NPM dependencies +const browserSync = require('browser-sync') + +// Local dependencies +const server = require('./server.js') +const config = require('./app/config.js') +const utils = require('./lib/utils.js') + +// Set up configuration variables +var useBrowserSync = config.useBrowserSync.toLowerCase() +var env = (process.env.NODE_ENV || 'development').toLowerCase() + +utils.findAvailablePort(server, function (port) { + console.log('Listening on port ' + port + ' url: http://localhost:' + port) + if (env === 'production' || useBrowserSync === 'false') { + server.listen(port) + } else { + server.listen(port - 50, function () { + browserSync({ + proxy: 'localhost:' + (port - 50), + port: port, + ui: false, + files: ['public/**/*.*', 'app/views/**/*.*'], + ghostmode: false, + open: false, + notify: false, + logLevel: 'error' + }) + }) + } +}) diff --git a/package.json b/package.json index de8c7b45d4..e9ce229a5d 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,19 @@ { "name": "express-prototype", "description": "GOVUK prototyping app in Express", - "version": "8.0.0", + "version": "8.7.0", "private": true, "engines": { - "node": "^8.9.1" + "node": "^10.0.0" }, "scripts": { "start": "node start.js", "lint": "standard", - "test": "gulp test && npm run lint" + "test": "npm run lint && gulp generate-assets && jest" }, "dependencies": { + "acorn": "^6.0.5", + "ansi-colors": "^3.2.3", "basic-auth": "^2.0.0", "basic-auth-connect": "^1.0.0", "body-parser": "^1.14.1", @@ -19,32 +21,27 @@ "client-sessions": "^0.8.0", "cookie-parser": "^1.4.3", "cross-spawn": "^6.0.5", + "del": "^3.0.0", "dotenv": "^6.0.0", "express": "4.16.3", "express-session": "^1.13.0", "express-writer": "0.0.4", + "fancy-log": "^1.3.3", "govuk-elements-sass": "^3.1.3", - "govuk-frontend": "^2.0.0", + "govuk-frontend": "^2.7.0", "govuk_frontend_toolkit": "^7.5.0", "govuk_template_jinja": "^0.24.1", - "gulp": "^3.9.1", - "gulp-clean": "^0.4.0", - "gulp-mocha": "^6.0.0", + "gulp": "^4.0.0", "gulp-nodemon": "^2.1.0", "gulp-sass": "^4.0.1", "gulp-sourcemaps": "^2.6.0", - "gulp-util": "^3.0.7", "keypather": "^3.0.0", "marked": "^0.4.0", - "minimist": "1.2.0", "notifications-node-client": "^4.1.0", "nunjucks": "^3.1.3", "portscanner": "^2.1.1", "prompt": "^1.0.0", "require-dir": "^1.0.0", - "run-sequence": "^2.2.1", - "standard": "^11.0.1", - "supertest": "^3.0.0", "sync-request": "^6.0.0", "universal-analytics": "^0.4.16", "uuid": "^3.2.1" @@ -53,5 +50,10 @@ "ignore": [ "nunjucks" ] + }, + "devDependencies": { + "jest": "^23.6.0", + "standard": "^12.0.1", + "supertest": "^3.0.0" } } diff --git a/server.js b/server.js index 1eb52695dc..3d58729cbe 100644 --- a/server.js +++ b/server.js @@ -3,7 +3,6 @@ const path = require('path') // NPM dependencies const bodyParser = require('body-parser') -const browserSync = require('browser-sync') const dotenv = require('dotenv') const express = require('express') const nunjucks = require('nunjucks') @@ -15,11 +14,16 @@ const cookieParser = require('cookie-parser') dotenv.config() // Local dependencies +const middleware = [ + require('./lib/middleware/authentication/authentication.js'), + require('./lib/middleware/extensions/extensions.js') +] const config = require('./app/config.js') const documentationRoutes = require('./docs/documentation_routes.js') const packageJson = require('./package.json') const routes = require('./app/routes.js') const utils = require('./lib/utils.js') +const extensions = require('./lib/extensions/extensions.js') // Variables for v6 backwards compatibility // Set false by default, then turn on if we find /app/v6/routes.js @@ -51,20 +55,13 @@ documentationApp.use(handleCookies) // Set up configuration variables var releaseVersion = packageJson.version -var username = process.env.USERNAME -var password = process.env.PASSWORD -var env = process.env.NODE_ENV || 'development' -var useAuth = process.env.USE_AUTH || config.useAuth +var env = (process.env.NODE_ENV || 'development').toLowerCase() var useAutoStoreData = process.env.USE_AUTO_STORE_DATA || config.useAutoStoreData var useCookieSessionStore = process.env.USE_COOKIE_SESSION_STORE || config.useCookieSessionStore var useHttps = process.env.USE_HTTPS || config.useHttps -var useBrowserSync = config.useBrowserSync var gtmId = process.env.GOOGLE_TAG_MANAGER_TRACKING_ID -env = env.toLowerCase() -useAuth = useAuth.toLowerCase() useHttps = useHttps.toLowerCase() -useBrowserSync = useBrowserSync.toLowerCase() var useDocumentation = (config.useDocumentation === 'true') @@ -83,25 +80,27 @@ if (isSecure) { app.set('trust proxy', 1) // needed for secure cookies on heroku } -// Ask for username and password on production -if (env === 'production' && useAuth === 'true') { - app.use(utils.basicAuth(username, password)) -} +middleware.forEach(func => app.use(func)) // Set up App -var appViews = [ - path.join(__dirname, '/node_modules/govuk-frontend/'), - path.join(__dirname, '/node_modules/govuk-frontend/components'), +var appViews = extensions.getAppViews([ path.join(__dirname, '/app/views/'), path.join(__dirname, '/lib/') -] +]) -var nunjucksAppEnv = nunjucks.configure(appViews, { +var nunjucksConfig = { autoescape: true, - express: app, noCache: true, - watch: true -}) + watch: false // We are now setting this to `false` (it's by default false anyway) as having it set to `true` for production was making the tests hang +} + +if (env === 'development') { + nunjucksConfig.watch = true +} + +nunjucksConfig.express = app + +var nunjucksAppEnv = nunjucks.configure(appViews, nunjucksConfig) // Add Nunjucks filters utils.addNunjucksFilters(nunjucksAppEnv) @@ -111,9 +110,8 @@ app.set('view engine', 'html') // Middleware to serve static assets app.use('/public', express.static(path.join(__dirname, '/public'))) -app.use('/assets', express.static(path.join(__dirname, 'node_modules', 'govuk-frontend', 'assets'))) -// Serve govuk-frontend in /public +// Serve govuk-frontend in from node_modules (so not to break pre-extenstions prototype kits) app.use('/node_modules/govuk-frontend', express.static(path.join(__dirname, '/node_modules/govuk-frontend'))) // Set up documentation app @@ -125,12 +123,8 @@ if (useDocumentation) { path.join(__dirname, '/lib/') ] - var nunjucksDocumentationEnv = nunjucks.configure(documentationViews, { - autoescape: true, - express: documentationApp, - noCache: true, - watch: true - }) + nunjucksConfig.express = documentationApp + var nunjucksDocumentationEnv = nunjucks.configure(documentationViews, nunjucksConfig) // Nunjucks filters utils.addNunjucksFilters(nunjucksDocumentationEnv) @@ -151,13 +145,9 @@ if (useV6) { path.join(__dirname, '/app/v6/views/'), path.join(__dirname, '/lib/v6') // for old unbranded template ] + nunjucksConfig.express = v6App + var nunjucksV6Env = nunjucks.configure(v6Views, nunjucksConfig) - var nunjucksV6Env = nunjucks.configure(v6Views, { - autoescape: true, - express: v6App, - noCache: true, - watch: true - }) // Nunjucks filters utils.addNunjucksFilters(nunjucksV6Env) @@ -188,6 +178,8 @@ app.locals.cookieText = config.cookieText app.locals.promoMode = promoMode app.locals.releaseVersion = 'v' + releaseVersion app.locals.serviceName = config.serviceName +// extensionConfig sets up variables used to add the scripts and stylesheets to each page. +app.locals.extensionConfig = extensions.getAppConfig() // Session uses service name to avoid clashes with other prototypes const sessionName = 'govuk-prototype-kit-' + (Buffer.from(config.serviceName, 'utf8')).toString('hex') @@ -227,9 +219,9 @@ if (useAutoStoreData === 'true') { } // Clear all data in session if you open /prototype-admin/clear-data -app.get('/prototype-admin/clear-data', function (req, res) { +app.post('/prototype-admin/clear-data', function (req, res) { req.session.data = {} - res.render('prototype-admin/clear-data') + res.render('prototype-admin/clear-data-success') }) // Redirect root to /docs when in promo mode. @@ -350,25 +342,4 @@ app.use(function (err, req, res, next) { console.log('\nGOV.UK Prototype Kit v' + releaseVersion) console.log('\nNOTICE: the kit is for building prototypes, do not use it for production services.') -// Find a free port and start the server -utils.findAvailablePort(app, function (port) { - console.log('Listening on port ' + port + ' url: http://localhost:' + port) - if (env === 'production' || useBrowserSync === 'false') { - app.listen(port) - } else { - app.listen(port - 50, function () { - browserSync({ - proxy: 'localhost:' + (port - 50), - port: port, - ui: false, - files: ['public/**/*.*', 'app/views/**/*.*'], - ghostmode: false, - open: false, - notify: false, - logLevel: 'error' - }) - }) - } -}) - module.exports = app diff --git a/test/mocha.opts b/test/mocha.opts deleted file mode 100644 index 4a52320178..0000000000 --- a/test/mocha.opts +++ /dev/null @@ -1 +0,0 @@ ---recursive diff --git a/test/spec/sanity-checks.js b/test/spec/sanity-checks.js deleted file mode 100644 index 6773ac7dea..0000000000 --- a/test/spec/sanity-checks.js +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-env mocha */ -var request = require('supertest') -var app = require('../../server.js') -var path = require('path') -var fs = require('fs') -var assert = require('assert') - -/** - * Basic sanity checks on the dev server - */ -describe('The Prototype Kit', function () { - it('should generate assets into the /public folder', function () { - assert.doesNotThrow(function () { - fs.accessSync(path.resolve(__dirname, '../../public/javascripts/application.js')) - fs.accessSync(path.resolve(__dirname, '../../public/images/unbranded.ico')) - fs.accessSync(path.resolve(__dirname, '../../public/stylesheets/application.css')) - }) - }) - - it('should send with a well formed response for the index page', function (done) { - request(app) - .get('/') - .expect('Content-Type', /text\/html/) - .expect(200) - .end(function (err, res) { - if (err) { - done(err) - } else { - done() - } - }) - }) - - it('should send with a well formed response for the docs page', function (done) { - request(app) - .get('/docs') - .expect('Content-Type', /text\/html/) - .expect(200) - .end(function (err, res) { - if (err) { - done(err) - } else { - done() - } - }) - }) -})