Skip to content

Commit

Permalink
Merge pull request #5607 from colinrotherham/custom-config-types
Browse files Browse the repository at this point in the history
Fix compiler error "does not satisfy the constraint 'ObjectNested'"
  • Loading branch information
domoscargin authored Feb 3, 2025
2 parents f896a78 + bae36c8 commit 42a1c61
Show file tree
Hide file tree
Showing 11 changed files with 121 additions and 85 deletions.
65 changes: 41 additions & 24 deletions packages/govuk-frontend/src/govuk/common/configuration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const configOverride = Symbol.for('configOverride')
* Centralises the behaviours shared by our components
*
* @virtual
* @template {ObjectNested} [ConfigurationType={}]
* @template {Partial<Record<keyof ConfigurationType, unknown>>} [ConfigurationType=ObjectNested]
* @template {Element & { dataset: DOMStringMap }} [RootElementType=HTMLElement]
* @augments GOVUKFrontendComponent<RootElementType>
*/
Expand All @@ -29,8 +29,8 @@ export class ConfigurableComponent extends GOVUKFrontendComponent {
*
* @internal
* @virtual
* @param {ObjectNested} [param] - Configuration object
* @returns {ObjectNested} return - Configuration object
* @param {Partial<ConfigurationType>} [param] - Configuration object
* @returns {Partial<ConfigurationType>} return - Configuration object
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[configOverride](param) {
Expand Down Expand Up @@ -66,7 +66,7 @@ export class ConfigurableComponent extends GOVUKFrontendComponent {
const childConstructor =
/** @type {ChildClassConstructor<ConfigurationType>} */ (this.constructor)

if (typeof childConstructor.defaults === 'undefined') {
if (!isObject(childConstructor.defaults)) {
throw new ConfigError(
formatErrorMessage(
childConstructor,
Expand Down Expand Up @@ -148,12 +148,14 @@ export function normaliseString(value, property) {
* optionally expanding nested `i18n.field`
*
* @internal
* @param {{ schema?: Schema, moduleName: string }} Component - Component class
* @template {Partial<Record<keyof ConfigurationType, unknown>>} ConfigurationType
* @template {[keyof ConfigurationType, SchemaProperty | undefined][]} SchemaEntryType
* @param {{ schema?: Schema<ConfigurationType>, moduleName: string }} Component - Component class
* @param {DOMStringMap} dataset - HTML element dataset
* @returns {ObjectNested} Normalised dataset
*/
export function normaliseDataset(Component, dataset) {
if (typeof Component.schema === 'undefined') {
if (!isObject(Component.schema)) {
throw new ConfigError(
formatErrorMessage(
Component,
Expand All @@ -162,10 +164,18 @@ export function normaliseDataset(Component, dataset) {
)
}

const out = /** @type {ReturnType<typeof normaliseDataset>} */ ({})
const out = /** @type {ObjectNested} */ ({})
const entries = /** @type {SchemaEntryType} */ (
Object.entries(Component.schema.properties)
)

// Normalise top-level dataset ('data-*') values using schema types
for (const [field, property] of Object.entries(Component.schema.properties)) {
for (const entry of entries) {
const [namespace, property] = entry

// Cast the `namespace` to string so it can be used to access the dataset
const field = namespace.toString()

if (field in dataset) {
out[field] = normaliseString(dataset[field], property)
}
Expand All @@ -175,7 +185,11 @@ export function normaliseDataset(Component, dataset) {
* {@link normaliseString} but only schema object types are allowed
*/
if (property?.type === 'object') {
out[field] = extractConfigByNamespace(Component.schema, dataset, field)
out[field] = extractConfigByNamespace(
Component.schema,
dataset,
namespace
)
}
}

Expand Down Expand Up @@ -207,7 +221,6 @@ export function mergeConfigs(...configObjects) {
// keys with object values will be merged, otherwise the new value will
// override the existing value.
if (isObject(option) && isObject(override)) {
// @ts-expect-error Index signature for type 'string' is missing
formattedConfigObject[key] = mergeConfigs(option, override)
} else {
// Apply override
Expand All @@ -228,8 +241,9 @@ export function mergeConfigs(...configObjects) {
* {@link https://ajv.js.org/packages/ajv-errors.html#single-message}
*
* @internal
* @param {Schema} schema - Config schema
* @param {{ [key: string]: unknown }} config - Component config
* @template {Partial<Record<keyof ConfigurationType, unknown>>} ConfigurationType
* @param {Schema<ConfigurationType>} schema - The schema of a component
* @param {ConfigurationType} config - Component config
* @returns {string[]} List of validation errors
*/
export function validateConfig(schema, config) {
Expand Down Expand Up @@ -262,9 +276,10 @@ export function validateConfig(schema, config) {
* object, removing the namespace in the process, normalising all values
*
* @internal
* @param {Schema} schema - The schema of a component
* @template {Partial<Record<keyof ConfigurationType, unknown>>} ConfigurationType
* @param {Schema<ConfigurationType>} schema - The schema of a component
* @param {DOMStringMap} dataset - The object to extract key-value pairs from
* @param {string} namespace - The namespace to filter keys with
* @param {keyof ConfigurationType} namespace - The namespace to filter keys with
* @returns {ObjectNested | undefined} Nested object with dot-separated key namespace removed
*/
export function extractConfigByNamespace(schema, dataset, namespace) {
Expand All @@ -276,9 +291,9 @@ export function extractConfigByNamespace(schema, dataset, namespace) {
}

// Add default empty config
const newObject = {
[namespace]: /** @type {ObjectNested} */ ({})
}
const newObject = /** @type {Record<typeof namespace, ObjectNested>} */ ({
[namespace]: {}
})

for (const [key, value] of Object.entries(dataset)) {
/** @type {ObjectNested | ObjectNested[NestedKey]} */
Expand All @@ -294,7 +309,7 @@ export function extractConfigByNamespace(schema, dataset, namespace) {
* `{ i18n: { textareaDescription: { other } } }`
*/
for (const [index, name] of keyParts.entries()) {
if (typeof current === 'object') {
if (isObject(current)) {
// Drop down to nested object until the last part
if (index < keyParts.length - 1) {
// New nested object (optionally) replaces existing value
Expand Down Expand Up @@ -324,9 +339,10 @@ export function extractConfigByNamespace(schema, dataset, namespace) {
/**
* Schema for component config
*
* @template {Partial<Record<keyof ConfigurationType, unknown>>} ConfigurationType
* @typedef {object} Schema
* @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
* @property {SchemaCondition[]} [anyOf] - List of schema conditions
* @property {Record<keyof ConfigurationType, SchemaProperty | undefined>} properties - Schema properties
* @property {SchemaCondition<ConfigurationType>[]} [anyOf] - List of schema conditions
*/

/**
Expand All @@ -339,20 +355,21 @@ export function extractConfigByNamespace(schema, dataset, namespace) {
/**
* Schema condition for component config
*
* @template {Partial<Record<keyof ConfigurationType, unknown>>} ConfigurationType
* @typedef {object} SchemaCondition
* @property {string[]} required - List of required config fields
* @property {(keyof ConfigurationType)[]} required - List of required config fields
* @property {string} errorMessage - Error message when required config fields not provided
*/

/**
* @template {ObjectNested} [ConfigurationType={}]
* @template {Partial<Record<keyof ConfigurationType, unknown>>} [ConfigurationType=ObjectNested]
* @typedef ChildClass
* @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component
* @property {Schema} [schema] - The schema of the component configuration
* @property {Schema<ConfigurationType>} [schema] - The schema of the component configuration
* @property {ConfigurationType} [defaults] - The default values of the configuration of the component
*/

/**
* @template {ObjectNested} [ConfigurationType={}]
* @template {Partial<Record<keyof ConfigurationType, unknown>>} [ConfigurationType=ObjectNested]
* @typedef {typeof GOVUKFrontendComponent & ChildClass<ConfigurationType>} ChildClassConstructor<ConfigurationType>
*/
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { extractConfigByNamespace } from '../configuration.mjs'

describe('extractConfigByNamespace', () => {
/**
* @satisfies {Schema}
* @satisfies {Schema<Config1>}
*/
const schema = {
const schema1 = {
properties: {
a: { type: 'string' },
b: { type: 'object' },
Expand All @@ -17,6 +17,17 @@ describe('extractConfigByNamespace', () => {
}
}

/**
* @satisfies {Schema<Config2>}
*/
const schema2 = {
properties: {
i18n: {
type: 'object'
}
}
}

/** @type {HTMLElement} */
let $element

Expand All @@ -40,14 +51,15 @@ describe('extractConfigByNamespace', () => {
it('defaults to empty config for known namespaces only', () => {
const { dataset } = $element

const nonObject1 = extractConfigByNamespace(schema, dataset, 'a')
const nonObject2 = extractConfigByNamespace(schema, dataset, 'd')
const nonObject3 = extractConfigByNamespace(schema, dataset, 'e')
const nonObject1 = extractConfigByNamespace(schema1, dataset, 'a')
const nonObject2 = extractConfigByNamespace(schema1, dataset, 'd')
const nonObject3 = extractConfigByNamespace(schema1, dataset, 'e')

const namespaceKnown = extractConfigByNamespace(schema, dataset, 'f')
const namespaceKnown = extractConfigByNamespace(schema1, dataset, 'f')
const namespaceUnknown = extractConfigByNamespace(
schema,
schema1,
dataset,
// @ts-expect-error - Allow unknown schema key for test
'unknown'
)

Expand All @@ -64,7 +76,7 @@ describe('extractConfigByNamespace', () => {
})

it('can extract config from key-value pairs', () => {
const result = extractConfigByNamespace(schema, $element.dataset, 'b')
const result = extractConfigByNamespace(schema1, $element.dataset, 'b')
expect(result).toEqual({ a: 'bat', e: 'bear', o: 'boar' })
})

Expand All @@ -79,15 +91,7 @@ describe('extractConfigByNamespace', () => {
`

const { dataset } = document.getElementById('app-example2')
const result = extractConfigByNamespace(
{
properties: {
i18n: { type: 'object' }
}
},
dataset,
'i18n'
)
const result = extractConfigByNamespace(schema2, dataset, 'i18n')

expect(result).toEqual({ key1: 'One', key2: 'Two', key3: 'Three' })
})
Expand All @@ -103,15 +107,7 @@ describe('extractConfigByNamespace', () => {
`

const { dataset } = document.getElementById('app-example2')
const result = extractConfigByNamespace(
{
properties: {
i18n: { type: 'object' }
}
},
dataset,
'i18n'
)
const result = extractConfigByNamespace(schema2, dataset, 'i18n')

expect(result).toEqual({ key1: 'One', key2: 'Two', key3: 'Three' })
})
Expand All @@ -132,7 +128,7 @@ describe('extractConfigByNamespace', () => {
`

const { dataset } = document.getElementById('app-example')
const result = extractConfigByNamespace(schema, dataset, 'c')
const result = extractConfigByNamespace(schema1, dataset, 'c')

expect(result).toEqual({ a: 'cat', o: 'cow' })
})
Expand All @@ -154,7 +150,7 @@ describe('extractConfigByNamespace', () => {
`

const { dataset } = document.getElementById('app-example')
const result = extractConfigByNamespace(schema, dataset, 'c')
const result = extractConfigByNamespace(schema1, dataset, 'c')

expect(result).toEqual({ a: 'cat', c: 'crow', o: 'cow' })
})
Expand All @@ -176,7 +172,7 @@ describe('extractConfigByNamespace', () => {
`

const { dataset } = document.getElementById('app-example')
const result = extractConfigByNamespace(schema, dataset, 'c')
const result = extractConfigByNamespace(schema1, dataset, 'c')

expect(result).toEqual({ a: 'cat', c: 'crow', o: 'cow' })
})
Expand All @@ -196,7 +192,7 @@ describe('extractConfigByNamespace', () => {
`

const { dataset } = document.getElementById('app-example')
const result = extractConfigByNamespace(schema, dataset, 'f')
const result = extractConfigByNamespace(schema1, dataset, 'f')

expect(result).toEqual({ e: { l: 'elephant' } })
})
Expand All @@ -211,15 +207,7 @@ describe('extractConfigByNamespace', () => {
`

const { dataset } = document.getElementById('app-example2')
const result = extractConfigByNamespace(
{
properties: {
i18n: { type: 'object' }
}
},
dataset,
'i18n'
)
const result = extractConfigByNamespace(schema2, dataset, 'i18n')

expect(result).toEqual({
key1: 'This, That',
Expand All @@ -243,15 +231,7 @@ describe('extractConfigByNamespace', () => {
`

const { dataset } = document.getElementById('app-example2')
const result = extractConfigByNamespace(
{
properties: {
i18n: { type: 'object' }
}
},
dataset,
'i18n'
)
const result = extractConfigByNamespace(schema2, dataset, 'i18n')

expect(result).toEqual({
key1: 'This, That',
Expand All @@ -260,6 +240,22 @@ describe('extractConfigByNamespace', () => {
})
})

/**
* @typedef {object} Config1
* @property {string} a - Item A
* @property {string | { a: string, e: string, o: string }} b - Item B
* @property {string | { a: string, c?: string, o: string }} c - Item C
* @property {string} d - Item D
* @property {string} [e] - Item E
* @property {{ e: string | { l: string } }} [f] - Item F
*/

/**
* @typedef {object} Config2
* @property {{ key1: string | TranslationPluralForms, key2: string | TranslationPluralForms }} i18n - Translations
*/

/**
* @import { Schema } from '../configuration.mjs'
* @import { TranslationPluralForms } from '../../i18n.mjs'
*/
Loading

0 comments on commit 42a1c61

Please sign in to comment.