diff --git a/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts b/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts index ca7701c..a4d9021 100644 --- a/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts +++ b/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts @@ -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 = ` + NameStatus + + Item 1Active + Item 2Inactive + + `; + 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 = ` + Name + Item 1 + `; + 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 = ` +
+ First choice + Description one + + Second choice + +
+ `; + 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 = ` + + + `; + 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 = ` + Tile A + + Tile B + + `; + 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 = ` +
    +
  1. + + Running, t2.micro +
  2. +
  3. + +
  4. +
+ `; + 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 = ` +
+ + + +
+ `; + 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 = ``; + 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', () => { diff --git a/src/internal/analytics-metadata/metadata-utils.ts b/src/internal/analytics-metadata/metadata-utils.ts index 08e7015..6766283 100644 --- a/src/internal/analytics-metadata/metadata-utils.ts +++ b/src/internal/analytics-metadata/metadata-utils.ts @@ -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, @@ -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) { @@ -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]; @@ -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 => { + 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 => { + 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 => { + 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); +}; diff --git a/src/internal/analytics-metadata/page-scanner-utils.ts b/src/internal/analytics-metadata/page-scanner-utils.ts index 065b9ab..88d5f21 100644 --- a/src/internal/analytics-metadata/page-scanner-utils.ts +++ b/src/internal/analytics-metadata/page-scanner-utils.ts @@ -8,10 +8,27 @@ import { getGeneratedAnalyticsMetadata } from './utils.js'; interface GeneratedAnalyticsMetadataComponentTree { name: string; label: string; - properties?: Record | Array>>; + properties?: Record | Array> | Array | Array>; children?: Array; } +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; parents: Map>; @@ -85,15 +102,16 @@ const mergeComponentsMaps = ( const getComponentsTreeRecursive = ( componentNodes: Array, - parentsMap: Map> + parentsMap: Map>, + options?: GetComponentsTreeOptions ): Array => { const tree: Array = []; 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; @@ -104,11 +122,12 @@ const getComponentsTreeRecursive = ( }; export const getComponentsTree = ( - node: HTMLElement | Document | null = document + node: HTMLElement | Document | null = document, + options?: GetComponentsTreeOptions ): Array => { if (!node) { return []; } const { roots, parents } = buildComponentsMap(node); - return getComponentsTreeRecursive(roots, parents); + return getComponentsTreeRecursive(roots, parents, options); }; diff --git a/src/internal/analytics-metadata/utils.ts b/src/internal/analytics-metadata/utils.ts index b3db081..06020a6 100644 --- a/src/internal/analytics-metadata/utils.ts +++ b/src/internal/analytics-metadata/utils.ts @@ -3,13 +3,18 @@ export { getRawAnalyticsMetadata } from './testing-utils.js'; export { getComponentsTree } from './page-scanner-utils.js'; +export type { GetComponentsTreeOptions, OptionItem, TabItem } from './page-scanner-utils.js'; import { METADATA_DATA_ATTRIBUTE } from './attributes.js'; import { GeneratedAnalyticsMetadata, GeneratedAnalyticsMetadataFragment } from './interfaces.js'; import { findLogicalParent } from './dom-utils.js'; import { mergeMetadata, processMetadata } from './metadata-utils.js'; +import type { GetComponentsTreeOptions } from './page-scanner-utils.js'; -export const getGeneratedAnalyticsMetadata = (target: HTMLElement | null): GeneratedAnalyticsMetadata => { +export const getGeneratedAnalyticsMetadata = ( + target: HTMLElement | null, + options?: GetComponentsTreeOptions +): GeneratedAnalyticsMetadata => { let metadata: GeneratedAnalyticsMetadataFragment = {}; let currentNode = target; while (currentNode) { @@ -17,7 +22,7 @@ export const getGeneratedAnalyticsMetadata = (target: HTMLElement | null): Gener const currentMetadataString = currentNode.dataset[METADATA_DATA_ATTRIBUTE]; if (currentMetadataString) { const currentMetadata = JSON.parse(currentMetadataString); - metadata = mergeMetadata(metadata, processMetadata(currentNode, currentMetadata)); + metadata = mergeMetadata(metadata, processMetadata(currentNode, currentMetadata, options)); } } catch { /* empty */