<|start_filename|>src/frontend/javascript/event-tracking.js<|end_filename|>
/* istanbul ignore file */

function EventTracking () {
  this.$bodyElement = document.querySelector('body')
  this.$mainHeading = this.$bodyElement.querySelector('h1')
 }

EventTracking.prototype.init = function() {

  this.$bodyElement.addEventListener('click', this.handleClickEvent.bind(this))
  this.checkForErrorPage(this.$mainHeading)
}

EventTracking.prototype.handleClickEvent = function (e) {
  var target = e.target,
      isLink = e.target.nodeName === 'A',
      // external link: starts with http(s) or mailto and does not contain a "service.gov" in url
      externalLinkRegex = /^(?=.*(http|mailto))(?:(?!cloud\.service\.gov\.uk).)*$/g,
      isExternalLink = isLink && externalLinkRegex.test(target.getAttribute('href')),
      // we have data tracking attributes on some elements
      isManuallyTargetedElement = target.hasAttribute('data-track-click') 
        && target.getAttribute('data-track-click').indexOf('true') > -1

  // therem ight be an edge case where a manually tagretted link could also be an external link
  // and we only wan't to fire one function in such a case
  // hence the logic below
  if (isExternalLink && isManuallyTargetedElement) {
    this.trackTargettedLinkClick(target)
  }
  
  if (!isExternalLink && isManuallyTargetedElement) {
    this.trackTargettedLinkClick(target)
  }

  if (isExternalLink && !isManuallyTargetedElement) {
    this.trackExternalLinkClick(target)
  }
}

EventTracking.prototype.trackTargettedLinkClick = function (element) {
  var eventParams = {},
    category = element.getAttribute('data-track-category'),
    action = element.getAttribute('data-track-action'),
    label = element.getAttribute('data-track-label')

  if (label) {
    eventParams.event_label = label
  }

  if (category) {
    eventParams.event_category = category
  }
  
  this.sendEvent(action, eventParams)
}

EventTracking.prototype.trackExternalLinkClick = function (element) {

  var eventParams = {},
      action = element.textContent

  eventParams.event_category = 'External Link Clicked'
  eventParams.event_label = element.getAttribute('href')
  
  this.sendEvent(action, eventParams)
}

EventTracking.prototype.errorPageEvent = function (errorStatusCode) {

  var eventParams = {},
      action = errorStatusCode

  eventParams.event_category = 'Error'
  
  this.sendEvent(action, eventParams)
}

EventTracking.prototype.sendEvent = function (action, options) {
  window.dataLayer = window.dataLayer || []
  var gtag = function () {
    dataLayer.push(arguments)
  }

  gtag('event', action, options)
}

EventTracking.prototype.checkForErrorPage = function(identifier) {
  // GA needs strings for status code as event labels
  var errorPagesArray = [
    {
      title: 'Page not authorised',
      statusCode: '403'
    },
    {
      title: 'Page not found',
      statusCode: '404'
    },
    {
      title: 'Sorry an error occurred',
      statusCode: '500'
    }
  ]
  if (!identifier) return
  
  var errorPageData  = errorPagesArray.filter(function(o){
    return o.title === identifier.textContent; 
  });
  
  errorPageData[0] ? this.errorPageEvent(errorPageData[0].statusCode) : null;
}

export default EventTracking


<|start_filename|>config/webpack.config.js<|end_filename|>
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const webpack = require('webpack');
const nodeModules = require('webpack-node-externals');

const enableServer = require('./server.config');

const NODE_ENV = process.env.NODE_ENV || 'development';
const assetName = ext => `assets/[contenthash:base32].[name]${ext || '[ext]'}`;

