diff --git a/src/internal/analytics-metadata/__tests__/dom-utils.test.tsx b/src/internal/analytics-metadata/__tests__/dom-utils.test.tsx index 05bb1d4..d77520f 100644 --- a/src/internal/analytics-metadata/__tests__/dom-utils.test.tsx +++ b/src/internal/analytics-metadata/__tests__/dom-utils.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { activateAnalyticsMetadata, getAnalyticsMetadataAttribute, METADATA_ATTRIBUTE } from '../attributes'; -import { findLogicalParent, isNodeComponent, findComponentUp, findSelectorUp } from '../dom-utils'; +import { findLogicalParent, isNodeComponent, findComponentUpUntil, findSelectorUp } from '../dom-utils'; beforeAll(() => { activateAnalyticsMetadata(true); @@ -78,9 +78,9 @@ describe('isNodeComponent', () => { }); }); -describe('findComponentUp', () => { +describe('findComponentUpUntil', () => { test('returns null when input is null', () => { - expect(findComponentUp(null)).toBeNull(); + expect(findComponentUpUntil(null)).toBeNull(); }); test('returns parent component element', () => { const { container } = render( @@ -88,7 +88,7 @@ describe('findComponentUp', () => {
); - expect(findComponentUp(container.querySelector('#target-element'))!.id).toBe('component-element'); + expect(findComponentUpUntil(container.querySelector('#target-element'))!.id).toBe('component-element'); }); test('returns parent component element with portals', () => { const { container } = render( @@ -101,7 +101,7 @@ describe('findComponentUp', () => { ); - expect(findComponentUp(container.querySelector('#target-element'))!.id).toBe('component-element'); + expect(findComponentUpUntil(container.querySelector('#target-element'))!.id).toBe('component-element'); }); test('returns null when element has no parent component', () => { const { container } = render( @@ -109,7 +109,54 @@ describe('findComponentUp', () => {
); - expect(findComponentUp(container.querySelector('#target-element'))).toBeNull(); + expect(findComponentUpUntil(container.querySelector('#target-element'))).toBeNull(); + }); + test('with `until` argument', () => { + const { container } = render( + <> +
+
+
+
+
+
+
+
+
+
+
+ + ); + expect( + findComponentUpUntil( + container.querySelector('#target-element'), + container.querySelector('#outer-element') as HTMLElement + )!.id + ).toBe('component-element'); + expect( + findComponentUpUntil( + container.querySelector('#target-element'), + container.querySelector('#target-element') as HTMLElement + ) + ).toBeNull(); + expect( + findComponentUpUntil( + container.querySelector('#target-element'), + container.querySelector('#component-element') as HTMLElement + )!.id + ).toBe('component-element'); + expect( + findComponentUpUntil( + container.querySelector('#another-target-element'), + container.querySelector('#outer-element') as HTMLElement + )!.id + ).toBe('component-element'); + expect( + findComponentUpUntil( + container.querySelector('#another-target-element'), + container.querySelector('#another-until') as HTMLElement + ) + ).toBeNull(); }); }); diff --git a/src/internal/analytics-metadata/__tests__/page-scanner-utils.test.tsx b/src/internal/analytics-metadata/__tests__/page-scanner-utils.test.tsx index d6998e9..2661620 100644 --- a/src/internal/analytics-metadata/__tests__/page-scanner-utils.test.tsx +++ b/src/internal/analytics-metadata/__tests__/page-scanner-utils.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { getComponentsTree } from '../utils'; -import { METADATA_ATTRIBUTE, activateAnalyticsMetadata } from '../attributes'; +import { METADATA_ATTRIBUTE, activateAnalyticsMetadata, getAnalyticsMetadataAttribute } from '../attributes'; import { ComponentOne, ComponentTwo, ComponentThree } from './components'; describe('getComponentsTree', () => { @@ -55,8 +55,11 @@ describe('getComponentsTree', () => { { name: 'ComponentThree', children: [ - { name: 'ComponentTwo', label: 'sub label' }, - { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' } }, + { + name: 'ComponentTwo', + label: 'sub label', + children: [{ name: 'ComponentOne', label: 'component label', properties: { multi: 'true' } }], + }, ], }, { name: 'ComponentTwo', label: 'sub label' }, @@ -66,7 +69,7 @@ describe('getComponentsTree', () => { const { container } = render(
- +
); @@ -74,15 +77,12 @@ describe('getComponentsTree', () => { { name: 'ComponentThree', children: [ - { name: 'ComponentTwo', label: 'sub label' }, - { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' } }, { - name: 'ComponentThree', - children: [ - { name: 'ComponentTwo', label: 'sub label' }, - { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' } }, - ], + name: 'ComponentTwo', + label: 'sub label', + children: [{ name: 'ComponentOne', label: 'component label', properties: { multi: 'true' } }], }, + { name: 'ComponentTwo', label: 'sub label' }, ], }, ]); @@ -98,8 +98,11 @@ describe('getComponentsTree', () => { { name: 'ComponentThree', children: [ - { name: 'ComponentTwo', label: 'sub label' }, - { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' } }, + { + name: 'ComponentTwo', + label: 'sub label', + children: [{ name: 'ComponentOne', label: 'component label', properties: { multi: 'true' } }], + }, ], }, { name: 'ComponentTwo', label: 'sub label' }, @@ -116,6 +119,83 @@ describe('getComponentsTree', () => { { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' } }, ]); }); + describe('with portals', () => { + test('returns an empty array when portal outside of the node element', () => { + const { container } = render( +
+
+
+ +
+
+
+ ); + const target = container.querySelector('#inner-target') as HTMLElement; + expect(getComponentsTree(target)).toEqual([]); + }); + test('returns nested portal correctly', () => { + const { container } = render( +
+
+ +
+
+
+ +
+
+
+
+ ); + expect(getComponentsTree(container.querySelector('#outer-target') as HTMLElement)).toEqual([ + { + name: 'ComponentFour', + children: [ + { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' } }, + { name: 'ComponentTwo', label: 'sub label' }, + ], + }, + ]); + expect(getComponentsTree(container.querySelector('#inner-target') as HTMLElement)).toEqual([ + { + name: 'ComponentFour', + children: [ + { name: 'ComponentTwo', label: 'sub label' }, + { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' } }, + ], + }, + ]); + }); + test('returns recursively nested portals', () => { + const { container } = render( +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+ ); + expect(getComponentsTree(container.querySelector('#inner-target') as HTMLElement)).toEqual([ + { + name: 'ComponentFour', + children: [ + { name: 'ComponentTwo', label: 'sub label' }, + { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' } }, + { name: 'ComponentFive' }, + ], + }, + ]); + }); + }); }); describe('with inactive analytics metadata', () => { diff --git a/src/internal/analytics-metadata/attributes.ts b/src/internal/analytics-metadata/attributes.ts index f9715ca..f9ee324 100644 --- a/src/internal/analytics-metadata/attributes.ts +++ b/src/internal/analytics-metadata/attributes.ts @@ -7,6 +7,8 @@ import { getGlobalFlag } from '../global-flags'; export const METADATA_DATA_ATTRIBUTE = 'awsuiAnalytics'; export const METADATA_ATTRIBUTE = 'data-awsui-analytics'; export const LABEL_DATA_ATTRIBUTE = 'awsuiAnalyticsLabel'; +export const REFERRER_DATA_ATTRIBUTE = 'awsuiReferrerId'; +export const REFERRER_ATTRIBUTE = 'data-awsui-referrer-id'; const LABEL_ATTRIBUTE = 'data-awsui-analytics-label'; let activated = getGlobalFlag('analyticsMetadata'); diff --git a/src/internal/analytics-metadata/dom-utils.ts b/src/internal/analytics-metadata/dom-utils.ts index 6cfd160..1a79b55 100644 --- a/src/internal/analytics-metadata/dom-utils.ts +++ b/src/internal/analytics-metadata/dom-utils.ts @@ -1,11 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { METADATA_DATA_ATTRIBUTE } from './attributes'; +import { METADATA_DATA_ATTRIBUTE, REFERRER_DATA_ATTRIBUTE } from './attributes'; export const findLogicalParent = (node: HTMLElement): HTMLElement | null => { try { - const referrer = node.dataset.awsuiReferrerId; + const referrer = node.dataset[REFERRER_DATA_ATTRIBUTE]; if (referrer) { return document.querySelector(`[id="${referrer}"]`); } @@ -15,12 +15,12 @@ export const findLogicalParent = (node: HTMLElement): HTMLElement | null => { } }; -export function findComponentUp(node: HTMLElement | null): HTMLElement | null { +export function findComponentUpUntil(node: HTMLElement | null, until: HTMLElement = document.body): HTMLElement | null { let firstComponentElement = node; - while (firstComponentElement && firstComponentElement.tagName !== 'body' && !isNodeComponent(firstComponentElement)) { + while (firstComponentElement && firstComponentElement !== until && !isNodeComponent(firstComponentElement)) { firstComponentElement = findLogicalParent(firstComponentElement); } - return firstComponentElement && firstComponentElement.tagName !== 'body' ? firstComponentElement : null; + return firstComponentElement && isNodeComponent(firstComponentElement) ? firstComponentElement : null; } export const isNodeComponent = (node: HTMLElement): boolean => { diff --git a/src/internal/analytics-metadata/labels-utils.ts b/src/internal/analytics-metadata/labels-utils.ts index 1ac173e..0ef2e5b 100644 --- a/src/internal/analytics-metadata/labels-utils.ts +++ b/src/internal/analytics-metadata/labels-utils.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { LABEL_DATA_ATTRIBUTE } from './attributes'; -import { findSelectorUp, findComponentUp } from './dom-utils'; +import { findSelectorUp, findComponentUpUntil } from './dom-utils'; import { LabelIdentifier } from './interfaces'; export const processLabel = (node: HTMLElement | null, labelIdentifier: string | LabelIdentifier | null): string => { @@ -52,7 +52,7 @@ const processSingleLabel = ( return processSingleLabel(findSelectorUp(node, rootSelector), labelSelector); } if (root === 'component') { - return processSingleLabel(findComponentUp(node), labelSelector); + return processSingleLabel(findComponentUpUntil(node), labelSelector); } if (root === 'body') { return processSingleLabel(document.body, labelSelector); diff --git a/src/internal/analytics-metadata/page-scanner-utils.ts b/src/internal/analytics-metadata/page-scanner-utils.ts index e90f645..ece9bf8 100644 --- a/src/internal/analytics-metadata/page-scanner-utils.ts +++ b/src/internal/analytics-metadata/page-scanner-utils.ts @@ -1,8 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { METADATA_ATTRIBUTE } from './attributes'; -import { isNodeComponent } from './dom-utils'; +import { METADATA_ATTRIBUTE, REFERRER_ATTRIBUTE, REFERRER_DATA_ATTRIBUTE } from './attributes'; +import { findComponentUpUntil, isNodeComponent } from './dom-utils'; import { getGeneratedAnalyticsMetadata } from './utils'; interface GeneratedAnalyticsMetadataComponentTree { @@ -12,26 +12,57 @@ interface GeneratedAnalyticsMetadataComponentTree { children?: Array; } +interface ComponentsMap { + roots: Array; + parents: Map>; +} + +const findPortalsOutsideOfNode = (node: HTMLElement): Array => + (Array.from(document.querySelectorAll(`[${REFERRER_ATTRIBUTE}]`)) as Array).filter(element => { + const referrer = element.dataset[REFERRER_DATA_ATTRIBUTE]; + return !!node.querySelector(`[id="${referrer}"]`) && !node.querySelector(`[${REFERRER_ATTRIBUTE}="${referrer}"]`); + }); + const getComponentsArray = (node: HTMLElement | Document = document) => { const elementsWithMetadata = Array.from(node.querySelectorAll(`[${METADATA_ATTRIBUTE}]`)) as Array; + findPortalsOutsideOfNode(node as HTMLElement).forEach(portal => { + elementsWithMetadata.push(...getComponentsArray(portal)); + }); return elementsWithMetadata.filter(isNodeComponent); }; +const buildComponentsMap = (node: HTMLElement | Document = document) => { + const componentsArray = getComponentsArray(node); + const map: ComponentsMap = { + roots: [], + parents: new Map>(), + }; + componentsArray.forEach(element => { + const parent = element.parentElement ? findComponentUpUntil(element.parentElement, node as HTMLElement) : null; + if (!parent) { + map.roots.push(element); + } else { + if (!map.parents.has(parent)) { + map.parents.set(parent, []); + } + map.parents.get(parent)?.push(element); + } + }); + return map; +}; + const getComponentsTreeRecursive = ( - node: HTMLElement | Document, - visited: Set + componentNodes: Array, + parentsMap: Map> ): Array => { const tree: Array = []; - const componentNodes = getComponentsArray(node); componentNodes.forEach(componentNode => { - if (visited.has(componentNode)) { - return; - } - visited.add(componentNode); const treeItem: GeneratedAnalyticsMetadataComponentTree = { ...getGeneratedAnalyticsMetadata(componentNode).contexts[0].detail, }; - const children = getComponentsTreeRecursive(componentNode, visited); + const children = parentsMap.has(componentNode) + ? getComponentsTreeRecursive(parentsMap.get(componentNode)!, parentsMap) + : []; if (children.length > 0) { treeItem.children = children; } @@ -46,5 +77,6 @@ export const getComponentsTree = ( if (!node) { return []; } - return getComponentsTreeRecursive(node, new Set()); + const { roots, parents } = buildComponentsMap(node); + return getComponentsTreeRecursive(roots, parents); };