Skip to content

Commit

Permalink
Refactor validation of GOV.UK Frontend support in a base class
Browse files Browse the repository at this point in the history
It's the same code for all components and the architecture we're looking to implement
so we may as well start introducing it, keeping it internal
  • Loading branch information
romaricpascal committed Aug 10, 2023
1 parent 3fa91ff commit d683de5
Show file tree
Hide file tree
Showing 15 changed files with 105 additions and 57 deletions.
14 changes: 14 additions & 0 deletions packages/govuk-frontend/src/govuk/common/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,17 @@ export function extractConfigByNamespace(configObject, namespace) {
}
return newObject
}

/**
* Checks if GOV.UK Frontend is supported on this page
*
* Some browsers will load and run our JavaScript but GOV.UK Frontend
* won't be supported.
*
* @internal
* @param {HTMLElement} [$scope] - The `<body>` element of the document to check for support
* @returns {boolean} Whether GOV.UK Frontend is supported on this page
*/
export function isSupported($scope = document.body) {
return $scope.classList.contains('govuk-frontend-supported')
}
24 changes: 23 additions & 1 deletion packages/govuk-frontend/src/govuk/common/index.unit.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { mergeConfigs, extractConfigByNamespace } from './index.mjs'
import {
mergeConfigs,
extractConfigByNamespace,
isSupported
} from './index.mjs'

