diff --git a/src/components/tooltip/Marker.css b/src/components/tooltip/Marker.css new file mode 100644 index 0000000000..ccbcfad6ad --- /dev/null +++ b/src/components/tooltip/Marker.css @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.marker-list-value { + margin: 0; + padding-inline-start: 25px; +} + +/* These styles are useful to make the table well-aligned with its labels in tooltips. */ +.marker-table-value { + border-collapse: collapse; + margin-block-start: -1px; +} + +.marker-table-value th { + text-align: start; +} diff --git a/src/components/tooltip/Marker.js b/src/components/tooltip/Marker.js index f7b49ce383..5a769a7a97 100644 --- a/src/components/tooltip/Marker.js +++ b/src/components/tooltip/Marker.js @@ -35,7 +35,7 @@ import { import { Backtrace } from 'firefox-profiler/components/shared/Backtrace'; import { - formatFromMarkerSchema, + formatMarkupFromMarkerSchema, getSchemaFromMarker, } from 'firefox-profiler/profile-logic/marker-schema'; import { computeScreenshotSize } from 'firefox-profiler/profile-logic/marker-data'; @@ -62,6 +62,8 @@ import { getGCSliceDetails, } from './GCMarker'; +import './Marker.css'; + function _maybeFormatDuration( start: number | void, end: number | void @@ -245,7 +247,7 @@ class MarkerTooltipContents extends React.PureComponent { key={schema.name + '-' + key} label={label || key} > - {formatFromMarkerSchema(schema.name, format, value)} + {formatMarkupFromMarkerSchema(schema.name, format, value)} ); } diff --git a/src/profile-logic/marker-schema.js b/src/profile-logic/marker-schema.js index 3c28eaa4d0..9235ee5d3a 100644 --- a/src/profile-logic/marker-schema.js +++ b/src/profile-logic/marker-schema.js @@ -2,6 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // @flow +import * as React from 'react'; import { oneLine } from 'common-tags'; import { formatNumber, @@ -359,19 +360,64 @@ export function getLabelGetter( }; } +/** + * This function formats a string from a marker type and a value. + * If you wish to get markup instead, have a look at + * formatMarkupFromMarkerSchema below. + */ export function formatFromMarkerSchema( markerType: string, format: MarkerFormatType, value: any ): string { + if (value === undefined || value === null) { + console.warn( + `Formatting ${value} for ${markerType} with format ${JSON.stringify( + format + )}` + ); + return '(empty)'; + } + if (typeof format === 'object') { + switch (format.type) { + case 'table': { + const { columns } = format; + if (!(value instanceof Array)) { + throw new Error('Expected an array for table type'); + } + const hasHeader = columns.some((column) => column.label); + const rows = hasHeader + ? [columns.map((x) => x.label || '(empty)')] + : []; + const cellRows = value.map((row, i) => { + if (!(row instanceof Array)) { + throw new Error('Expected an array for table row'); + } + + if (row.length !== columns.length) { + throw new Error( + `Row ${i} length doesn't match column count (row: ${row.length}, cols: ${columns.length})` + ); + } + return row.map((cell, j) => { + const { format } = columns[j]; + return formatFromMarkerSchema(markerType, format || 'string', cell); + }); + }); + rows.push(...cellRows); + return rows.map((row) => `(${row.join(', ')})`).join(','); + } + default: + throw new Error( + `Unknown format type ${JSON.stringify((format.type: empty))}` + ); + } + } switch (format) { case 'url': case 'file-path': case 'string': // Make sure a non-empty string is returned here. - if (value === undefined || value === null) { - return '(empty)'; - } return String(value) || '(empty)'; case 'duration': case 'time': @@ -392,10 +438,129 @@ export function formatFromMarkerSchema( return formatNumber(value); case 'percentage': return formatPercent(value); + case 'list': + if (!(value instanceof Array)) { + throw new Error('Expected an array for list format'); + } + return value + .map((v) => formatFromMarkerSchema(markerType, 'string', v)) + .join(', '); default: - console.error( - `A marker schema of type "${markerType}" had an unknown format "${(format: empty)}"` + console.warn( + `A marker schema of type "${markerType}" had an unknown format ${JSON.stringify( + (format: empty) + )}` ); return value; } } + +// This regexp is used to test for URLs and remove their scheme for display. +const URL_SCHEME_REGEXP = /^http(s?):\/\//; + +/** + * This function may return structured markup for some types suchs as table, + * list, or urls. For other types this falls back to formatFromMarkerSchema + * above. + */ +export function formatMarkupFromMarkerSchema( + markerType: string, + format: MarkerFormatType, + value: any +): React.Element | string { + if (value === undefined || value === null) { + console.warn(`Formatting ${value} for ${JSON.stringify(markerType)}`); + return '(empty)'; + } + if (format !== 'url' && typeof format !== 'object' && format !== 'list') { + return formatFromMarkerSchema(markerType, format, value); + } + if (typeof format === 'object') { + switch (format.type) { + case 'table': { + const { columns } = format; + if (!(value instanceof Array)) { + throw new Error('Expected an array for table type'); + } + const hasHeader = columns.some((column) => column.label); + return ( + + {hasHeader ? ( + + + {columns.map((col, i) => ( + + ))} + + + ) : null} + + {value.map((row, i) => { + if (!(row instanceof Array)) { + throw new Error('Expected an array for table row'); + } + + if (row.length !== columns.length) { + throw new Error( + `Row ${i} length doesn't match column count (row: ${row.length}, cols: ${columns.length})` + ); + } + return ( + + {row.map((cell, i) => { + return ( + + ); + })} + + ); + })} + +
{col.label || ''}
+ {formatMarkupFromMarkerSchema( + markerType, + columns[i].type || 'string', + cell + )} +
+ ); + } + default: + throw new Error( + `Unknown format type ${JSON.stringify((format: empty))}` + ); + } + } + switch (format) { + case 'list': + if (!(value instanceof Array)) { + throw new Error('Expected an array for list format'); + } + return ( + + ); + case 'url': { + if (!URL_SCHEME_REGEXP.test(value)) { + return value; + } + return ( + + {value.replace(URL_SCHEME_REGEXP, '')} + + ); + } + default: + throw new Error(`Unknown format type ${JSON.stringify((format: empty))}`); + } +} diff --git a/src/test/unit/__snapshots__/marker-schema.test.js.snap b/src/test/unit/__snapshots__/marker-schema.test.js.snap index 760592ac67..ddd50244fd 100644 --- a/src/test/unit/__snapshots__/marker-schema.test.js.snap +++ b/src/test/unit/__snapshots__/marker-schema.test.js.snap @@ -1,5 +1,265 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`marker schema formatting supports complex formats 1`] = ` +Array [ + Array [ + "url", + "http://example.com", + + example.com + , + "http://example.com", + ], + Array [ + "file-path", + "/Users/me/gecko", + "/Users/me/gecko", + "/Users/me/gecko", + ], + Array [ + "file-path", + null, + "(empty)", + "(empty)", + ], + Array [ + "file-path", + undefined, + "(empty)", + "(empty)", + ], + Array [ + "duration", + 0, + "0s", + "0s", + ], + Array [ + "duration", + 10, + "10ms", + "10ms", + ], + Array [ + "duration", + 12.3456789, + "12.346ms", + "12.346ms", + ], + Array [ + Object { + "columns": Array [ + Object { + "type": "string", + }, + Object { + "type": "integer", + }, + ], + "type": "table", + }, + Array [ + Array [ + "a", + 1, + ], + Array [ + "b", + 2, + ], + ], + + + + + + + + + + + +
+ a + + 1 +
+ b + + 2 +
, + "(a, 1),(b, 2)", + ], + Array [ + Object { + "columns": Array [ + Object { + "label": "a", + "type": "string", + }, + Object { + "label": "b", + "type": "integer", + }, + ], + "type": "table", + }, + Array [ + Array [ + "b", + 2, + ], + ], + + + + + + + + + + + + + +
+ a + + b +
+ b + + 2 +
, + "(a, b),(b, 2)", + ], + Array [ + Object { + "columns": Array [ + Object { + "label": "a", + "type": "string", + }, + Object { + "type": "integer", + }, + ], + "type": "table", + }, + Array [ + Array [ + "b", + 2, + ], + ], + + + + + + + + + + + + + +
+ a + + +
+ b + + 2 +
, + "(a, (empty)),(b, 2)", + ], + Array [ + Object { + "columns": Array [ + Object { + "label": "a", + "type": "string", + }, + Object {}, + ], + "type": "table", + }, + Array [ + Array [ + "b", + 2, + ], + ], + + + + + + + + + + + + + +
+ a + + +
+ b + + 2 +
, + "(a, (empty)),(b, 2)", + ], + Array [ + "list", + Array [], +