let cfg = {

  target: 'node',

  mode: NODE_ENV,

  entry: {
    main: ['./src/main.ts'],
    init: {import: './src/frontend/javascript/init.js', filename: 'assets/init.js'},
    sankey: {import: './src/frontend/javascript/sankey.js', filename: 'assets/sankey.js'}
  },

  output: {
    publicPath: '/',
  },

  devtool: 'source-map',

  externalsPresets: { node: true },
  externals: ['chokidar'],

  stats: {
    modules: false,
    warningsFilter: [
      // Express uses a dynamic require in view.js but we don't care
      /node_modules\/express\/lib\/view\.js/,
    ],
  },

  optimization: {
    minimizer: [
      new CssMinimizerPlugin(),
    ],
    splitChunks: false,
    runtimeChunk: false,
  },

  performance: {
    hints: false,
  },

  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.json'],
  },

  module: {
    rules: [
      {
        test: /\.(png|jpg|ico|svg|gif)$/,
        type: 'asset/resource',
        generator: {
          filename: assetName()
        }
      },
      {
        test: /\.(scss)$/,
        use: [
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: {
              url: false,
            },
          },
          {
            loader: 'sass-loader',
            options: {
              sassOptions: {
                includePaths: [
                  path.resolve(__dirname, '../node_modules'),
                ],
              },
            },
          },
        ],
      },
      {
        test: /\.(ts(x?))/,
        use: [
          {
            loader: 'ts-loader',
            options: {
              reportFiles: [/(?<!\.test)\.ts(x?)/],
            },
          },
        ],
      },
      {
        test: /\.pegjs$/,
        use: [
          {
            loader: 'pegjs-loader',
          },
        ],
      },
    ],
  },

  plugins: [
    new webpack.EnvironmentPlugin({ NODE_ENV }),
    new CompressionPlugin({
      test: /\.(js|svg|css)$/,
      include: 'assets/',
      deleteOriginalAssets: 'keep-source-map',
    }),
    new MiniCssExtractPlugin({
      filename: 'assets/govuk.screen.css'
    })
  ],
};

if (process.env.ENABLE_WATCH === 'true') {
  cfg.watch = true; //possibly depricated by webpack 5 https://webpack.js.org/migrate/5/#clean-up-configuration
}

if (process.env.ENABLE_SERVER === 'true') {
  cfg = enableServer(cfg);
}

if (NODE_ENV === 'production') {
  cfg.devtool = false;
  cfg.plugins.push(new CssMinimizerPlugin({
    minimizerOptions: {
      preset: [
        'default',
        {
          discardComments: { removeAll: true },
        },
      ],
    },
  }));
}

cfg.externals.push(nodeModules({ allowlist: [] }));

module.exports = cfg;


<|start_filename|>src/frontend/javascript/cookie-functions.js<|end_filename|>
/* istanbul ignore file */

function Cookies () {
    this.cookieName = 'govuk-paas-cookie-policy'
    this.cookieDomain = 'cloud.service.gov.uk'
    this.cookieDuration = 365
    this.trackingId = 'UA-43115970-5'
    // disable tracking by default
    window['ga-disable-' + this.trackingId] = true;
}

Cookies.prototype.hasConsentForAnalytics = function () {
  var consentCookie = JSON.parse(this.getCookie(this.cookieName));
  return consentCookie ? consentCookie.analytics : false
}

Cookies.prototype.initAnalytics = function() {

  // guard against being called more than once
  if (!('GoogleAnalyticsObject' in window)) {

    window['ga-disable-' + this.trackingId] = false;

    // Load GTM
    this.loadGtmScript()
    this.setupGtm()
  }
}

Cookies.prototype.initCookieBanner = function ($module) {
  this.$module = $module

  if (!this.$module) {
    return
  }

  this.$cookieBannerMainContent = document.querySelector('.js-cookie-content')
  this.$cookieBannerAcceptMessage = document.querySelector('.js-cookie-accept-content')
  this.$cookieBannerRejectMessage = document.querySelector('.js-cookie-reject-content')
  this.$module.hideCookieMessage = this.hideCookieMessage.bind(this)
  this.$module.showBannerConfirmationMessage = this.showBannerConfirmationMessage.bind(this)
  this.$module.setBannerCookieConsent = this.setBannerCookieConsent.bind(this)

  this.hideLinksArray = this.$module.querySelectorAll('button[data-hide-cookie-banner]');

  for (var i = 0; i < this.hideLinksArray.length; i++) {
    this.hideLinksArray[i].addEventListener('click', this.$module.hideCookieMessage);
  }

  this.$acceptCookiesLink = this.$module.querySelector('button[data-accept-cookies=true]');
  if (this.$acceptCookiesLink) {
    this.$acceptCookiesLink.addEventListener('click', function() {
      this.$module.setBannerCookieConsent(true);
      this.setFocus(this.$cookieBannerAcceptMessage)
    }.bind(this));
  }

  this.$rejectCookiesLink = this.$module.querySelector('button[data-accept-cookies=false]');
  if (this.$rejectCookiesLink) {
    this.$rejectCookiesLink.addEventListener('click', function() {
      this.$module.setBannerCookieConsent(false);
      this.setFocus(this.$cookieBannerRejectMessage)
    }.bind(this));
  }

  this.showCookieBanner()
}

