Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix compiler error "does not satisfy the constraint 'ObjectNested'" #5607

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 36 additions & 23 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,13 @@ 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
* @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 @@ -165,7 +166,12 @@ export function normaliseDataset(Component, dataset) {
const out = /** @type {ReturnType<typeof normaliseDataset>} */ ({})

// Normalise top-level dataset ('data-*') values using schema types
for (const [field, property] of Object.entries(Component.schema.properties)) {
for (const entries of /** @type {[keyof ConfigurationType, SchemaProperty | undefined][]} */ (
Object.entries(Component.schema.properties)
)) {
const [namespace, property] = entries
const field = namespace.toString()

if (field in dataset) {
out[field] = normaliseString(dataset[field], property)
}
Expand All @@ -175,7 +181,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 +217,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 +237,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 +272,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 +287,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 +305,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 +335,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 +351,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 @@ -261,5 +241,21 @@ describe('extractConfigByNamespace', () => {
})

/**
* @typedef {import('../configuration.mjs').Schema} Schema
* @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'
*/
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { normaliseDataset } from '../configuration.mjs'
import {
ConfigurableComponent,
normaliseDataset
} from '../../common/configuration.mjs'

describe('normaliseDataset', () => {
it('normalises the entire dataset', () => {
expect(
normaliseDataset(
class Component {
/**
* @augments ConfigurableComponent<Config>
*/
class Component extends ConfigurableComponent {
static moduleName = 'Component'

/**
* @satisfies {Schema}
* @satisfies {Schema<Config>}
*/
static schema = {
properties: {
Expand Down Expand Up @@ -54,5 +60,17 @@ describe('normaliseDataset', () => {
})

/**
* @typedef {import('./../configuration.mjs').Schema} Schema
* @typedef {object} Config
* @property {number} aNumber - A number
* @property {number} aDecimalNumber - A decimal number
* @property {boolean} aBoolean - A boolean
* @property {string} aString - A string
* @property {'true' | 'false'} aStringBoolean - A string boolean
* @property {string} aStringNumber - A string number
* @property {string} [anOptionalString] - An optional string
* @property {{ one: string, two: string, three: string }} anObject - An object
*/

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