diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js index e2a506cfe98b..179b7eedd9e8 100644 --- a/lighthouse-core/report/html/renderer/details-renderer.js +++ b/lighthouse-core/report/html/renderer/details-renderer.js @@ -208,7 +208,7 @@ class DetailsRenderer { * Render a details item value for embedding in a table. Renders the value * based on the heading's valueType, unless the value itself has a `type` * property to override it. - * @param {LH.Audit.Details.TableItem[string] | LH.Audit.Details.OpportunityItem[string]} value + * @param {LH.Audit.Details.Value} value * @param {LH.Audit.Details.OpportunityColumnHeading} heading * @return {Element|null} */ @@ -246,7 +246,7 @@ class DetailsRenderer { switch (heading.valueType) { case 'bytes': { const numValue = Number(value); - return this._renderBytes({value: numValue, granularity: 1}); + return this._renderBytes({value: numValue, granularity: heading.granularity}); } case 'code': { const strValue = String(value); @@ -296,22 +296,54 @@ class DetailsRenderer { * OpportunityColumnHeading type until we have all details use the same * heading format. * @param {LH.Audit.Details.Table|LH.Audit.Details.Opportunity} tableLike - * @return {Array} header + * @return {Array} */ - _getCanonicalizedTableHeadings(tableLike) { + _getCanonicalizedHeadingsFromTable(tableLike) { if (tableLike.type === 'opportunity') { return tableLike.headings; } - return tableLike.headings.map(heading => { - return { - key: heading.key, - label: heading.text, - valueType: heading.itemType, - displayUnit: heading.displayUnit, - granularity: heading.granularity, - }; - }); + return tableLike.headings.map(heading => this._getCanonicalizedHeading(heading)); + } + + /** + * Get the headings of a table-like details object, converted into the + * OpportunityColumnHeading type until we have all details use the same + * heading format. + * @param {LH.Audit.Details.TableColumnHeading} heading + * @return {LH.Audit.Details.OpportunityColumnHeading} + */ + _getCanonicalizedHeading(heading) { + let subRows; + if (heading.subRows) { + // @ts-ignore: It's ok that there is no text. + subRows = this._getCanonicalizedHeading(heading.subRows); + } + + return { + key: heading.key, + valueType: heading.itemType, + subRows, + label: heading.text, + displayUnit: heading.displayUnit, + granularity: heading.granularity, + }; + } + + /** + * @param {LH.Audit.Details.Value[]} values + * @param {LH.Audit.Details.OpportunityColumnHeading} heading + * @return {Element} + */ + _renderSubRows(values, heading) { + const subRowsElement = this._dom.createElement('div', 'lh-sub-rows'); + for (const childValue of values) { + const subRowElement = this._renderTableValue(childValue, heading); + if (!subRowElement) continue; + subRowElement.classList.add('lh-sub-row'); + subRowsElement.appendChild(subRowElement); + } + return subRowsElement; } /** @@ -325,7 +357,7 @@ class DetailsRenderer { const theadElem = this._dom.createChildOf(tableElem, 'thead'); const theadTrElem = this._dom.createChildOf(theadElem, 'tr'); - const headings = this._getCanonicalizedTableHeadings(details); + const headings = this._getCanonicalizedHeadingsFromTable(details); for (const heading of headings) { const valueType = heading.valueType || 'text'; @@ -339,12 +371,30 @@ class DetailsRenderer { for (const row of details.items) { const rowElem = this._dom.createChildOf(tbodyElem, 'tr'); for (const heading of headings) { + const valueFragment = this._dom.createFragment(); + const value = row[heading.key]; - const valueElement = this._renderTableValue(value, heading); + const valueElement = + value !== undefined && !Array.isArray(value) && this._renderTableValue(value, heading); + if (valueElement) valueFragment.appendChild(valueElement); + + if (heading.subRows) { + const subRowsHeading = { + key: heading.subRows.key, + valueType: heading.subRows.valueType || heading.valueType, + granularity: heading.subRows.granularity || heading.granularity, + displayUnit: heading.subRows.displayUnit || heading.displayUnit, + label: '', + }; + const values = row[subRowsHeading.key]; + if (!Array.isArray(values)) continue; + const subRowsElement = this._renderSubRows(values, subRowsHeading); + valueFragment.appendChild(subRowsElement); + } - if (valueElement) { + if (valueFragment.childElementCount) { const classes = `lh-table-column--${heading.valueType}`; - this._dom.createChildOf(rowElem, 'td', classes).appendChild(valueElement); + this._dom.createChildOf(rowElem, 'td', classes).appendChild(valueFragment); } else { this._dom.createChildOf(rowElem, 'td', 'lh-table-column--empty'); } diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css index 0ecb30f0317a..3408de2e7c05 100644 --- a/lighthouse-core/report/html/report-styles.css +++ b/lighthouse-core/report/html/report-styles.css @@ -167,6 +167,7 @@ /* Pallete */ --color-gray-200: var(--color-gray-800); --color-gray-400: var(--color-gray-600); + --color-gray-700: var(--color-gray-400); --color-gray-50: #757575; --color-gray-600: var(--color-gray-500); --color-green-700: var(--color-green); @@ -1405,6 +1406,11 @@ text-decoration: underline dotted #999; } +.lh-sub-rows:not(:first-child) .lh-sub-row { + margin-left: 20px; + color: var(--color-gray-700); +} + /* Chevron https://codepen.io/paulirish/pen/LmzEmK */ diff --git a/lighthouse-core/test/report/html/renderer/details-renderer-test.js b/lighthouse-core/test/report/html/renderer/details-renderer-test.js index 3dd2058b5ba5..0b4193ebd945 100644 --- a/lighthouse-core/test/report/html/renderer/details-renderer-test.js +++ b/lighthouse-core/test/report/html/renderer/details-renderer-test.js @@ -548,7 +548,7 @@ describe('DetailsRenderer', () => { // itemType is overriden by code object headings: [{key: 'content', itemType: 'url', text: 'Heading'}], items: [ - {content: {type: 'code', value: 'code object'}}, + {content: {type: 'code', value: 'https://codeobject.com'}}, {content: 'https://example.com'}, ], }; @@ -560,7 +560,7 @@ describe('DetailsRenderer', () => { const codeEl = itemElements[0].firstChild; assert.equal(codeEl.localName, 'pre'); assert.ok(codeEl.classList.contains('lh-code')); - assert.equal(codeEl.textContent, 'code object'); + assert.equal(codeEl.textContent, 'https://codeobject.com'); // Second item uses the heading's specified type for the column. const urlEl = itemElements[1].firstChild; @@ -569,5 +569,78 @@ describe('DetailsRenderer', () => { assert.equal(urlEl.title, 'https://example.com'); assert.equal(urlEl.textContent, 'https://example.com'); }); + + describe('subRows', () => { + it('renders', () => { + const details = { + type: 'table', + headings: [{key: 'url', itemType: 'url', subRows: {key: 'sources', itemType: 'code'}}], + items: [ + {url: 'https://www.example.com', sources: ['a', 'b', 'c']}, + ], + }; + + const el = renderer.render(details); + const columnElement = el.querySelector('td.lh-table-column--url'); + + // First element is the url. + const codeEl = columnElement.firstChild; + assert.equal(codeEl.localName, 'div'); + assert.ok(codeEl.classList.contains('lh-text__url')); + assert.equal(codeEl.textContent, 'https://www.example.com'); + + // Second element lists the multiple values. + const subRowsEl = columnElement.children[1]; + assert.equal(subRowsEl.localName, 'div'); + assert.ok(subRowsEl.classList.contains('lh-sub-rows')); + + const multiValueEls = subRowsEl.querySelectorAll('.lh-sub-row'); + assert.equal(multiValueEls[0].textContent, 'a'); + assert.ok(multiValueEls[0].classList.contains('lh-code')); + assert.equal(multiValueEls[1].textContent, 'b'); + assert.ok(multiValueEls[1].classList.contains('lh-code')); + assert.equal(multiValueEls[2].textContent, 'c'); + assert.ok(multiValueEls[2].classList.contains('lh-code')); + }); + + it('renders, uses heading properties as fallback', () => { + const details = { + type: 'table', + headings: [{key: 'url', itemType: 'url', subRows: {key: 'sources'}}], + items: [ + { + url: 'https://www.example.com', + sources: [ + 'https://www.a.com', + {type: 'code', value: 'https://www.b.com'}, + 'https://www.c.com', + ], + }, + ], + }; + + const el = renderer.render(details); + const columnElement = el.querySelector('td.lh-table-column--url'); + + // First element is the url. + const codeEl = columnElement.firstChild; + assert.equal(codeEl.localName, 'div'); + assert.ok(codeEl.classList.contains('lh-text__url')); + assert.equal(codeEl.textContent, 'https://www.example.com'); + + // Second element lists the multiple values. + const subRowsEl = columnElement.children[1]; + assert.equal(subRowsEl.localName, 'div'); + assert.ok(subRowsEl.classList.contains('lh-sub-rows')); + + const multiValueEls = subRowsEl.querySelectorAll('.lh-sub-row'); + assert.equal(multiValueEls[0].textContent, 'https://www.a.com'); + assert.ok(multiValueEls[0].classList.contains('lh-text__url')); + assert.equal(multiValueEls[1].textContent, 'https://www.b.com'); + assert.ok(multiValueEls[1].classList.contains('lh-code')); + assert.equal(multiValueEls[2].textContent, 'https://www.c.com'); + assert.ok(multiValueEls[2].classList.contains('lh-text__url')); + }); + }); }); }); diff --git a/types/audit-details.d.ts b/types/audit-details.d.ts index 6b1820afdbf6..b5dfef7de0f8 100644 --- a/types/audit-details.d.ts +++ b/types/audit-details.d.ts @@ -82,7 +82,8 @@ declare global { } /** Possible types of values found within table items. */ - type ItemValueTypes = 'bytes' | 'code' | 'link' | 'ms' | 'node' | 'source-location' | 'numeric' | 'text' | 'thumbnail' | 'timespanMs' | 'url'; + type ItemValueTypes = 'bytes' | 'code' | 'link' | 'ms' | 'multi' | 'node' | 'source-location' | 'numeric' | 'text' | 'thumbnail' | 'timespanMs' | 'url'; + type Value = string | number | boolean | DebugData | NodeValue | SourceLocationValue | LinkValue | UrlValue | CodeValue; // TODO(bckenny): unify Table/Opportunity headings and items on next breaking change. @@ -97,6 +98,11 @@ declare global { * could also be objects with their own type to override this field. */ itemType: ItemValueTypes; + /** + * Optional - defines an inner table of values that correspond to this column. + * Key is required - if other properties are not provided, the value for the heading is used. + */ + subRows?: {key: string, itemType?: ItemValueTypes, displayUnit?: string, granularity?: number}; displayUnit?: string; granularity?: number; @@ -104,7 +110,7 @@ declare global { export type TableItem = { debugData?: DebugData; - [p: string]: undefined | string | number | boolean | undefined | DebugData | NodeValue | SourceLocationValue | LinkValue | UrlValue | CodeValue; + [p: string]: undefined | Value | Value[]; } export interface OpportunityColumnHeading { @@ -118,6 +124,11 @@ declare global { * could also be objects with their own type to override this field. */ valueType: ItemValueTypes; + /** + * Optional - defines an inner table of values that correspond to this column. + * Key is required - if other properties are not provided, the value for the heading is used. + */ + subRows?: {key: string, valueType?: ItemValueTypes, displayUnit?: string, granularity?: number}; // NOTE: not used by opportunity details, but used in the renderer until table/opportunity unification. displayUnit?: string; @@ -130,7 +141,7 @@ declare global { totalBytes?: number; wastedMs?: number; debugData?: DebugData; - [p: string]: number | boolean | string | undefined | DebugData; + [p: string]: undefined | Value | Value[]; } /**