Skip to content
Merged
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
20 changes: 18 additions & 2 deletions src/converter/generate-component-finders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ ElementWrapper.prototype.findAll${pluralName} = function(selector) {
return this.findAllComponents(${wrapperName}, selector);
};`;

const componentClosestFinder = ({ name, wrapperName }: ComponentWrapperMetadata) => `
ElementWrapper.prototype.findClosest${name} = function() {
// casting to 'any' is needed to avoid this issue with generics
// https://github.com/microsoft/TypeScript/issues/29132
return (this as any).findClosestComponent(${wrapperName});
};`;

const componentFindersInterfaces = {
dom: ({ name, pluralName, wrapperName }: ComponentWrapperMetadata) => `
/**
Expand All @@ -43,7 +50,16 @@ find${name}(selector?: string): ${wrapperName} | null;
* @param {string} [selector] CSS Selector
* @returns {Array<${wrapperName}>}
*/
findAll${pluralName}(selector?: string): Array<${wrapperName}>;`,
findAll${pluralName}(selector?: string): Array<${wrapperName}>;

/**
* Returns the wrapper of the closest parent ${name} for the current element,
* or the element itself if it is an instance of ${name}.
* If no ${name} is found, returns \`null\`.
*
* @returns {${wrapperName} | null}
*/
findClosest${name}(): ${wrapperName} | null;`,

