From d6373e0ac689c4a9692dd1d1470ce93fdead34a8 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Sep 2024 10:12:53 +0100 Subject: [PATCH 1/2] Updated component class to TS - Created basic `Component` test - Updated and clarified Component class - Modified and added type constraints to Component class - Updated derived classes as necessary in order to ensure compilation --- .../form-group/input/lib/component.ts | 2 +- src/frontend/js/lib/component.js | 136 ------------------ src/frontend/js/lib/component.test.ts | 30 ++++ src/frontend/js/lib/component.ts | 115 +++++++++++++++ 4 files changed, 146 insertions(+), 137 deletions(-) delete mode 100644 src/frontend/js/lib/component.js create mode 100644 src/frontend/js/lib/component.test.ts create mode 100644 src/frontend/js/lib/component.ts diff --git a/src/frontend/components/form-group/input/lib/component.ts b/src/frontend/components/form-group/input/lib/component.ts index 7a4a2c9a8..26bae257d 100644 --- a/src/frontend/components/form-group/input/lib/component.ts +++ b/src/frontend/components/form-group/input/lib/component.ts @@ -19,7 +19,7 @@ class InputComponent extends Component { 'input--autocomplete': autocompleteComponent }; - constructor(element: HTMLElement | JQuery) { + constructor(element: HTMLElement) { super(element); this.initializeComponent(); this.initializeValidation(); diff --git a/src/frontend/js/lib/component.js b/src/frontend/js/lib/component.js deleted file mode 100644 index b65794ac6..000000000 --- a/src/frontend/js/lib/component.js +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Base attribute name that's set on a component that's initialized - */ -const componentInitializedAttr = 'data-component-initialized' - -/** - * The actual attribute name that's set on a component that's initialized. - * This is appended with the component name, to allow multiple different - * components to be initialized on the same element. - */ -const componentInitializedAttrName = (component_name) => { - return componentInitializedAttr + "-" + component_name; -} - -/** - * Establish whether a component has already been initialized on an element - */ -const componentIsInitialized = (element, name) => { - return element.getAttribute(componentInitializedAttrName(name)) ? true : false -} - -/** - * Default component class. - * Components should inherit this class. - */ -class Component { - - // Whether a component can be reinitialized on an element. For legacy - // reasons, the default is not to be and initialization will only be run - // once. For components that set this to true, they must cleanly handle - // such a reinitialization (returning the object but not resetting up HTML - // elements etc) - static get allowReinitialization() { return false } - - constructor(element) { - if (!(element instanceof HTMLElement)) { - throw new Error( - 'Components can only be initialized with an HTMLElement as argument to the constructor', - ) - } - - this.element = element - this.wasInitialized = componentIsInitialized(this.element, this.constructor.name) - this.element.setAttribute(componentInitializedAttrName(this.constructor.name), true) - } -} - -/** - * All registered component - */ -const registeredComponents = [] - -/** - * Register a component that can be initialized - * - * @export - * @param { Function } componentInitializer Function that will be called when component initializes - */ -const registerComponent = (componentInitializer) => { - registeredComponents.push(componentInitializer) -} - -/** - * Initialize all registered components in the defined scope - * - * @export - * @param {HTMLElement} scope The scope to initialize the components in (either - * JQuery elements or DOM). - */ -const initializeRegisteredComponents = (scope) => { - registeredComponents.forEach((componentInitializer) => { - componentInitializer(scope) - }) -} - -/** - * Get an Array of elements matching `selector` within `scope` - * - * @export - * @param {HTMLElement} scope The scope to select elements - * @param {String} selector The selector to select elements - * @returns {Array[HTMLElement]} An array of elements - */ -const getComponentElements = (scope, selector) => { - const elements = scope.querySelectorAll(selector) - if (!elements.length) { - return [] - } - - return Array.from(elements) -} - -/** - * Initialize component `Component` on all elements matching `selector` within `scope` - * Will only initialize elements that have not been initialized. - * - * @export - * @param {HTMLElement} scope The scope to initialize the objects on - * @param {String|Function} selector The selector to select elements - * @param {Component} ComponentClass The Component class to initialize - * @returns {Array[Component]} An array of initialized components - */ -const initializeComponent = (scope, selector, ComponentClass) => { - if (!(ComponentClass.prototype instanceof Component)) { - throw new Error( - 'Components can only be initialized when they inherit the basecomponent', - ) - } - - const scopes = (scope instanceof jQuery) ? scope.get() : [scope] - - const elements = scopes.flatMap( - (scope) => typeof(selector) === 'function' ? selector(scope) : getComponentElements(scope, selector) - ) - - if (!elements.length) { - return [] - } - - return elements - .filter((el) => { - return ( - ComponentClass.allowReinitialization - // See comments for allowReinitialization() - || !componentIsInitialized(el, ComponentClass.name) - ) - }).map((el) => new ComponentClass(el)) -} - -export { - Component, - initializeComponent, - initializeRegisteredComponents, - getComponentElements, - registerComponent, -} diff --git a/src/frontend/js/lib/component.test.ts b/src/frontend/js/lib/component.test.ts new file mode 100644 index 000000000..8971e480a --- /dev/null +++ b/src/frontend/js/lib/component.test.ts @@ -0,0 +1,30 @@ +import "../../testing/globals.definitions"; +import { it, expect, describe } from '@jest/globals'; +import { Component, initializeComponent } from './component'; + +class TestComponent extends Component { + constructor(element: HTMLElement) { + super(element); + element.innerText = 'I have this text now!'; + } +} + +function testComponentInitializer(scope: HTMLElement) { + return initializeComponent(scope, '.test-component', TestComponent); +} + +describe('Component Tests', ()=>{ + it('should be created', ()=>{ + const div = document.createElement('div'); + const span = document.createElement('span'); + span.classList.add('test-component'); + span.innerText = 'I shouldn\'t have this text when initialized'; + div.appendChild(span); + document.body.appendChild(div); + const component = testComponentInitializer(document.body); + expect(component.length).toBe(1); + expect(component[0]).toBeInstanceOf(TestComponent); + expect(component[0].element.innerText).toBe('I have this text now!'); + }); +}); + diff --git a/src/frontend/js/lib/component.ts b/src/frontend/js/lib/component.ts new file mode 100644 index 000000000..182f1d805 --- /dev/null +++ b/src/frontend/js/lib/component.ts @@ -0,0 +1,115 @@ +/** + * Type for a component class for use in generics + */ +type ComponentLike = { + new (element: TElement): T +} + +/** + * Base attribute name that's set on a component that's initialized + */ +const componentInitializedAttr = 'component-initialized' + +/** + * The actual attribute name that's set on a component that's initialized. + * This is appended with the component name, to allow multiple different + * components to be initialized on the same element. + */ +function componentInitializedAttrName(component_name: string) { + return `${componentInitializedAttr}-${component_name}`; +} + +/** + * Establish whether a component has already been initialized on an element + */ +function componentIsInitialized(element: HTMLElement, name: string): boolean { + return $(element).data(componentInitializedAttrName(name)) ? true : false +} + +/** + * Default component class. + * Components should inherit this class. + */ +export abstract class Component { + get wasInitialized(): boolean { + return componentIsInitialized(this.element, this.constructor.name); + } + + // Whether a component can be reinitialized on an element. For legacy + // reasons, the default is not to be and initialization will only be run + // once. For components that set this to true, they must cleanly handle + // such a reinitialization (returning the object but not resetting up HTML + // elements etc) + static allowReinitialization = false; + + constructor(public readonly element: HTMLElement) { + $(this.element).data(componentInitializedAttrName(this.constructor.name), "true") + } +} + +/** + * All registered component + */ +const registeredComponents: ((...args: any[]) => ComponentLike)[] = [] + +/** + * Register a component that can be initialized + * + * @param componentInitializer Function that will be called when component initializes + */ +export const registerComponent = (componentInitializer: (...args: any[]) => ComponentLike) => { + registeredComponents.push(componentInitializer) +} + +/** + * Initialize all registered components in the defined scope + * + * @param scope The scope to initialize the components in (either JQuery elements or DOM). + */ +export function initializeRegisteredComponents(scope: T | JQuery) { + registeredComponents.forEach((componentInitializer) => { + componentInitializer(scope); + }); +} + +/** + * Get an Array of elements matching `selector` within `scope` + * + * @param scope The scope to select elements + * @param selector The selector to select elements + * @returns An array of elements + */ +export function getComponentElements(scope: T, selector: string): Array { + const elements = scope.querySelectorAll(selector) + if (!elements.length) return []; + + return Array.from(elements).map((el) => el as T ?? el as HTMLElement); +} + +/** + * Initialize component `Component` on all elements matching `selector` within `scope` + * Will only initialize elements that have not been initialized. + * + * @param {HTMLElement} scope The scope to initialize the objects on + * @param {string|Function} selector The selector to select elements + * @param {ComponentLike} ComponentClass The Component class to initialize + * @returns {Array[T]} An array of initialized components + */ +export const initializeComponent = (scope: HTMLElement | JQuery, selector: string | Function, ComponentClass: ComponentLike): T[] => { + const scopes = $(scope).get(); + + const elements = scopes.flatMap( + (scope) => typeof (selector) === 'function' ? selector(scope) : getComponentElements(scope, selector) + ) + + if (!elements.length) return [] + + return elements + .filter((el) => { + return ( + ComponentClass.prototype.allowReinitialization + // See comments for allowReinitialization() + || !componentIsInitialized(el, ComponentClass.name) + ) + }).map((el) => new ComponentClass(el)) +} From 0f274ffe3ae7ff1d4973c1b529fc3a4c513421e7 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Sep 2024 10:50:06 +0100 Subject: [PATCH 2/2] Updated generics in component class --- src/frontend/js/lib/component.ts | 47 ++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/frontend/js/lib/component.ts b/src/frontend/js/lib/component.ts index 182f1d805..b4ba7fe38 100644 --- a/src/frontend/js/lib/component.ts +++ b/src/frontend/js/lib/component.ts @@ -14,6 +14,8 @@ const componentInitializedAttr = 'component-initialized' * The actual attribute name that's set on a component that's initialized. * This is appended with the component name, to allow multiple different * components to be initialized on the same element. + * @param component_name The name of the component + * @returns The attribute name */ function componentInitializedAttrName(component_name: string) { return `${componentInitializedAttr}-${component_name}`; @@ -21,16 +23,20 @@ function componentInitializedAttrName(component_name: string) { /** * Establish whether a component has already been initialized on an element + * @param element The element to check + * @param name The name of the component + * @returns Whether the component has been initialized */ -function componentIsInitialized(element: HTMLElement, name: string): boolean { +function componentIsInitialized(element: TElement, name: string): boolean { return $(element).data(componentInitializedAttrName(name)) ? true : false } /** * Default component class. * Components should inherit this class. + * @template TElement The type of the element that the component is attached to */ -export abstract class Component { +export abstract class Component { get wasInitialized(): boolean { return componentIsInitialized(this.element, this.constructor.name); } @@ -42,7 +48,11 @@ export abstract class Component { // elements etc) static allowReinitialization = false; - constructor(public readonly element: HTMLElement) { + /** + * Create a new component + * @param element The element to attach the component to + */ + constructor(public readonly element: TElement) { $(this.element).data(componentInitializedAttrName(this.constructor.name), "true") } } @@ -50,23 +60,23 @@ export abstract class Component { /** * All registered component */ -const registeredComponents: ((...args: any[]) => ComponentLike)[] = [] +const registeredComponents: ((...args: any[]) => ComponentLike)[] = [] /** * Register a component that can be initialized - * - * @param componentInitializer Function that will be called when component initializes + * @template TComponent The type of the component + * @param componentInitializer Function that will be called to initialize the component */ -export const registerComponent = (componentInitializer: (...args: any[]) => ComponentLike) => { +export function registerComponent(componentInitializer: (...args: any[]) => ComponentLike) { registeredComponents.push(componentInitializer) } /** * Initialize all registered components in the defined scope - * + * @template TElement The type of the component * @param scope The scope to initialize the components in (either JQuery elements or DOM). */ -export function initializeRegisteredComponents(scope: T | JQuery) { +export function initializeRegisteredComponents(scope: TElement | JQuery) { registeredComponents.forEach((componentInitializer) => { componentInitializer(scope); }); @@ -74,28 +84,29 @@ export function initializeRegisteredComponents(scope: T, selector: string): Array { +export function getComponentElements(scope: TElement, selector: string): Array { const elements = scope.querySelectorAll(selector) if (!elements.length) return []; - return Array.from(elements).map((el) => el as T ?? el as HTMLElement); + return Array.from(elements).map((el) => el as TElement ?? el as HTMLElement); // I prefer to ensure that we return something, even though we should never get here } /** * Initialize component `Component` on all elements matching `selector` within `scope` * Will only initialize elements that have not been initialized. - * - * @param {HTMLElement} scope The scope to initialize the objects on - * @param {string|Function} selector The selector to select elements - * @param {ComponentLike} ComponentClass The Component class to initialize - * @returns {Array[T]} An array of initialized components + * @template TComponent The type of the component + * @template TElement The type of the element + * @param scope The scope to initialize the objects on + * @param selector The selector to select elements + * @param ComponentClass The Component class to initialize + * @returns An array of initialized components */ -export const initializeComponent = (scope: HTMLElement | JQuery, selector: string | Function, ComponentClass: ComponentLike): T[] => { +export function initializeComponent(scope: TElement | JQuery, selector: string | ((...params: any[]) => any), ComponentClass: ComponentLike): TComponent[] { const scopes = $(scope).get(); const elements = scopes.flatMap(