From 929af9408ee2609b2fa0c67ab56685b17d15e972 Mon Sep 17 00:00:00 2001 From: Amir Alami Date: Wed, 23 Oct 2024 14:24:35 +0200 Subject: [PATCH] feat: Adds findAllComponents selector --- package-lock.json | 1 + src/core/dom.ts | 46 ++- src/core/selectors.ts | 41 ++- .../__snapshots__/documenter.test.ts.snap | 293 ++++++++++++++++++ src/core/test/dom.test.ts | 49 +++ src/core/test/selectors.test.ts | 18 ++ 6 files changed, 441 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index c52c170..1cd9e0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@cloudscape-design/test-utils-monorepo", "version": "1.0.0", + "license": "Apache-2.0", "dependencies": { "@babel/core": "^7.16.0", "@babel/plugin-syntax-decorators": "^7.16.0", diff --git a/src/core/dom.ts b/src/core/dom.ts index 3773701..ce0cabc 100644 --- a/src/core/dom.ts +++ b/src/core/dom.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 /*eslint-env browser*/ import { IElementWrapper } from './interfaces'; -import { KeyCode, isScopedSelector, substituteScope } from './utils'; +import { KeyCode, isScopedSelector, substituteScope, appendSelector } from './utils'; import { act } from './utils-dom'; // Original KeyboardEventInit lacks some properties https://github.com/Microsoft/TypeScript/issues/15228 @@ -23,6 +23,14 @@ const defaultParams = { cancelable: true, }; +interface WrapperClass { + new (element: ElementType): Wrapper; +} + +interface ComponentWrapperClass extends WrapperClass { + rootSelector: string; +} + export class AbstractWrapper implements IElementWrapper>> { @@ -120,13 +128,45 @@ export class AbstractWrapper return this.findAll(`.${className}`); } - findComponent, ElementType extends HTMLElement>( + /** + * Returns the component wrapper matching the specified selector. + * If the specified selector doesn't match any element, it returns `null`. + * + * Note: This function returns the specified component's wrapper even if the specified selector points to a different component type. + * + * @param {string} selector CSS selector + * @param {WrapperClass} ComponentClass Component's wrapper class + * @returns `Wrapper | null` + */ + findComponent( selector: string, - ComponentClass: { new (element: ElementType): Wrapper } + ComponentClass: WrapperClass ): Wrapper | null { const elementWrapper = this.find(selector); return elementWrapper ? new ComponentClass(elementWrapper.getElement()) : null; } + + /** + * Returns the wrappers of all components that match the specified component type and the specified CSS selector. + * If no CSS selector is specified, returns all of the components that match the specified component type. + * If no matching component is found, returns an empty array. + * + * @param {ComponentWrapperClass} ComponentClass Component's wrapper class + * @param {string} [selector] CSS selector + * @returns `Array` + */ + findAllComponents( + ComponentClass: ComponentWrapperClass, + selector?: string + ): Array { + const componentRootSelector = `.${ComponentClass.rootSelector}`; + const componentCombinedSelector = selector + ? appendSelector(componentRootSelector, selector) + : componentRootSelector; + + const elementWrappers = this.findAll(componentCombinedSelector); + return elementWrappers.map(wrapper => new ComponentClass(wrapper.getElement())); + } } export class ElementWrapper extends AbstractWrapper {} diff --git a/src/core/selectors.ts b/src/core/selectors.ts index 139c855..1ff43ca 100644 --- a/src/core/selectors.ts +++ b/src/core/selectors.ts @@ -8,6 +8,14 @@ const getRootSelector = (selector: string, root: string): string => { return getUnscopedClassName(rootSelector); }; +interface WrapperClass { + new (selector: string): Wrapper; +} + +interface ComponentWrapperClass extends WrapperClass { + rootSelector: string; +} + export class AbstractWrapper implements IElementWrapper> { constructor(protected root: string) {} @@ -35,13 +43,38 @@ export class AbstractWrapper implements IElementWrapper( - selector: string, - ComponentClass: { new (element: string): Wrapper } - ): Wrapper { + /** + * Returns a wrapper that matches the specified component type with the specified CSS selector. + * + * Note: This function returns the specified component's wrapper even if the specified selector points to a different component type. + * + * @param {string} selector CSS selector + * @param {WrapperClass} ComponentClass Component's wrapper class + * @returns `Wrapper` + */ + findComponent(selector: string, ComponentClass: WrapperClass): Wrapper { return new ComponentClass(this.find(selector).getElement()); } + /** + * Returns a multi-element wrapper that matches the specified component type with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches the specified component type. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ + findAllComponents( + ComponentClass: ComponentWrapperClass, + selector?: string + ): MultiElementWrapper { + const componentRootSelector = `.${ComponentClass.rootSelector}`; + const componentCombinedSelector = selector + ? appendSelector(componentRootSelector, selector) + : componentRootSelector; + const rootSelector = getRootSelector(componentCombinedSelector, this.root); + return new MultiElementWrapper(rootSelector, selector => new ComponentClass(selector)); + } + toSelector(): string { return this.root; } diff --git a/src/core/test/__snapshots__/documenter.test.ts.snap b/src/core/test/__snapshots__/documenter.test.ts.snap index 6f92415..7d405f8 100644 --- a/src/core/test/__snapshots__/documenter.test.ts.snap +++ b/src/core/test/__snapshots__/documenter.test.ts.snap @@ -78,6 +78,39 @@ Note that programmatic events ignore disabled attribute and will trigger listene "type": "reference", }, }, + { + "description": "Returns the wrappers of all components that match the specified component type and the specified CSS selector. +If no CSS selector is specified, returns all of the components that match the specified component type. +If no matching component is found, returns an empty array.", + "name": "findAllComponents", + "parameters": [ + { + "description": " +Component's wrapper class", + "flags": { + "isOptional": false, + }, + "name": "ComponentClass", + "typeName": "ComponentWrapperClass", + }, + { + "flags": { + "isOptional": true, + }, + "name": "selector", + }, + ], + "returnType": { + "name": "Array", + "type": "reference", + "typeArguments": [ + { + "name": "Wrapper", + "type": "typeParameter", + }, + ], + }, + }, { "name": "findByClassName", "parameters": [ @@ -95,9 +128,15 @@ Note that programmatic events ignore disabled attribute and will trigger listene }, }, { + "description": "Returns the component wrapper matching the specified selector. +If the specified selector doesn't match any element, it returns \`null\`. +Note: This function returns the specified component's wrapper even if the specified selector points to a different component type. +", "name": "findComponent", "parameters": [ { + "description": " +CSS selector", "flags": { "isOptional": false, }, @@ -105,10 +144,13 @@ Note that programmatic events ignore disabled attribute and will trigger listene "typeName": "string", }, { + "description": " +Component's wrapper class", "flags": { "isOptional": false, }, "name": "ComponentClass", + "typeName": "WrapperClass", }, ], "returnType": { @@ -306,6 +348,42 @@ Note that programmatic events ignore disabled attribute and will trigger listene "type": "reference", }, }, + { + "description": "Returns the wrappers of all components that match the specified component type and the specified CSS selector. +If no CSS selector is specified, returns all of the components that match the specified component type. +If no matching component is found, returns an empty array.", + "inheritedFrom": { + "name": "AbstractWrapper.findAllComponents", + }, + "name": "findAllComponents", + "parameters": [ + { + "description": " +Component's wrapper class", + "flags": { + "isOptional": false, + }, + "name": "ComponentClass", + "typeName": "ComponentWrapperClass", + }, + { + "flags": { + "isOptional": true, + }, + "name": "selector", + }, + ], + "returnType": { + "name": "Array", + "type": "reference", + "typeArguments": [ + { + "name": "Wrapper", + "type": "typeParameter", + }, + ], + }, + }, { "inheritedFrom": { "name": "AbstractWrapper.findByClassName", @@ -326,12 +404,18 @@ Note that programmatic events ignore disabled attribute and will trigger listene }, }, { + "description": "Returns the component wrapper matching the specified selector. +If the specified selector doesn't match any element, it returns \`null\`. +Note: This function returns the specified component's wrapper even if the specified selector points to a different component type. +", "inheritedFrom": { "name": "AbstractWrapper.findComponent", }, "name": "findComponent", "parameters": [ { + "description": " +CSS selector", "flags": { "isOptional": false, }, @@ -339,10 +423,13 @@ Note that programmatic events ignore disabled attribute and will trigger listene "typeName": "string", }, { + "description": " +Component's wrapper class", "flags": { "isOptional": false, }, "name": "ComponentClass", + "typeName": "WrapperClass", }, ], "returnType": { @@ -561,6 +648,42 @@ Note that programmatic events ignore disabled attribute and will trigger listene "type": "reference", }, }, + { + "description": "Returns the wrappers of all components that match the specified component type and the specified CSS selector. +If no CSS selector is specified, returns all of the components that match the specified component type. +If no matching component is found, returns an empty array.", + "inheritedFrom": { + "name": "AbstractWrapper.findAllComponents", + }, + "name": "findAllComponents", + "parameters": [ + { + "description": " +Component's wrapper class", + "flags": { + "isOptional": false, + }, + "name": "ComponentClass", + "typeName": "ComponentWrapperClass", + }, + { + "flags": { + "isOptional": true, + }, + "name": "selector", + }, + ], + "returnType": { + "name": "Array", + "type": "reference", + "typeArguments": [ + { + "name": "Wrapper", + "type": "typeParameter", + }, + ], + }, + }, { "inheritedFrom": { "name": "AbstractWrapper.findByClassName", @@ -581,12 +704,18 @@ Note that programmatic events ignore disabled attribute and will trigger listene }, }, { + "description": "Returns the component wrapper matching the specified selector. +If the specified selector doesn't match any element, it returns \`null\`. +Note: This function returns the specified component's wrapper even if the specified selector points to a different component type. +", "inheritedFrom": { "name": "AbstractWrapper.findComponent", }, "name": "findComponent", "parameters": [ { + "description": " +CSS selector", "flags": { "isOptional": false, }, @@ -594,10 +723,13 @@ Note that programmatic events ignore disabled attribute and will trigger listene "typeName": "string", }, { + "description": " +Component's wrapper class", "flags": { "isOptional": false, }, "name": "ComponentClass", + "typeName": "WrapperClass", }, ], "returnType": { @@ -792,6 +924,36 @@ exports[`documenter output > selectors 1`] = ` ], }, }, + { + "description": "Returns a multi-element wrapper that matches the specified component type with the specified CSS selector. +If no CSS selector is specified, returns a multi-element wrapper that matches the specified component type.", + "name": "findAllComponents", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "ComponentClass", + "typeName": "ComponentWrapperClass", + }, + { + "flags": { + "isOptional": true, + }, + "name": "selector", + }, + ], + "returnType": { + "name": "MultiElementWrapper", + "type": "reference", + "typeArguments": [ + { + "name": "Wrapper", + "type": "typeParameter", + }, + ], + }, + }, { "name": "findByClassName", "parameters": [ @@ -809,9 +971,14 @@ exports[`documenter output > selectors 1`] = ` }, }, { + "description": "Returns a wrapper that matches the specified component type with the specified CSS selector. +Note: This function returns the specified component's wrapper even if the specified selector points to a different component type. +", "name": "findComponent", "parameters": [ { + "description": " +CSS selector", "flags": { "isOptional": false, }, @@ -819,10 +986,13 @@ exports[`documenter output > selectors 1`] = ` "typeName": "string", }, { + "description": " +Component's wrapper class", "flags": { "isOptional": false, }, "name": "ComponentClass", + "typeName": "WrapperClass", }, ], "returnType": { @@ -936,6 +1106,39 @@ exports[`documenter output > selectors 1`] = ` ], }, }, + { + "description": "Returns a multi-element wrapper that matches the specified component type with the specified CSS selector. +If no CSS selector is specified, returns a multi-element wrapper that matches the specified component type.", + "inheritedFrom": { + "name": "AbstractWrapper.findAllComponents", + }, + "name": "findAllComponents", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "ComponentClass", + "typeName": "ComponentWrapperClass", + }, + { + "flags": { + "isOptional": true, + }, + "name": "selector", + }, + ], + "returnType": { + "name": "MultiElementWrapper", + "type": "reference", + "typeArguments": [ + { + "name": "Wrapper", + "type": "typeParameter", + }, + ], + }, + }, { "inheritedFrom": { "name": "AbstractWrapper.findByClassName", @@ -956,12 +1159,17 @@ exports[`documenter output > selectors 1`] = ` }, }, { + "description": "Returns a wrapper that matches the specified component type with the specified CSS selector. +Note: This function returns the specified component's wrapper even if the specified selector points to a different component type. +", "inheritedFrom": { "name": "AbstractWrapper.findComponent", }, "name": "findComponent", "parameters": [ { + "description": " +CSS selector", "flags": { "isOptional": false, }, @@ -969,10 +1177,13 @@ exports[`documenter output > selectors 1`] = ` "typeName": "string", }, { + "description": " +Component's wrapper class", "flags": { "isOptional": false, }, "name": "ComponentClass", + "typeName": "WrapperClass", }, ], "returnType": { @@ -1095,6 +1306,39 @@ exports[`documenter output > selectors 1`] = ` ], }, }, + { + "description": "Returns a multi-element wrapper that matches the specified component type with the specified CSS selector. +If no CSS selector is specified, returns a multi-element wrapper that matches the specified component type.", + "inheritedFrom": { + "name": "AbstractWrapper.findAllComponents", + }, + "name": "findAllComponents", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "ComponentClass", + "typeName": "ComponentWrapperClass", + }, + { + "flags": { + "isOptional": true, + }, + "name": "selector", + }, + ], + "returnType": { + "name": "MultiElementWrapper", + "type": "reference", + "typeArguments": [ + { + "name": "Wrapper", + "type": "typeParameter", + }, + ], + }, + }, { "inheritedFrom": { "name": "AbstractWrapper.findByClassName", @@ -1115,12 +1359,17 @@ exports[`documenter output > selectors 1`] = ` }, }, { + "description": "Returns a wrapper that matches the specified component type with the specified CSS selector. +Note: This function returns the specified component's wrapper even if the specified selector points to a different component type. +", "inheritedFrom": { "name": "AbstractWrapper.findComponent", }, "name": "findComponent", "parameters": [ { + "description": " +CSS selector", "flags": { "isOptional": false, }, @@ -1128,10 +1377,13 @@ exports[`documenter output > selectors 1`] = ` "typeName": "string", }, { + "description": " +Component's wrapper class", "flags": { "isOptional": false, }, "name": "ComponentClass", + "typeName": "WrapperClass", }, ], "returnType": { @@ -1254,6 +1506,39 @@ exports[`documenter output > selectors 1`] = ` ], }, }, + { + "description": "Returns a multi-element wrapper that matches the specified component type with the specified CSS selector. +If no CSS selector is specified, returns a multi-element wrapper that matches the specified component type.", + "inheritedFrom": { + "name": "AbstractWrapper.findAllComponents", + }, + "name": "findAllComponents", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "ComponentClass", + "typeName": "ComponentWrapperClass", + }, + { + "flags": { + "isOptional": true, + }, + "name": "selector", + }, + ], + "returnType": { + "name": "MultiElementWrapper", + "type": "reference", + "typeArguments": [ + { + "name": "Wrapper", + "type": "typeParameter", + }, + ], + }, + }, { "inheritedFrom": { "name": "AbstractWrapper.findByClassName", @@ -1274,12 +1559,17 @@ exports[`documenter output > selectors 1`] = ` }, }, { + "description": "Returns a wrapper that matches the specified component type with the specified CSS selector. +Note: This function returns the specified component's wrapper even if the specified selector points to a different component type. +", "inheritedFrom": { "name": "AbstractWrapper.findComponent", }, "name": "findComponent", "parameters": [ { + "description": " +CSS selector", "flags": { "isOptional": false, }, @@ -1287,10 +1577,13 @@ exports[`documenter output > selectors 1`] = ` "typeName": "string", }, { + "description": " +Component's wrapper class", "flags": { "isOptional": false, }, "name": "ComponentClass", + "typeName": "WrapperClass", }, ], "returnType": { diff --git a/src/core/test/dom.test.ts b/src/core/test/dom.test.ts index c638d03..c2ed60b 100644 --- a/src/core/test/dom.test.ts +++ b/src/core/test/dom.test.ts @@ -7,8 +7,13 @@ import { KeyCode } from '../utils'; describe('DOM test utils', () => { let node: HTMLElement, wrapper: ElementWrapper; + const CLASS_NAME = 'some-class'; + const LIST_WITH_ITEMS_CLASS_NAME = 'list-with-items'; + const LIST_WITHOUT_ITEMS_CLASS_NAME = 'list-without-items'; + const LIST_ITEM_CLASS_NAME = 'list-item'; + beforeEach(() => { // create test HTML node = document.createElement('div'); @@ -27,6 +32,17 @@ describe('DOM test utils', () => { +
    +
  • 1
  • +
  • 2
  • +
      +
    • 2.1
    • +
    +
+
    +
  • 1
  • +
  • 2
  • +
`; document.body.appendChild(node); @@ -210,7 +226,40 @@ describe('DOM test utils', () => { expect(result).toEqual(null); }); }); + + describe('findAllComponents()', () => { + class ListItemWrapper extends ComponentWrapper { + static rootSelector = LIST_ITEM_CLASS_NAME; + } + + it('returns an array of all components matching the wrapper class', () => { + const nodeWithListItemComponents = node.querySelector(`.${LIST_WITH_ITEMS_CLASS_NAME}`)!; + const wrapper = createWrapper(nodeWithListItemComponents); + const listItems = wrapper.findAllComponents(ListItemWrapper); + const listItemsContent = listItems.map(wrapper => wrapper.getElement().textContent); + + expect(listItemsContent).toEqual(['1', '2', '2.1']); + }); + + it('returns an array of all components matching the wrapper class and the specified selector', () => { + const nodeWithListItemComponents = node.querySelector(`.${LIST_WITH_ITEMS_CLASS_NAME}`)!; + const wrapper = createWrapper(nodeWithListItemComponents); + const listItems = wrapper.findAllComponents(ListItemWrapper, '.second-type'); + const listItemsContent = listItems.map(wrapper => wrapper.getElement().textContent); + + expect(listItemsContent).toEqual(['2', '2.1']); + }); + + it('returns an empty array if no component was found', () => { + const nodeWithoutListItemComponents = node.querySelector(`.${LIST_WITHOUT_ITEMS_CLASS_NAME}`)!; + const wrapper = createWrapper(nodeWithoutListItemComponents); + const listItems = wrapper.findAllComponents(ListItemWrapper, '.second-type'); + + expect(listItems).toHaveLength(0); + }); + }); }); + describe('createWrapper', () => { test('returns an ElementWrapper of the document body', () => { const actual = createWrapper().getElement(); diff --git a/src/core/test/selectors.test.ts b/src/core/test/selectors.test.ts index bf5ce2a..1ace924 100644 --- a/src/core/test/selectors.test.ts +++ b/src/core/test/selectors.test.ts @@ -11,9 +11,15 @@ class TestComponentWrapper extends ElementWrapper { findSingleChild() { return this.findComponent('awsui-child', ChildComponentWrapper); } + + findAllChildren(selector?: string) { + return this.findAllComponents(ChildComponentWrapper, selector); + } } class ChildComponentWrapper extends ElementWrapper { + static rootSelector = 'awsui-child'; + findTitle() { return this.find('.title'); } @@ -62,6 +68,18 @@ describe('CSS-selectors test utils', () => { expect(wrapper.findSingleChild().findTitle().toSelector()).toEqual('.awsui-component awsui-child .title'); }); + it('allows to find nth-child of the same type components', () => { + expect(wrapper.findAllChildren().get(2).findTitle().toSelector()).toEqual( + '.awsui-component .awsui-child:nth-child(2) .title' + ); + }); + + it('allows to find nth-child of the same type components matching the specified selector', () => { + expect(wrapper.findAllChildren('.some-class[data-some-attribute]').get(2).findTitle().toSelector()).toEqual( + '.awsui-component .awsui-child.some-class[data-some-attribute]:nth-child(2) .title' + ); + }); + it('converts css scoped selectors to wildcard classname selectors', () => { const CLASS_NAME = 'awsui_element_header_filenameHash_contentHash_3'; expect(wrapper.find(`.${CLASS_NAME}`).toSelector()).toEqual(