Cookies.prototype.showCookieBanner = function () {
  // Show the cookie banner if there is no cookie set
  var hasCookiesPolicy = this.getCookie(this.cookieName)
  if (this.$module && !hasCookiesPolicy) {
    this.$module.style.display = 'block'
  } else {
    this.$module.style.display = 'none'
  }
}

Cookies.prototype.setBannerCookieConsent = function (analyticsConsent) {
  this.setCookie(this.cookieName, JSON.stringify({ 'analytics': analyticsConsent }), {days: this.cookieDuration})

  this.$module.showBannerConfirmationMessage(analyticsConsent)

  if (analyticsConsent) { 
    this.initAnalytics()
  }
}

Cookies.prototype.hideCookieMessage = function (event) {
  if (this.$module) {
    this.$module.style.display = 'none'
  }

  if (event.target) {
    event.preventDefault()
  }
}

Cookies.prototype.showBannerConfirmationMessage = function (analyticsConsent) {

  analyticsConsent ? this.$cookieBannerAcceptMessage.removeAttribute('hidden') : this.$cookieBannerRejectMessage.removeAttribute('hidden')
  this.$cookieBannerMainContent.setAttribute('hidden', '')
};

Cookies.prototype.setFocus = function (target) {
  target.setAttribute('tabindex', '-1')
  target.focus()
}

Cookies.prototype.getCookie = function (name) {
  var nameEQ = name + '='
  var cookies = document.cookie.split(';')
  for (var i = 0, len = cookies.length; i < len; i++) {
    var cookie = cookies[i]
    while (cookie.charAt(0) === ' ') {
      cookie = cookie.substring(1, cookie.length)
    }
    if (cookie.indexOf(nameEQ) === 0) {
      return decodeURIComponent(cookie.substring(nameEQ.length))
    }
  }
  return null
}

Cookies.prototype.setCookie = function (name, values, options) {
  if (typeof options === 'undefined') {
    options = {}
  }

  var cookieString = name + '=' + values
  if (options.days) {
    var date = new Date()
    date.setTime(date.getTime() + (options.days * 24 * 60 * 60 * 1000))
    cookieString = cookieString + '; expires=' + date.toGMTString() + ';domain=' + this.cookieDomain + '; path=/'
  }

  if (document.location.protocol === 'https:') {
    cookieString = cookieString + '; Secure';
  }

  document.cookie = cookieString
}

// GA analytics functions

Cookies.prototype.setupGtm = function () {
  // Pull dimensions vals from meta ; else all script/origin combinations have to be in the CSP	
  window.dataLayer = window.dataLayer || [];	
  function gtag(){dataLayer.push(arguments);}	
  gtag('js', new Date());	

  var config = {
    cookie_expires: this.cookieDuration * 24 * 60 * 60,
    page_path: this.PIIfy(window.location.pathname),
    // Paas-admin gets a relatively small number	
    // of visits daily, so the default site speed	
    // sample rate of 1% gives us too few data points.	
    // Settings it to 30% gives us more data.	
    siteSpeedSampleRate: 30,
    anonymize_ip: true,
    linker: {
      domains: [
        'cloud.service.gov.uk',
        'admin.cloud.service.gov.uk',
        'admin.london.cloud.service.gov.uk', 
        'docs.cloud.service.gov.uk'
      ]
    }
  };	

  var originTag = document.querySelector('meta[name="x-user-identity-origin"]')
  if (originTag && originTag.content) {	
    config['dimension1'] = originTag.content;	
  }	

  gtag('config', this.trackingId, config);
}

Cookies.prototype.loadGtmScript = function () {
  var gtmScriptTag = document.createElement("script");
  gtmScriptTag.type = "text/javascript"
  gtmScriptTag.setAttribute("async", "true")
  gtmScriptTag.setAttribute("src", "https://www.googletagmanager.com/gtag/js?id=" + this.trackingId)
  document.documentElement.firstChild.appendChild(gtmScriptTag)
}

