Skip to content
Open
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
160 changes: 160 additions & 0 deletions src/internal/analytics-metadata/__tests__/metadata-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,166 @@ describe('processMetadata', () => {

document.body.removeChild(mockTable);
});

test('extracts table rows when includeAllTableRows option is true', () => {
const mockTable = document.createElement('table');
mockTable.innerHTML = `
<thead><tr><th>Name</th><th>Status</th></tr></thead>
<tbody>
<tr><td>Item 1</td><td>Active</td></tr>
<tr><td>Item 2</td><td>Inactive</td></tr>
</tbody>
`;
document.body.appendChild(mockTable);

const result: any = processMetadata(
mockTable,
{ name: 'awsui.Table', properties: {} },
{ includeAllTableRows: true }
);
expect(result.properties.rows).toEqual([
['Item 1', 'Active'],
['Item 2', 'Inactive'],
]);

document.body.removeChild(mockTable);
});

test('does not extract table rows when includeAllTableRows is false or omitted', () => {
const mockTable = document.createElement('table');
mockTable.innerHTML = `
<thead><tr><th>Name</th></tr></thead>
<tbody><tr><td>Item 1</td></tr></tbody>
`;
document.body.appendChild(mockTable);

const result1: any = processMetadata(mockTable, { name: 'awsui.Table', properties: {} });
expect(result1.properties.rows).toBeUndefined();

const result2: any = processMetadata(
mockTable,
{ name: 'awsui.Table', properties: {} },
{ includeAllTableRows: false }
);
expect(result2.properties.rows).toBeUndefined();

document.body.removeChild(mockTable);
});

test('extracts RadioGroup options with value, label, and description', () => {
const mockDiv = document.createElement('div');
mockDiv.innerHTML = `
<div role="radiogroup">
<span id="label-1">First choice</span>
<span id="desc-1">Description one</span>
<input type="radio" value="first" aria-labelledby="label-1" aria-describedby="desc-1" />
<span id="label-2">Second choice</span>
<input type="radio" value="second" aria-labelledby="label-2" />
</div>
`;
document.body.appendChild(mockDiv);

const result: any = processMetadata(mockDiv, { name: 'awsui.RadioGroup', properties: { value: 'first' } });
expect(result.properties.options).toEqual([
{ value: 'first', label: 'First choice', description: 'Description one' },
{ value: 'second', label: 'Second choice' },
]);

document.body.removeChild(mockDiv);
});

test('extracts RadioGroup options using aria-label fallback', () => {
const mockDiv = document.createElement('div');
mockDiv.innerHTML = `
<input type="radio" value="opt1" aria-label="Option One" />
<input type="radio" value="opt2" aria-label="Option Two" />
`;
document.body.appendChild(mockDiv);

const result: any = processMetadata(mockDiv, { name: 'awsui.RadioGroup', properties: {} });
expect(result.properties.options).toEqual([
{ value: 'opt1', label: 'Option One' },
{ value: 'opt2', label: 'Option Two' },
]);

document.body.removeChild(mockDiv);
});

test('extracts Tiles options same as RadioGroup', () => {
const mockDiv = document.createElement('div');
mockDiv.innerHTML = `
<span id="t-label-1">Tile A</span>
<input type="radio" value="a" aria-labelledby="t-label-1" />
<span id="t-label-2">Tile B</span>
<input type="radio" value="b" aria-labelledby="t-label-2" />
`;
document.body.appendChild(mockDiv);

const result: any = processMetadata(mockDiv, { name: 'awsui.Tiles', properties: { value: 'a' } });
expect(result.properties.options).toEqual([
{ value: 'a', label: 'Tile A' },
{ value: 'b', label: 'Tile B' },
]);

document.body.removeChild(mockDiv);
});

test('extracts Cards items from selection inputs', () => {
const mockDiv = document.createElement('div');
mockDiv.innerHTML = `
<ol>
<li data-awsui-analytics='{"component":{"innerContext":{"item":"id-1"}}}'>
<input type="checkbox" aria-label="Select item 1" aria-describedby="card-desc-1" />
<span id="card-desc-1">Running, t2.micro</span>
</li>
<li data-awsui-analytics='{"component":{"innerContext":{"item":"id-2"}}}'>
<input type="checkbox" aria-label="Select item 2" />
</li>
</ol>
`;
document.body.appendChild(mockDiv);

const result: any = processMetadata(mockDiv, { name: 'awsui.Cards', properties: {} });
expect(result.properties.options).toEqual([
{ value: 'id-1', label: 'Select item 1', description: 'Running, t2.micro' },
{ value: 'id-2', label: 'Select item 2' },
]);

document.body.removeChild(mockDiv);
});

