diff --git a/src/internal/analytics-metadata/__tests__/components.tsx b/src/internal/analytics-metadata/__tests__/components.tsx index 2005400..ad371cc 100644 --- a/src/internal/analytics-metadata/__tests__/components.tsx +++ b/src/internal/analytics-metadata/__tests__/components.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; +import React, { ReactNode } from 'react'; import { METADATA_ATTRIBUTE, getAnalyticsMetadataAttribute, getAnalyticsLabelAttribute } from '../attributes'; export const ComponentOne = ({ malformed }: { malformed?: boolean }) => ( @@ -33,7 +33,7 @@ export const ComponentOne = ({ malformed }: { malformed?: boolean }) => ( ); -const ComponentTwo = () => ( +export const ComponentTwo = () => (
sub label
@@ -43,7 +43,7 @@ const ComponentTwo = () => (
); -export const ComponentThree = () => ( +export const ComponentThree = ({ children }: { children?: ReactNode }) => (
(
+ {children}
); diff --git a/src/internal/analytics-metadata/__tests__/page-scanner-utils.test.tsx b/src/internal/analytics-metadata/__tests__/page-scanner-utils.test.tsx new file mode 100644 index 0000000..df6b636 --- /dev/null +++ b/src/internal/analytics-metadata/__tests__/page-scanner-utils.test.tsx @@ -0,0 +1,131 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { render } from '@testing-library/react'; +import { getComponentsTree } from '../utils'; +import { METADATA_ATTRIBUTE, activateAnalyticsMetadata } from '../attributes'; +import { ComponentOne, ComponentTwo, ComponentThree } from './components'; + +describe('getComponentsTree', () => { + describe('with active analytics metadata', () => { + beforeAll(() => { + activateAnalyticsMetadata(true); + }); + test('returns an empty array when input is null', () => { + expect(getComponentsTree(null)).toEqual([]); + }); + test('skips metadata that does not refer to a component', () => { + const { container } = render( +
+ +
+ ); + const target = container.querySelector('#outer-target') as HTMLElement; + expect(getComponentsTree(target)).toEqual([ + { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' }, children: [] }, + ]); + }); + test('only includes components inside the specified element', () => { + const { container } = render( + <> +
+ +
+
+ +
+ + ); + expect(getComponentsTree(container.querySelector('#outer-target-1') as HTMLElement)).toEqual([ + { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' }, children: [] }, + ]); + expect(getComponentsTree(container.querySelector('#outer-target-2') as HTMLElement)).toEqual([ + { name: 'ComponentTwo', label: 'sub label', children: [] }, + ]); + }); + test('can include multiple components', () => { + const { container } = render( +
+ + +
+ ); + expect(getComponentsTree(container.querySelector('#outer-target-1') as HTMLElement)).toEqual([ + { + name: 'ComponentThree', + children: [ + { name: 'ComponentTwo', label: 'sub label', children: [] }, + { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' }, children: [] }, + ], + }, + { name: 'ComponentTwo', label: 'sub label', children: [] }, + ]); + }); + test('can include multiple nested components', () => { + const { container } = render( +
+ + + +
+ ); + expect(getComponentsTree(container.querySelector('#outer-target-1') as HTMLElement)).toEqual([ + { + name: 'ComponentThree', + children: [ + { name: 'ComponentTwo', label: 'sub label', children: [] }, + { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' }, children: [] }, + { + name: 'ComponentThree', + children: [ + { name: 'ComponentTwo', label: 'sub label', children: [] }, + { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' }, children: [] }, + ], + }, + ], + }, + ]); + }); + test('use document as default element', () => { + render( + <> + + + + ); + expect(getComponentsTree()).toEqual([ + { + name: 'ComponentThree', + children: [ + { name: 'ComponentTwo', label: 'sub label', children: [] }, + { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' }, children: [] }, + ], + }, + { name: 'ComponentTwo', label: 'sub label', children: [] }, + ]); + }); + test('skips malformed metadata', () => { + const { container } = render( +
+ +
+ ); + const target = container.querySelector('#outer-target') as HTMLElement; + expect(getComponentsTree(target)).toEqual([ + { name: 'ComponentOne', label: 'component label', properties: { multi: 'true' }, children: [] }, + ]); + }); + }); + + describe('with inactive analytics metadata', () => { + beforeAll(() => { + activateAnalyticsMetadata(false); + }); + test('returns an empty object', () => { + const { container } = render(); + const target = container.querySelector('#target') as HTMLElement; + expect(getComponentsTree(target)).toEqual([]); + }); + }); +}); diff --git a/src/internal/analytics-metadata/interfaces.ts b/src/internal/analytics-metadata/interfaces.ts index 383dd3c..297e244 100644 --- a/src/internal/analytics-metadata/interfaces.ts +++ b/src/internal/analytics-metadata/interfaces.ts @@ -12,7 +12,7 @@ export interface GeneratedAnalyticsMetadata { contexts: Array; } -interface GeneratedAnalyticsMetadataComponent { +export interface GeneratedAnalyticsMetadataComponent { // name of the component. For example: "awsui.RadioGroup". We prefix the actual name with awsui to account for future tagging of custom components name: string; diff --git a/src/internal/analytics-metadata/page-scanner-utils.ts b/src/internal/analytics-metadata/page-scanner-utils.ts new file mode 100644 index 0000000..615b32e --- /dev/null +++ b/src/internal/analytics-metadata/page-scanner-utils.ts @@ -0,0 +1,44 @@ +// 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 { GeneratedAnalyticsMetadataComponent } from './interfaces'; +import { getGeneratedAnalyticsMetadata } from './utils'; + +interface GeneratedAnalyticsMetadataComponentTree extends GeneratedAnalyticsMetadataComponent { + children?: Array; +} + +const getComponentsArray = (node: HTMLElement | Document = document) => { + const elementsWithMetadata = Array.from(node.querySelectorAll(`[${METADATA_ATTRIBUTE}]`)) as Array; + return elementsWithMetadata.filter(isNodeComponent); +}; + +const getComponentsTreeRecursive = ( + node: HTMLElement | Document, + visited: Set +): Array => { + const tree: Array = []; + const componentNodes = getComponentsArray(node); + componentNodes.forEach(componentNode => { + if (visited.has(componentNode)) { + return; + } + visited.add(componentNode); + tree.push({ + ...getGeneratedAnalyticsMetadata(componentNode).contexts[0].detail, + children: getComponentsTreeRecursive(componentNode, visited), + }); + }); + return tree; +}; + +export const getComponentsTree = ( + node: HTMLElement | Document | null = document +): Array => { + if (!node) { + return []; + } + return getComponentsTreeRecursive(node, new Set()); +}; diff --git a/src/internal/analytics-metadata/utils.ts b/src/internal/analytics-metadata/utils.ts index 05c1205..19bb318 100644 --- a/src/internal/analytics-metadata/utils.ts +++ b/src/internal/analytics-metadata/utils.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export { getRawAnalyticsMetadata } from './testing-utils'; +export { getComponentsTree } from './page-scanner-utils'; import { METADATA_DATA_ATTRIBUTE } from './attributes'; import { GeneratedAnalyticsMetadata, GeneratedAnalyticsMetadataFragment } from './interfaces';