describe('Common JS utilities', () => {
describe('mergeConfigs', () => {
Expand Down Expand Up @@ -112,4 +116,22 @@ describe('Common JS utilities', () => {
expect(() => extractConfigByNamespace(flattenedConfig)).toThrow()
})
})

describe.only('isSupported', () => {
beforeEach(() => {
// Jest does not tidy the JSDOM document between tests
// so we need to take care of that ourselves
document.documentElement.innerHTML = ''
})

it('returns true if the govuk-frontend-supported class is set', () => {
document.body.classList.add('govuk-frontend-supported')

expect(isSupported(document.body)).toBe(true)
})

it('returns false if the govuk-frontend-supported class is not set', () => {
expect(isSupported(document.body)).toBe(false)
})
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mergeConfigs, extractConfigByNamespace } from '../../common/index.mjs'
import { normaliseDataset } from '../../common/normalise-dataset.mjs'
import { GOVUKFrontendSupportError } from '../../errors/index.mjs'
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'
import { I18n } from '../../i18n.mjs'

/**
Expand All @@ -15,7 +15,7 @@ import { I18n } from '../../i18n.mjs'
* The state of each section is saved to the DOM via the `aria-expanded`
* attribute, which also provides accessibility.
*/
export class Accordion {
export class Accordion extends GOVUKFrontendComponent {
/** @private */
$module

Expand Down Expand Up @@ -114,9 +114,7 @@ export class Accordion {
* @param {AccordionConfig} [config] - Accordion config
*/
constructor($module, config) {
if (!document.body.classList.contains('govuk-frontend-supported')) {
throw new GOVUKFrontendSupportError()
}
super()

if (!($module instanceof HTMLElement)) {
return this
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { mergeConfigs } from '../../common/index.mjs'
import { normaliseDataset } from '../../common/normalise-dataset.mjs'
import { GOVUKFrontendSupportError } from '../../errors/index.mjs'
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'

const KEY_SPACE = 32
const DEBOUNCE_TIMEOUT_IN_SECONDS = 1

/**
* JavaScript enhancements for the Button component
*/
export class Button {
export class Button extends GOVUKFrontendComponent {
/** @private */
$module

Expand All @@ -30,9 +30,7 @@ export class Button {
* @param {ButtonConfig} [config] - Button config
*/
constructor($module, config) {
if (!document.body.classList.contains('govuk-frontend-supported')) {
throw new GOVUKFrontendSupportError()
}
super()

if (!($module instanceof HTMLElement)) {
return this
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { closestAttributeValue } from '../../common/closest-attribute-value.mjs'
import { extractConfigByNamespace, mergeConfigs } from '../../common/index.mjs'
import { normaliseDataset } from '../../common/normalise-dataset.mjs'
import { GOVUKFrontendSupportError } from '../../errors/index.mjs'
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'
import { I18n } from '../../i18n.mjs'

/**
Expand All @@ -14,7 +14,7 @@ import { I18n } from '../../i18n.mjs'
* You can configure the message to only appear after a certain percentage
* of the available characters/words has been entered.
*/
export class CharacterCount {
export class CharacterCount extends GOVUKFrontendComponent {
/** @private */
$module

Expand Down Expand Up @@ -65,9 +65,7 @@ export class CharacterCount {
* @param {CharacterCountConfig} [config] - Character count config
*/
constructor($module, config) {
if (!document.body.classList.contains('govuk-frontend-supported')) {
throw new GOVUKFrontendSupportError()
}
super()

if (!($module instanceof HTMLElement)) {
return this
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { GOVUKFrontendSupportError } from '../../errors/index.mjs'
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'

/**
* Checkboxes component
*/
export class Checkboxes {
export class Checkboxes extends GOVUKFrontendComponent {
/** @private */
$module

Expand All @@ -25,9 +25,7 @@ export class Checkboxes {
* @param {Element} $module - HTML element to use for checkboxes
*/
constructor($module) {
if (!document.body.classList.contains('govuk-frontend-supported')) {
throw new GOVUKFrontendSupportError()
}
super()

if (!($module instanceof HTMLElement)) {
return this
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { mergeConfigs } from '../../common/index.mjs'
import { normaliseDataset } from '../../common/normalise-dataset.mjs'
import { GOVUKFrontendSupportError } from '../../errors/index.mjs'
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'

/**
* Error summary component
*
* Takes focus on initialisation for accessible announcement, unless disabled in configuration.
*/
export class ErrorSummary {
export class ErrorSummary extends GOVUKFrontendComponent {
/** @private */
$module

Expand All @@ -23,9 +23,7 @@ export class ErrorSummary {
* @param {ErrorSummaryConfig} [config] - Error summary config
*/
constructor($module, config) {
if (!document.body.classList.contains('govuk-frontend-supported')) {
throw new GOVUKFrontendSupportError()
}
super()

if (!($module instanceof HTMLElement)) {
return this
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { mergeConfigs, extractConfigByNamespace } from '../../common/index.mjs'
import { normaliseDataset } from '../../common/normalise-dataset.mjs'
import { GOVUKFrontendSupportError } from '../../errors/index.mjs'
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'
import { I18n } from '../../i18n.mjs'

/**
* Exit This Page component
*/
export class ExitThisPage {
export class ExitThisPage extends GOVUKFrontendComponent {
/** @private */
$module

Expand Down Expand Up @@ -76,9 +76,7 @@ export class ExitThisPage {
* @param {ExitThisPageConfig} [config] - Exit This Page config
*/
constructor($module, config) {
if (!document.body.classList.contains('govuk-frontend-supported')) {
throw new GOVUKFrontendSupportError()
}
super()

if (!($module instanceof HTMLElement)) {
return this
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { GOVUKFrontendSupportError } from '../../errors/index.mjs'
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'

/**
* Header component
*/
export class Header {
export class Header extends GOVUKFrontendComponent {
/** @private */
$module

Expand Down Expand Up @@ -39,9 +39,7 @@ export class Header {
* @param {Element} $module - HTML element to use for header
*/
constructor($module) {
if (!document.body.classList.contains('govuk-frontend-supported')) {
throw new GOVUKFrontendSupportError()
}
super()

if (!($module instanceof HTMLElement)) {
return this
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { mergeConfigs } from '../../common/index.mjs'
import { normaliseDataset } from '../../common/normalise-dataset.mjs'
import { GOVUKFrontendSupportError } from '../../errors/index.mjs'
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'

/**
* Notification Banner component
*/
export class NotificationBanner {
export class NotificationBanner extends GOVUKFrontendComponent {
/** @private */
$module

Expand All @@ -20,9 +20,7 @@ export class NotificationBanner {
* @param {NotificationBannerConfig} [config] - Notification banner config
*/
constructor($module, config) {
if (!document.body.classList.contains('govuk-frontend-supported')) {
throw new GOVUKFrontendSupportError()
}
super()

if (!($module instanceof HTMLElement)) {
return this
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { GOVUKFrontendSupportError } from '../../errors/index.mjs'
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'

/**
* Radios component
*/
export class Radios {
export class Radios extends GOVUKFrontendComponent {
/** @private */
$module

Expand All @@ -25,9 +25,7 @@ export class Radios {
* @param {Element} $module - HTML element to use for radios
*/
constructor($module) {
if (!document.body.classList.contains('govuk-frontend-supported')) {
throw new GOVUKFrontendSupportError()
}
super()

if (!($module instanceof HTMLElement)) {
return this
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { GOVUKFrontendSupportError } from '../../errors/index.mjs'
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'

/**
* Skip link component
*/
export class SkipLink {
export class SkipLink extends GOVUKFrontendComponent {
/** @private */
$module

Expand All @@ -21,9 +21,7 @@ export class SkipLink {
* @param {Element} $module - HTML element to use for skip link
*/
constructor($module) {
if (!document.body.classList.contains('govuk-frontend-supported')) {
throw new GOVUKFrontendSupportError()
}
super()

if (!($module instanceof HTMLAnchorElement)) {
return this
Expand Down
8 changes: 3 additions & 5 deletions packages/govuk-frontend/src/govuk/components/tabs/tabs.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { GOVUKFrontendSupportError } from '../../errors/index.mjs'
import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs'

/**
* Tabs component
*/
export class Tabs {
export class Tabs extends GOVUKFrontendComponent {
/** @private */
$module

Expand Down Expand Up @@ -38,9 +38,7 @@ export class Tabs {
* @param {Element} $module - HTML element to use for tabs
*/
constructor($module) {
if (!document.body.classList.contains('govuk-frontend-supported')) {
throw new GOVUKFrontendSupportError()
}
super()

if (!($module instanceof HTMLElement)) {
return this
Expand Down
32 changes: 32 additions & 0 deletions packages/govuk-frontend/src/govuk/govuk-frontend-component.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { isSupported } from './common/index.mjs'
import { GOVUKFrontendSupportError } from './errors/index.mjs'

/**
* Base Component class
*
* Centralises the behaviours shared by our components
*
* @internal
* @abstract
*/
export class GOVUKFrontendComponent {
/**
* Constructs a new component, validating that GOV.UK Frontend is supported
*
* @internal
*/
constructor() {
this.checkSupport()
}

/**
* Validates whether GOV.UK Frontend is supported
*
* @internal
*/
checkSupport() {
if (!isSupported()) {
throw new GOVUKFrontendSupportError()
}
}
}
4 changes: 3 additions & 1 deletion packages/govuk-frontend/tasks/build/package.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,9 @@ describe('packages/govuk-frontend/dist/', () => {
for (const componentName of componentNamesWithJavaScript) {
const componentClassName = componentNameToClassName(componentName)

expect(contents).toContain(`class ${componentClassName} {`)
expect(contents).toContain(

Check failure on line 219 in packages/govuk-frontend/tasks/build/package.test.mjs

View workflow job for this annotation

GitHub Actions / Verify package build (ubuntu-latest)

packages/govuk-frontend/dist/ › Universal Module Definition (UMD) › all.bundle.js › should export each module

expect(received).toContain(expected) // indexOf Expected substring: "class Accordion extends Component {" Received string: "(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define('GOVUKFrontend', ['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GOVUKFrontend = {})); })(this, (function (exports) { 'use strict';· /* * This variable is automatically overwritten during builds and releases. * It doesn't need to be updated manually. */· /** * GOV.UK Frontend release version * * {@link https://github.com/alphagov/govuk-frontend/releases} */ const version = '4.6.0';· /** * Common helpers which do not require polyfill. * * IMPORTANT: If a helper require a polyfill, please isolate it in its own module * so that the polyfill can be properly tree-shaken and does not burden * the components that do not need that helper * * @module common/index */· /** * Config flattening function * * Takes any number of objects, flattens them into namespaced key-value pairs, * (e.g. \\{'i18n.showSection': 'Show section'\\}) and combines them together, with * greatest priority on the LAST item passed in. * * @Private * @returns {{ [key: string]: unknown }} A flattened object of key-value pairs. */ function mergeConfigs( /* configObject1, configObject2, ...configObjects */ ) { /** * Function to take nested objects and flatten them to a dot-separated keyed * object. Doing this means we don't need to do any deep/recursive merging of * each of our objects, nor transform our dataset from a flat list into a * nested object. * * @param {{ [key: string]: unknown }} configObject - Deeply nested object * @returns {{ [key: string]: unknown }} Flattened object with dot-separated keys */ const flattenObject = function flattenObject(configObject) { // Prepare an empty return object /** @type {{ [key: string]: unknown }} */ const flattenedObject = {};· /** * Our flattening function, this is called recursively for each level of * depth in the object. At each level we prepend the previous level names to * the key using `prefix`. * * @param {Partial<{ [key: string]: unknown }>} obj - Object to flatten * @param {string} [prefix] - Optional dot-separated prefix */ const flattenLoop = function flattenLoop(obj, prefix) { // Loop through keys... for (const key in obj) { // Check to see if this is a prototypical key/value, // if it is, skip it. if (!Object.prototype.hasOwnProperty.call(obj, key)) { continue; } const value = obj[key]; const prefixedKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'object') { // If the value is a nested object, recurse over that too flattenLoop(value, prefixedKey); } else { // Otherwise, add this value to our return object flattenedObject[prefixedKey] = value; } } };· // Kick off the recursive loop flattenLoop(configObject); return flattenedObject; };· // Start with an empty object as our base /** @type {{ [key: string]: unknown }} */ const formattedConfigObject = {};· // Loop through each of the remaining passed objects and push their keys // one-by-one into configObject. Any duplicate keys will override the existing // key with the new value. for (let i = 0; i < arguments.length; i++) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- Ignore mismatch between arguments types const obj = flattenObject(arguments[i]); for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { formattedConfigObject[key] = obj[key]; } } } r

Check failure on line 219 in packages/govuk-frontend/tasks/build/package.test.mjs

View workflow job for this annotation

GitHub Actions / Verify package build (windows-latest)

packages/govuk-frontend/dist/ › Universal Module Definition (UMD) › all.bundle.js › should export each module

expect(received).toContain(expected) // indexOf Expected substring: "class Accordion extends Component {" Received string: "(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define('GOVUKFrontend', ['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GOVUKFrontend = {})); })(this, (function (exports) { 'use strict';· /* * This variable is automatically overwritten during builds and releases. * It doesn't need to be updated manually. */· /** * GOV.UK Frontend release version * * {@link https://github.com/alphagov/govuk-frontend/releases} */ const version = '4.6.0';· /** * Common helpers which do not require polyfill. * * IMPORTANT: If a helper require a polyfill, please isolate it in its own module * so that the polyfill can be properly tree-shaken and does not burden * the components that do not need that helper * * @module common/index */· /** * Config flattening function * * Takes any number of objects, flattens them into namespaced key-value pairs, * (e.g. \\{'i18n.showSection': 'Show section'\\}) and combines them together, with * greatest priority on the LAST item passed in. * * @Private * @returns {{ [key: string]: unknown }} A flattened object of key-value pairs. */ function mergeConfigs( /* configObject1, configObject2, ...configObjects */ ) { /** * Function to take nested objects and flatten them to a dot-separated keyed * object. Doing this means we don't need to do any deep/recursive merging of * each of our objects, nor transform our dataset from a flat list into a * nested object. * * @param {{ [key: string]: unknown }} configObject - Deeply nested object * @returns {{ [key: string]: unknown }} Flattened object with dot-separated keys */ const flattenObject = function flattenObject(configObject) { // Prepare an empty return object /** @type {{ [key: string]: unknown }} */ const flattenedObject = {};· /** * Our flattening function, this is called recursively for each level of * depth in the object. At each level we prepend the previous level names to * the key using `prefix`. * * @param {Partial<{ [key: string]: unknown }>} obj - Object to flatten * @param {string} [prefix] - Optional dot-separated prefix */ const flattenLoop = function flattenLoop(obj, prefix) { // Loop through keys... for (const key in obj) { // Check to see if this is a prototypical key/value, // if it is, skip it. if (!Object.prototype.hasOwnProperty.call(obj, key)) { continue; } const value = obj[key]; const prefixedKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'object') { // If the value is a nested object, recurse over that too flattenLoop(value, prefixedKey); } else { // Otherwise, add this value to our return object flattenedObject[prefixedKey] = value; } } };· // Kick off the recursive loop flattenLoop(configObject); return flattenedObject; };· // Start with an empty object as our base /** @type {{ [key: string]: unknown }} */ const formattedConfigObject = {};· // Loop through each of the remaining passed objects and push their keys // one-by-one into configObject. Any duplicate keys will override the existing // key with the new value. for (let i = 0; i < arguments.length; i++) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- Ignore mismatch between arguments types const obj = flattenObject(arguments[i]); for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { formattedConfigObject[key] = obj[key]; } } } r
`class ${componentClassName} extends Component {`
)
expect(contents).toContain(
`exports.${componentClassName} = ${componentClassName};`
)
Expand Down

0 comments on commit d683de5

Please sign in to comment.