test('extracts Tabs items from role=tab elements', () => {
const mockDiv = document.createElement('div');
mockDiv.innerHTML = `
<div role="tablist">
<button role="tab" data-testid="details" id="tab-1">Details</button>
<button role="tab" data-testid="monitoring" id="tab-2">Monitoring</button>
<button role="tab" data-testid="tags" id="tab-3" aria-disabled="true">Tags</button>
</div>
`;
document.body.appendChild(mockDiv);

const result: any = processMetadata(mockDiv, { name: 'awsui.Tabs', properties: { tabsCount: '3' } });
expect(result.properties.tabs).toEqual([
{ value: 'details', label: 'Details' },
{ value: 'monitoring', label: 'Monitoring' },
{ value: 'tags', label: 'Tags', disabled: 'true' },
]);

document.body.removeChild(mockDiv);
});

test('does not extract options for non-matching components', () => {
const mockDiv = document.createElement('div');
mockDiv.innerHTML = `<input type="radio" value="x" aria-label="X" />`;
document.body.appendChild(mockDiv);

const result: any = processMetadata(mockDiv, { name: 'awsui.Button', properties: {} });
expect(result.properties.options).toBeUndefined();
expect(result.properties.tabs).toBeUndefined();

document.body.removeChild(mockDiv);
});
});