selectors: ({ name, pluralName, wrapperName }: ComponentWrapperMetadata) => `
/**
Expand Down Expand Up @@ -103,6 +119,6 @@ declare module '@cloudscape-design/test-utils-core/dist/${testUtilType}' {
}

${components.map(componentFinders).join('')}

${testUtilType === 'dom' ? components.map(componentClosestFinder).join('') : ''}
${defaultExport[testUtilType]}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ findTestComponentA(selector?: string): TestComponentAWrapper | null;
* @returns {Array<TestComponentAWrapper>}
*/
findAllTestComponentAs(selector?: string): Array<TestComponentAWrapper>;

/**
* Returns the wrapper of the closest parent TestComponentA for the current element,
* or the element itself if it is an instance of TestComponentA.
* If no TestComponentA is found, returns \`null\`.
*
* @returns {TestComponentAWrapper | null}
*/
findClosestTestComponentA(): TestComponentAWrapper | null;
/**
* Returns the wrapper of the first TestComponentB that matches the specified CSS selector.
* If no CSS selector is specified, returns the wrapper of the first TestComponentB.
Expand All @@ -57,6 +66,15 @@ findTestComponentB(selector?: string): TestComponentBWrapper | null;
* @returns {Array<TestComponentBWrapper>}
*/
findAllTestComponentBs(selector?: string): Array<TestComponentBWrapper>;

/**
* Returns the wrapper of the closest parent TestComponentB for the current element,
* or the element itself if it is an instance of TestComponentB.
* If no TestComponentB is found, returns \`null\`.
*
* @returns {TestComponentBWrapper | null}
*/
findClosestTestComponentB(): TestComponentBWrapper | null;
}
}

Expand Down Expand Up @@ -88,6 +106,16 @@ ElementWrapper.prototype.findAllTestComponentBs = function(selector) {
return this.findAllComponents(TestComponentBWrapper, selector);
};

ElementWrapper.prototype.findClosestTestComponentA = function() {
// casting to 'any' is needed to avoid this issue with generics
// https://github.com/microsoft/TypeScript/issues/29132
return (this as any).findClosestComponent(TestComponentAWrapper);
};
ElementWrapper.prototype.findClosestTestComponentB = function() {
// casting to 'any' is needed to avoid this issue with generics
// https://github.com/microsoft/TypeScript/issues/29132
return (this as any).findClosestComponent(TestComponentBWrapper);
};

export default function wrapper(root: Element = document.body) {
if (document && document.body && !document.body.contains(root)) {
Expand Down
6 changes: 6 additions & 0 deletions src/converter/test/generate-component-finders.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ describe(`${generateComponentFinders.name}`, () => {
if (testUtilType === 'dom') {
expect(sourceFileContent).toMatch(`findAlert(selector?: string): AlertWrapper | null`);
expect(sourceFileContent).toMatch(`findAllAlerts(selector?: string): Array<AlertWrapper>`);
expect(sourceFileContent).toMatch(`findClosestAlert(): AlertWrapper | null`);
expect(sourceFileContent).toMatch(`findStatus(selector?: string): StatusWrapper | null`);
expect(sourceFileContent).toMatch(`findAllStatus(selector?: string): Array<StatusWrapper>`);
expect(sourceFileContent).toMatch(`findClosestStatus(): StatusWrapper | null`);
} else {
expect(sourceFileContent).toMatch(`findAlert(selector?: string): AlertWrapper`);
expect(sourceFileContent).toMatch(`findAllAlerts(selector?: string): MultiElementWrapper<AlertWrapper>`);
Expand All @@ -57,6 +59,10 @@ describe(`${generateComponentFinders.name}`, () => {
expect(sourceFileContent).toMatch('ElementWrapper.prototype.findAllAlerts = function(selector)');
expect(sourceFileContent).toMatch('ElementWrapper.prototype.findStatus = function(selector)');
expect(sourceFileContent).toMatch('ElementWrapper.prototype.findAllStatus = function(selector)');
if (testUtilType === 'dom') {
expect(sourceFileContent).toMatch('ElementWrapper.prototype.findClosestAlert = function()');
expect(sourceFileContent).toMatch('ElementWrapper.prototype.findClosestStatus = function()');
}
});

test('it exports the wrapper creator', () => {
Expand Down
22 changes: 17 additions & 5 deletions src/core/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
/*eslint-env browser*/
import { IElementWrapper } from './interfaces';
import { KeyCode, isScopedSelector, substituteScope, appendSelector } from './utils';
import { KeyCode, isScopedSelector, substituteScope, appendSelector, getComponentRootSelector } from './utils';
import { act } from './utils-dom';

// Original KeyboardEventInit lacks some properties https://github.com/Microsoft/TypeScript/issues/15228
Expand Down Expand Up @@ -172,17 +172,29 @@ export class AbstractWrapper<ElementType extends Element>
ComponentClass: ComponentWrapperClass<Wrapper, ElementType>,
selector?: string,
): Array<Wrapper> {
let componentRootSelector = `.${ComponentClass.rootSelector}`;
if ('legacyRootSelector' in ComponentClass && ComponentClass.legacyRootSelector) {
componentRootSelector = `:is(.${ComponentClass.rootSelector}, .${ComponentClass.legacyRootSelector})`;
}
const componentRootSelector = getComponentRootSelector(ComponentClass);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deduplicated this logic which exists for both dom and selectors

const componentCombinedSelector = selector
? appendSelector(componentRootSelector, selector)
: componentRootSelector;

const elementWrappers = this.findAll<ElementType>(componentCombinedSelector);
return elementWrappers.map(wrapper => new ComponentClass(wrapper.getElement()));
}

/**
* Returns the closest ancestor element (or self) that matches the specified component type.
* If no matching component is found, returns `null`.
*
* @param {ComponentWrapperClass} ComponentClass Component's wrapper class
* @returns `Wrapper | null`
*/
findClosestComponent<Wrapper extends ComponentWrapper, ElementType extends HTMLElement>(
ComponentClass: ComponentWrapperClass<Wrapper, ElementType>,
): Wrapper | null {
const componentRootSelector = getComponentRootSelector(ComponentClass);
const closestElement = this.element.closest<ElementType>(componentRootSelector);
return closestElement ? new ComponentClass(closestElement) : null;
}
}

export class ElementWrapper<ElementType extends Element = HTMLElement> extends AbstractWrapper<ElementType> {}
Expand Down
13 changes: 8 additions & 5 deletions src/core/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { IElementWrapper } from './interfaces';
import { appendSelector, isScopedSelector, substituteScope, getUnscopedClassName } from './utils';
import {
appendSelector,
isScopedSelector,
substituteScope,
getUnscopedClassName,
getComponentRootSelector,
} from './utils';

const getRootSelector = (selector: string, root: string): string => {
const rootSelector = isScopedSelector(selector) ? substituteScope(selector, root) : `${root} ${selector}`;
Expand Down Expand Up @@ -72,10 +78,7 @@ export class AbstractWrapper implements IElementWrapper<string, MultiElementWrap
ComponentClass: ComponentWrapperClass<Wrapper>,
selector?: string,
): MultiElementWrapper<Wrapper> {
let componentRootSelector = `.${ComponentClass.rootSelector}`;
if ('legacyRootSelector' in ComponentClass && ComponentClass.legacyRootSelector) {
componentRootSelector = `:is(.${ComponentClass.rootSelector}, .${ComponentClass.legacyRootSelector})`;
}
const componentRootSelector = getComponentRootSelector(ComponentClass);
const componentCombinedSelector = selector
? appendSelector(componentRootSelector, selector)
: componentRootSelector;
Expand Down
63 changes: 63 additions & 0 deletions src/core/test/__snapshots__/documenter.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,25 @@ If no matching component is found, returns an empty array.",
],
},
},
{
"description": "Returns the closest ancestor element (or self) that matches the specified component type.
If no matching component is found, returns \`null\`.",
"name": "findClosestComponent",
"parameters": [
{
"description": "Component's wrapper class",
"flags": {
"isOptional": false,
},
"name": "ComponentClass",
"typeName": "ComponentWrapperClass<Wrapper, ElementType>",
},
],
"returnType": {
"isNullable": true,
"name": "Wrapper",
},
},
{
"description": "Returns the component wrapper matching the specified selector.
If the specified selector doesn't match any element, it returns \`null\`.
Expand Down Expand Up @@ -535,6 +554,28 @@ If no matching component is found, returns an empty array.",
],
},
},
{
"description": "Returns the closest ancestor element (or self) that matches the specified component type.
If no matching component is found, returns \`null\`.",
"inheritedFrom": {
"name": "AbstractWrapper.findClosestComponent",
},
"name": "findClosestComponent",
"parameters": [
{
"description": "Component's wrapper class",
"flags": {
"isOptional": false,
},
"name": "ComponentClass",
"typeName": "ComponentWrapperClass<Wrapper, ElementType>",
},
],
"returnType": {
"isNullable": true,
"name": "Wrapper",
},
},
{
"description": "Returns the component wrapper matching the specified selector.
If the specified selector doesn't match any element, it returns \`null\`.
Expand Down Expand Up @@ -934,6 +975,28 @@ If no matching component is found, returns an empty array.",
],
},
},
{
"description": "Returns the closest ancestor element (or self) that matches the specified component type.
If no matching component is found, returns \`null\`.",
"inheritedFrom": {
"name": "AbstractWrapper.findClosestComponent",
},
"name": "findClosestComponent",
"parameters": [
{
"description": "Component's wrapper class",
"flags": {
"isOptional": false,
},
"name": "ComponentClass",
"typeName": "ComponentWrapperClass<Wrapper, ElementType>",
},
],
"returnType": {
"isNullable": true,
"name": "Wrapper",
},
},
{
"description": "Returns the component wrapper matching the specified selector.
If the specified selector doesn't match any element, it returns \`null\`.
Expand Down
69 changes: 69 additions & 0 deletions src/core/test/dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,75 @@ describe('DOM test utils', () => {
expect(components[1].getElement().textContent).toBe('Old Button');
});
});

describe('findClosestComponent()', () => {
class ComponentAWrapper extends ComponentWrapper {
static rootSelector = 'component-a';
}

class ComponentBWrapper extends ComponentWrapper {
static rootSelector = 'component-b';
}

class UnrelatedWrapper extends ComponentWrapper {
static rootSelector = 'unrelated-component';
}

const nestedElements = document.createElement('div');
nestedElements.innerHTML = `
<div class="component-a" data-testid="component-a-outer">
<div class="component-b" data-testid="component-b-outer">
<div class="component-a" data-testid="component-a-inner">
<div class="component-b" data-testid="component-b-inner">
<span data-testid="deep-child">target</span>
</div>
</div>
</div>
</div>
<div class="component-b" data-testid="component-b-sibling"/>
`;

beforeEach(() => {
document.body.appendChild(nestedElements);
});

afterEach(() => {
document.body.removeChild(nestedElements);
});

it('returns the closest parent matching the component type, skipping nearer components of different types', () => {
const deepChild = nestedElements.querySelector('[data-testid="deep-child"]')!;
const childWrapper = new ElementWrapper(deepChild);

const closestInner = childWrapper.findClosestComponent(ComponentAWrapper);
expect(closestInner).toBeInstanceOf(ComponentAWrapper);
expect(closestInner!.getElement().getAttribute('data-testid')).toBe('component-a-inner');
});

it('returns self when the element itself matches the component type', () => {
const inner = nestedElements.querySelector('[data-testid="component-b-inner"]')!;
const innerWrapper = new ElementWrapper(inner);

const closestInner = innerWrapper.findClosestComponent(ComponentBWrapper);
expect(closestInner!.getElement().getAttribute('data-testid')).toBe('component-b-inner');
});

it('returns null when no ancestor matches the component type', () => {
const deepChild = nestedElements.querySelector('[data-testid="deep-child"]')!;
const childWrapper = new ElementWrapper(deepChild);

const result = childWrapper.findClosestComponent(UnrelatedWrapper);
expect(result).toBeNull();
});

it('does not match elements outside of the parent chain', () => {
const deepChild = nestedElements.querySelector('[data-testid="component-a-outer"]')!;
const childWrapper = new ElementWrapper(deepChild);

const result = childWrapper.findClosestComponent(ComponentBWrapper);
expect(result).toBeNull();
});
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest adding test coverage for sibling structures like:

<Component1>
  <Component2 />
</Component1>
<Component3>
  <Component4 />
</Component3>

When looking up the closest component for Component4 or Component3, the result should not match Component1 or Component2 as they're in a separate subtree

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added coverage for this in b0af06d.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And added a specific test for this in the latest commit

});

describe('createWrapper', () => {
Expand Down
11 changes: 11 additions & 0 deletions src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ const trimContentHash = (className: string): string => {
return className;
};

export function getComponentRootSelector(componentClass: {
rootSelector: string;
legacyRootSelector?: string;
}): string {
const rootSelector = `.${componentClass.rootSelector}`;
if ('legacyRootSelector' in componentClass && componentClass.legacyRootSelector) {
return `:is(.${componentClass.rootSelector}, .${componentClass.legacyRootSelector})`;
}
return rootSelector;
}

export const getUnscopedClassName = (selector: string): string => {
// this regexp resembles the logic of this code in the theming-core package:
// see src/build/tasks/postcss/generate-scoped-name.ts
Expand Down
Loading