Cookies.prototype.PIIfy = function (string) {

  var strippedString
  var EMAIL_PATTERN = /[^\s=/?&]+(?:@|%40)[^\s=/?&]+/g
  var UUID_PATTERN = /[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}/g
  strippedString = string
    .replace(
      UUID_PATTERN, '…'
    ).replace(
      EMAIL_PATTERN, '[email]'
    )

  return strippedString
}

export default Cookies


<|start_filename|>src/frontend/javascript/event-tracking.test.js<|end_filename|>
/**
 * @jest-environment jsdom
 */

import EventTracking from './event-tracking'

const eventTracking = new EventTracking()

describe("link event tracking", () => {
  beforeEach(() => {
    global.dataLayer = []
    eventTracking.init()
  })

  afterEach(() => {
    document.body.innerHTML = ''
  })

  it("if a link has data tracking attributes, those values should be used in push to dataLayer", () => {

    document.body.innerHTML = `
    <a href="/test"
      data-testid="data-attribute-tracking"
      data-track-click="true" 
      data-track-category="Test category 1" 
      data-track-action="Test action 1" 
      data-track-label="Test label 1">
        Link with data tracking attributes
    </a>
    `
    document.querySelector('[data-testid="data-attribute-tracking"').click()
    
    expect(global.dataLayer[0]).toEqual(
      expect.objectContaining({
        '0': 'event',
        '1': 'Test action 1',
        '2': { event_label: 'Test label 1', event_category: 'Test category 1' }
      })
    )
  })

  it("if a link has data tracking attributes but is also an external link, data attribute values should be used in push to dataLayer", () => {

    document.body.innerHTML = `
    <a href="https://example.com"
      data-testid="external-with-data-attribute-tracking"
      data-track-click="true" 
      data-track-category="Test category 2" 
      data-track-action="Test action 2" 
      data-track-label="Test label 2">
        External link with data tracking attributes
    </a>
    `

    document.querySelector('[data-testid="external-with-data-attribute-tracking"]').click()

    expect(global.dataLayer[0]).toEqual(
      expect.objectContaining({
        '0': 'event',
        '1': 'Test action 2',
        '2': { event_label: 'Test label 2', event_category: 'Test category 2' }
      })
    )
  })

  it("if a link is an external link only, values set in 'trackExternalLinkClick' function should be used in push to dataLayer", () => {

    document.body.innerHTML = `<a href="https://www.example.com" data-testid="external">External link</a>`

    document.querySelector('[data-testid="external"]').click()

    expect(global.dataLayer[0]).toEqual(
      expect.objectContaining({
        '0': 'event',
        '1': 'External link',
        '2': { event_category: 'External Link Clicked', event_label: 'https://www.example.com' }
      })
    )
  })
})

describe("page events", () => {
  it("if a page is a 403 page, values set in the 'errorPageEvent' function should be used in push to dataLayer", () => {
    document.body.innerHTML = `
      <h1>Page not authorised</h1>
    `
    global.dataLayer = []
    const eventTracking = new EventTracking()
    eventTracking.init()
    
    expect(global.dataLayer[0]).toEqual(
      expect.objectContaining({
        '0': 'event',
        '1': '403',
        '2': { event_category: 'Error' }
      })
    )
  })

  it("if a page is a 404 page, values set in the 'errorPageEvent' function should be used in push to dataLayer", () => {
    document.body.innerHTML = `
      <h1>Sorry an error occurred</h1>
    `
    global.dataLayer = []
    const eventTracking = new EventTracking()
    eventTracking.init()
    
    expect(global.dataLayer[0]).toEqual(
      expect.objectContaining({
        '0': 'event',
        '1': '500',
        '2': { event_category: 'Error' }
      })
    )
  })
  it("if a page is a 4500 page, values set in the 'errorPageEvent' function should be used in push to dataLayer", () => {
    document.body.innerHTML = `
      <h1>Page not found</h1>
    `
    global.dataLayer = []
    const eventTracking = new EventTracking()
    eventTracking.init()
    
    expect(global.dataLayer[0]).toEqual(
      expect.objectContaining({
        '0': 'event',
        '1': '404',
        '2': { event_category: 'Error' }
      })
    )
  })
})