describe('merge', () => {
Expand Down
126 changes: 124 additions & 2 deletions src/internal/analytics-metadata/metadata-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { GeneratedAnalyticsMetadataFragment } from './interfaces.js';
import { processLabel } from './labels-utils.js';
import type { GetComponentsTreeOptions, OptionItem, TabItem } from './page-scanner-utils.js';

export const mergeMetadata = (
metadata: GeneratedAnalyticsMetadataFragment | null,
Expand All @@ -16,14 +17,18 @@ export const mergeMetadata = (
return output;
};

export const processMetadata = (node: HTMLElement | null, localMetadata: any): GeneratedAnalyticsMetadataFragment => {
export const processMetadata = (
node: HTMLElement | null,
localMetadata: any,
options?: GetComponentsTreeOptions
): GeneratedAnalyticsMetadataFragment => {
return Object.keys(localMetadata).reduce((acc: any, key: string) => {
if (key.toLowerCase().match(/labels$/)) {
acc[key] = processLabel(node, localMetadata[key], 'multi');
} else if (key.toLowerCase().match(/label$/)) {
acc[key] = processLabel(node, localMetadata[key], 'single');
} else if (typeof localMetadata[key] !== 'string' && !Array.isArray(localMetadata[key])) {
acc[key] = processMetadata(node, localMetadata[key]);
acc[key] = processMetadata(node, localMetadata[key], options);
if (key === 'properties' && localMetadata.name === 'awsui.Table') {
const selectedItems = getTableSelectedItems(node);
if (selectedItems.length) {
Expand All @@ -33,6 +38,30 @@ export const processMetadata = (node: HTMLElement | null, localMetadata: any): G
if (columns.length) {
acc[key].columnLabels = columns;
}
if (options?.includeAllTableRows) {
const rows = getTableRows(node!);
if (rows.length) {
acc[key].rows = rows;
}
}
}
if (key === 'properties' && (localMetadata.name === 'awsui.RadioGroup' || localMetadata.name === 'awsui.Tiles')) {
const items = getRadioGroupOptions(node!);
if (items.length) {
acc[key].options = items;
}
}
if (key === 'properties' && localMetadata.name === 'awsui.Cards') {
const items = getCardsItems(node!);
if (items.length) {
acc[key].options = items;
}
}
if (key === 'properties' && localMetadata.name === 'awsui.Tabs') {
const tabs = getTabsItems(node!);
if (tabs.length) {
acc[key].tabs = tabs;
}
}
} else {
acc[key] = localMetadata[key];
Expand Down Expand Up @@ -95,3 +124,96 @@ const getTableColumns = (node: HTMLElement | null): string[] => {
.filter(Boolean)
: [];
};

const getTableRows = (node: HTMLElement): string[][] => {
const rows = Array.from(node.querySelectorAll('tbody tr'));
return rows
.map(row =>
Array.from(row.querySelectorAll('td, th'))
.filter(cell => !(cell as HTMLElement).querySelector('input[type="checkbox"], input[type="radio"]'))
.map(cell => cell.textContent?.trim() || '')
.filter(Boolean)
)
.filter(row => row.length > 0);
};

const resolveInputLabel = (root: HTMLElement, input: HTMLElement): string => {
const labelledBy = input.getAttribute('aria-labelledby');
if (labelledBy) {
const doc = root.ownerDocument || document;
const labelEl = doc.getElementById(labelledBy.split(' ')[0]);
if (labelEl?.textContent?.trim()) {
return labelEl.textContent.trim();
}
}
return input.getAttribute('aria-label') || '';
};

const resolveInputDescription = (root: HTMLElement, input: HTMLElement): string => {
const describedBy = input.getAttribute('aria-describedby');
if (describedBy) {
const doc = root.ownerDocument || document;
const descEl = doc.getElementById(describedBy.split(' ')[0]);
return descEl?.textContent?.trim() || '';
}
return '';
};

const getRadioGroupOptions = (node: HTMLElement): Array<OptionItem> => {
const inputs = Array.from(node.querySelectorAll('input[type="radio"]')) as HTMLElement[];
return inputs
.map(input => {
const value = input.getAttribute('value') || '';
const label = resolveInputLabel(node, input);
const description = resolveInputDescription(node, input);
const option: OptionItem = { value, label };
if (description) {
option.description = description;
}
return option;
})
.filter(opt => opt.value || opt.label);
};

const getCardsItems = (node: HTMLElement): Array<OptionItem> => {
const inputs = Array.from(node.querySelectorAll('input[type="checkbox"], input[type="radio"]')) as HTMLElement[];
return inputs
.map(input => {
const label = resolveInputLabel(node, input);
const description = resolveInputDescription(node, input);
let value = '';
const li = input.closest('li');
if (li) {
const metadataStr = (li as HTMLElement).dataset?.awsuiAnalytics;
if (metadataStr) {
try {
const meta = JSON.parse(metadataStr);
value = meta?.component?.innerContext?.item || '';
} catch {
/* empty */
}
}
}
const item: OptionItem = { value, label };
if (description) {
item.description = description;
}
return item;
})
.filter(opt => opt.value || opt.label);
};

const getTabsItems = (node: HTMLElement): Array<TabItem> => {
const tabs = Array.from(node.querySelectorAll('[role="tab"]')) as HTMLElement[];
return tabs
.map(tab => {
const id = tab.getAttribute('data-testid') || tab.id || '';
const label = tab.textContent?.trim() || tab.getAttribute('aria-label') || '';
const item: TabItem = { value: id, label };
if (tab.getAttribute('aria-disabled') === 'true') {
item.disabled = 'true';
}
return item;
})
.filter(tab => tab.label);
};
31 changes: 25 additions & 6 deletions src/internal/analytics-metadata/page-scanner-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,27 @@ import { getGeneratedAnalyticsMetadata } from './utils.js';
interface GeneratedAnalyticsMetadataComponentTree {
name: string;
label: string;
properties?: Record<string, string | Array<string> | Array<Array<string>>>;
properties?: Record<string, string | Array<string> | Array<Array<string>> | Array<OptionItem> | Array<TabItem>>;
children?: Array<GeneratedAnalyticsMetadataComponentTree>;
}

export interface OptionItem {
value: string;
label: string;
description?: string;
}

export interface TabItem {
value: string;
label: string;
disabled?: string;
}

export interface GetComponentsTreeOptions {
/** When true, include full table row data in awsui.Table properties. Default: false. */
includeAllTableRows?: boolean;
}

interface ComponentsMap {
roots: Array<HTMLElement>;
parents: Map<HTMLElement, Array<HTMLElement>>;
Expand Down Expand Up @@ -85,15 +102,16 @@ const mergeComponentsMaps = (

const getComponentsTreeRecursive = (
componentNodes: Array<HTMLElement>,
parentsMap: Map<HTMLElement, Array<HTMLElement>>
parentsMap: Map<HTMLElement, Array<HTMLElement>>,
options?: GetComponentsTreeOptions
): Array<GeneratedAnalyticsMetadataComponentTree> => {
const tree: Array<GeneratedAnalyticsMetadataComponentTree> = [];
componentNodes.forEach(componentNode => {
const treeItem: GeneratedAnalyticsMetadataComponentTree = {
...getGeneratedAnalyticsMetadata(componentNode).contexts[0].detail,
...getGeneratedAnalyticsMetadata(componentNode, options).contexts[0].detail,
};
const children = parentsMap.has(componentNode)
? getComponentsTreeRecursive(parentsMap.get(componentNode)!, parentsMap)
? getComponentsTreeRecursive(parentsMap.get(componentNode)!, parentsMap, options)
: [];
if (children.length > 0) {
treeItem.children = children;
Expand All @@ -104,11 +122,12 @@ const getComponentsTreeRecursive = (
};

export const getComponentsTree = (
node: HTMLElement | Document | null = document
node: HTMLElement | Document | null = document,
options?: GetComponentsTreeOptions
): Array<GeneratedAnalyticsMetadataComponentTree> => {
if (!node) {
return [];
}
const { roots, parents } = buildComponentsMap(node);
return getComponentsTreeRecursive(roots, parents);
return getComponentsTreeRecursive(roots, parents, options);
};
Loading
Loading