Improve test freshness page interaction experience.
[WebKit.git] / Websites / perf.webkit.org / public / v3 / pages / test-freshness-page.js
index bd50697..72c83d6 100644 (file)
@@ -9,6 +9,18 @@ class TestFreshnessPage extends PageWithHeading {
         this._lastDataPointByConfiguration = null;
         this._indicatorByConfiguration = null;
         this._renderTableLazily = new LazilyEvaluatedFunction(this._renderTable.bind(this));
+        this._hoveringIndicator = null;
+        this._indicatorForTooltip = null;
+        this._firstIndicatorAnchor = null;
+        this._showTooltip = false;
+        this._builderByIndicator = null;
+        this._tabIndexForIndicator = null;
+        this._coordinateForIndicator = null;
+        this._indicatorAnchorGrid = null;
+        this._skipNextClick = false;
+        this._skipNextStateCleanOnScroll = false;
+        this._lastFocusedCell = null;
+        this._renderTooltipLazily = new LazilyEvaluatedFunction(this._renderTooltip.bind(this));
 
         this._loadConfig(summaryPageConfiguration);
     }
@@ -51,10 +63,91 @@ class TestFreshnessPage extends PageWithHeading {
         super.open(state);
     }
 
+    didConstructShadowTree()
+    {
+        super.didConstructShadowTree();
+
+        const tooltipTable = this.content('tooltip-table');
+        this.content().addEventListener('click', (event) => {
+            if (!tooltipTable.contains(event.target))
+                this._clearIndicatorState(false);
+        });
+
+        tooltipTable.onkeydown = this.createEventHandler((event) => {
+            if (event.code == 'Escape') {
+                event.preventDefault();
+                event.stopPropagation();
+                this._lastFocusedCell.focus({preventScroll: true});
+            }
+        }, {preventDefault: false, stopPropagation: false});
+
+        window.addEventListener('keydown', (event) => {
+            if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.code))
+                return;
+
+            event.preventDefault();
+            if (!this._indicatorForTooltip && !this._hoveringIndicator) {
+                if (this._firstIndicatorAnchor)
+                    this._firstIndicatorAnchor.focus({preventScroll: true});
+                return;
+            }
+
+            let [row, column] = this._coordinateForIndicator.get(this._indicatorForTooltip || this._hoveringIndicator);
+            let direction = null;
+
+            switch (event.code) {
+                case 'ArrowUp':
+                    row -= 1;
+                    break;
+                case 'ArrowDown':
+                    row += 1;
+                    break;
+                case 'ArrowLeft':
+                    column -= 1;
+                    direction = 'leftOnly'
+                    break;
+                case 'ArrowRight':
+                    column += 1;
+                    direction = 'rightOnly'
+            }
+
+            const closestIndicatorAnchor = this._findClosestIndicatorAnchorForCoordinate(column, row, this._indicatorAnchorGrid, direction);
+            if (closestIndicatorAnchor)
+                closestIndicatorAnchor.focus({preventScroll: true});
+        });
+    }
+
+    _findClosestIndicatorAnchorForCoordinate(columnIndex, rowIndex, grid, direction)
+    {
+        rowIndex = Math.min(Math.max(rowIndex, 0), grid.length - 1);
+        const row = grid[rowIndex];
+        if (!row.length)
+            return null;
+
+        const start = Math.min(Math.max(columnIndex, 0), row.length - 1);
+        if (row[start])
+            return row[start];
+
+        let offset = 1;
+        while (true) {
+            const leftIndex = start - offset;
+            if (leftIndex >= 0 && row[leftIndex] && direction != 'rightOnly')
+                return row[leftIndex];
+            const rightIndex = start + offset;
+            if (rightIndex < row.length && row[rightIndex] && direction != 'leftOnly')
+                return row[rightIndex];
+            if (leftIndex < 0 && rightIndex >= row.length)
+                break;
+            offset += 1;
+        }
+        return null;
+    }
+
     _fetchTestResults()
     {
         this._measurementSetFetchTime = Date.now();
         this._lastDataPointByConfiguration = new Map;
+        this._builderByIndicator = new Map;
 
         const startTime = this._measurementSetFetchTime - this._timeDuration;
 
@@ -70,11 +163,29 @@ class TestFreshnessPage extends PageWithHeading {
                 measurementSet.fetchBetween(startTime, this._measurementSetFetchTime).then(() => {
                     const currentTimeSeries = measurementSet.fetchedTimeSeries('current', false, false);
 
-                    let timeForLastDataPoint = startTime;
-                    if (currentTimeSeries.lastPoint())
-                        timeForLastDataPoint = currentTimeSeries.lastPoint().build().buildTime();
+                    let timeForLatestBuild = startTime;
+                    let lastBuild = null;
+                    let builder = null;
+                    let commitSetOfLastPoint = null;
+                    const lastPoint = currentTimeSeries.lastPoint();
+                    if (lastPoint) {
+                        timeForLatestBuild = lastPoint.build().buildTime().getTime();
+                        const view = currentTimeSeries.viewBetweenPoints(currentTimeSeries.firstPoint(), lastPoint);
+                        for (const point of view) {
+                            const build = point.build();
+                            if (!build)
+                                continue;
+                            if (build.buildTime().getTime() >= timeForLatestBuild) {
+                                timeForLatestBuild = build.buildTime().getTime();
+                                lastBuild = build;
+                                builder = build.builder();
+                            }
+                        }
+                        commitSetOfLastPoint = lastPoint.commitSet();
+                    }
 
-                    lastDataPointByMetric.set(metric, {time: timeForLastDataPoint, hasCurrentDataPoint: !!currentTimeSeries.lastPoint()});
+                    lastDataPointByMetric.set(metric, {time: timeForLatestBuild, hasCurrentDataPoint: !!lastPoint,
+                        lastBuild, builder, commitSetOfLastPoint});
                     this.enqueueToRender();
                 });
             }
@@ -87,40 +198,140 @@ class TestFreshnessPage extends PageWithHeading {
 
         this._renderTableLazily.evaluate(this._platforms, this._metrics);
 
+        let buildSummaryForFocusingIndicator = null;
+        let buildForFocusingIndicator = null;
+        let commitSetForFocusingdIndicator = null;
+        let chartURLForFocusingIndicator = null;
+        let platformForFocusingIndicator = null;
+        let metricForFocusingIndicator = null;
+        const builderForFocusingIndicator = this._indicatorForTooltip ? this._builderByIndicator.get(this._indicatorForTooltip) : null;
+        const builderForHoveringIndicator = this._hoveringIndicator ? this._builderByIndicator.get(this._hoveringIndicator) : null;
         for (const [platform, lastDataPointByMetric] of this._lastDataPointByConfiguration.entries()) {
             for (const [metric, lastDataPoint] of lastDataPointByMetric.entries()) {
                 const timeDuration = this._measurementSetFetchTime - lastDataPoint.time;
                 const timeDurationSummaryPrefix = lastDataPoint.hasCurrentDataPoint ? '' : 'More than ';
                 const timeDurationSummary = BuildRequest.formatTimeInterval(timeDuration);
-                const testLabel = `"${metric.test().fullName()}" for "${platform.name()}"`;
-                const summary = `${timeDurationSummaryPrefix}${timeDurationSummary} since last data point on ${testLabel}`;
-                const url = this._router.url('charts', ChartsPage.createStateForDashboardItem(platform.id(), metric.id(),
-                    this._measurementSetFetchTime - this._timeDuration));
+                const summary = `${timeDurationSummaryPrefix}${timeDurationSummary} since latest data point.`;
 
                 const indicator = this._indicatorByConfiguration.get(platform).get(metric);
-                indicator.update(timeDuration, this._testAgeTolerance, summary, url);
+                if (this._indicatorForTooltip && this._indicatorForTooltip === indicator) {
+                    buildSummaryForFocusingIndicator = summary;
+                    buildForFocusingIndicator = lastDataPoint.lastBuild;
+                    commitSetForFocusingdIndicator = lastDataPoint.commitSetOfLastPoint;
+                    chartURLForFocusingIndicator =  this._router.url('charts', ChartsPage.createStateForDashboardItem(platform.id(), metric.id(),
+                        this._measurementSetFetchTime - this._timeDuration));
+                    platformForFocusingIndicator = platform;
+                    metricForFocusingIndicator = metric;
+                }
+                this._builderByIndicator.set(indicator, lastDataPoint.builder);
+                const highlighted = builderForFocusingIndicator && builderForFocusingIndicator == lastDataPoint.builder
+                    || builderForHoveringIndicator && builderForHoveringIndicator === lastDataPoint.builder;
+                indicator.update(timeDuration, this._testAgeTolerance, highlighted);
             }
         }
+        this._renderTooltipLazily.evaluate(this._indicatorForTooltip, this._showTooltip, buildSummaryForFocusingIndicator, buildForFocusingIndicator,
+            commitSetForFocusingdIndicator, chartURLForFocusingIndicator, platformForFocusingIndicator, metricForFocusingIndicator, this._tabIndexForIndicator.get(this._indicatorForTooltip));
+    }
+
+    _renderTooltip(indicator, showTooltip, buildSummary, build, commitSet, chartURL, platform, metric, tabIndex)
+    {
+        if (!indicator || !buildSummary || !showTooltip) {
+            this.content('tooltip-anchor').style.display =  showTooltip ? null : 'none';
+            return;
+        }
+        const element = ComponentBase.createElement;
+        const link = ComponentBase.createLink;
+
+        const rect = indicator.element().getBoundingClientRect();
+        const tooltipAnchor = this.content('tooltip-anchor');
+        tooltipAnchor.style.display = null;
+        tooltipAnchor.style.top = rect.top + 'px';
+        tooltipAnchor.style.left = rect.left + rect.width / 2 + 'px';
+
+        let tableContent = [element('tr', element('td', {colspan: 2}, buildSummary))];
+
+        if (chartURL) {
+            const linkDescription = `${metric.test().name()} on ${platform.name()}`;
+            tableContent.push(element('tr', [
+                element('td', 'Chart'),
+                element('td', {colspan: 2}, link(linkDescription, linkDescription, chartURL, true, tabIndex))
+            ]));
+        }
+
+        if (commitSet) {
+            if (commitSet.repositories().length)
+                tableContent.push(element('tr', element('th', {colspan: 2}, 'Latest build information')));
+
+            tableContent.push(Repository.sortByNamePreferringOnesWithURL(commitSet.repositories()).map((repository) => {
+                const commit = commitSet.commitForRepository(repository);
+                return element('tr', [
+                    element('td', repository.name()),
+                    element('td', commit.url() ? link(commit.label(), commit.label(), commit.url(), true, tabIndex) : commit.label())
+                ]);
+            }));
+        }
+
+        if (build) {
+            const url = build.url();
+            const buildNumber = build.buildNumber();
+            tableContent.push(element('tr', [
+                element('td', 'Build'),
+                element('td', {colspan: 2}, [
+                    url ? link(buildNumber, build.label(), url, true, tabIndex) : buildNumber
+                ]),
+            ]));
+        }
+
+        this.renderReplace(this.content("tooltip-table"),  tableContent);
     }
 
     _renderTable(platforms, metrics)
     {
         const element = ComponentBase.createElement;
-        const tableBodyElement = [];
-        const tableHeadElements = [element('th',  {class: 'table-corner'}, 'Platform \\ Test')];
+        const tableHeadElements = [element('th',  {class: 'table-corner row-head'}, 'Platform \\ Test')];
 
         for (const metric of metrics)
-            tableHeadElements.push(element('th', {class: 'diagonal-header'}, element('div', metric.test().fullName())));
+            tableHeadElements.push(element('th', {class: 'diagonal-head'}, element('div', metric.test().fullName())));
 
         this._indicatorByConfiguration = new Map;
-        for (const platform of platforms) {
+        this._coordinateForIndicator = new Map;
+        this._tabIndexForIndicator = new Map;
+        this._indicatorAnchorGrid = [];
+        this._firstIndicatorAnchor = null;
+        let currentTabIndex = 1;
+
+        const tableBodyElement = platforms.map((platform, rowIndex) =>  {
             const indicatorByMetric = new Map;
             this._indicatorByConfiguration.set(platform, indicatorByMetric);
-            tableBodyElement.push(element('tr',
-                [element('th', platform.label()), ...metrics.map((metric) => this._constructTableCell(platform, metric, indicatorByMetric))]));
-        }
 
-        this.renderReplace(this.content('test-health'), [element('thead', tableHeadElements), element('tbody', tableBodyElement)]);
+            let indicatorAnchorsInCurrentRow = [];
+
+            const cells = metrics.map((metric, columnIndex) => {
+                const [cell, anchor, indicator] = this._constructTableCell(platform, metric, currentTabIndex);
+
+                indicatorAnchorsInCurrentRow.push(anchor);
+                if (!indicator)
+                    return cell;
+
+                indicatorByMetric.set(metric, indicator);
+                this._tabIndexForIndicator.set(indicator, currentTabIndex);
+                this._coordinateForIndicator.set(indicator, [rowIndex, columnIndex]);
+
+                ++currentTabIndex;
+                if (!this._firstIndicatorAnchor)
+                    this._firstIndicatorAnchor = anchor;
+                return cell;
+            });
+            this._indicatorAnchorGrid.push(indicatorAnchorsInCurrentRow);
+
+            const row = element('tr', [element('th', {class: 'row-head'}, platform.label()), ...cells]);
+            return row;
+        });
+
+        const tableBody = element('tbody', tableBodyElement);
+        tableBody.onscroll = this.createEventHandler(() => this._clearIndicatorState(true));
+
+        this.renderReplace(this.content('test-health'), [element('thead', tableHeadElements), tableBody]);
     }
 
     _isValidPlatformMetricCombination(platform, metric)
@@ -130,21 +341,75 @@ class TestFreshnessPage extends PageWithHeading {
             && platform.hasMetric(metric);
     }
 
-    _constructTableCell(platform, metric, indicatorByMetric)
+    _constructTableCell(platform, metric, tabIndex)
     {
         const element = ComponentBase.createElement;
-
+        const link = ComponentBase.createLink;
         if (!this._isValidPlatformMetricCombination(platform, metric))
-            return element('td', {class: 'blank-cell'}, element('div'));
+            return [element('td', {class: 'blank-cell'}, element('div')), null, null];
 
         const indicator = new FreshnessIndicator;
-        indicatorByMetric.set(metric, indicator);
-        return element('td', {class: 'status-cell'}, indicator);
+        const anchor = link(indicator, '', () => {
+            if (this._skipNextClick) {
+                this._skipNextClick = false;
+                return;
+            }
+            this._showTooltip = !this._showTooltip;
+            this.enqueueToRender();
+        }, false, tabIndex);
+
+        const cell = element('td', {class: 'status-cell'}, anchor);
+        this._configureAnchorForIndicator(anchor, indicator);
+        return [cell, anchor, indicator];
+    }
+
+    _configureAnchorForIndicator(anchor, indicator)
+    {
+        anchor.onmouseover = this.createEventHandler(() => {
+            this._hoveringIndicator = indicator;
+            this.enqueueToRender();
+        });
+        anchor.onmousedown = this.createEventHandler(() =>
+            this._skipNextClick = this._indicatorForTooltip != indicator, {preventDefault: false, stopPropagation: false});
+        anchor.onfocus = this.createEventHandler(() => {
+            this._showTooltip = this._indicatorForTooltip != indicator;
+            this._hoveringIndicator = indicator;
+            this._indicatorForTooltip = indicator;
+            this._lastFocusedCell = anchor;
+            this._skipNextStateCleanOnScroll = true;
+            this.enqueueToRender();
+        });
+        anchor.onkeydown = this.createEventHandler((event) => {
+            if (event.code == 'Escape') {
+                event.preventDefault();
+                event.stopPropagation();
+                this._showTooltip = event.code == 'Enter' ? !this._showTooltip : false;
+                this.enqueueToRender();
+            }
+        }, {preventDefault: false, stopPropagation: false});
+    }
+
+    _clearIndicatorState(dueToScroll)
+    {
+        if (this._skipNextStateCleanOnScroll) {
+            this._skipNextStateCleanOnScroll = false;
+            if (dueToScroll)
+                return;
+        }
+        this._showTooltip = false;
+        this._indicatorForTooltip = null;
+        this._hoveringIndicator = null;
+        this.enqueueToRender();
     }
 
     static htmlTemplate()
     {
-        return `<section class="page-with-heading"><table id="test-health"></table></section>`;
+        return `<section class="page-with-heading">
+            <table id="test-health"></table>
+            <div id="tooltip-anchor">
+                <table id="tooltip-table"></table>
+            </div>
+        </section>`;
     }
 
     static cssTemplate()
@@ -157,26 +422,42 @@ class TestFreshnessPage extends PageWithHeading {
             #test-health {
                 font-size: 1rem;
             }
+            #test-health thead {
+                display: block;
+                align: right;
+            }
             #test-health th.table-corner {
                 text-align: right;
                 vertical-align: bottom;
             }
+            #test-health .row-head {
+                min-width: 15.5rem;
+            }
             #test-health th {
                 text-align: left;
                 border-bottom: 0.1rem solid #ccc;
                 font-weight: normal;
             }
-            #test-health th.diagonal-header {
+            #test-health th.diagonal-head {
                 white-space: nowrap;
                 height: 16rem;
+                width: 2.2rem;
                 border-bottom: 0rem;
+                padding: 0;
             }
-            #test-health th.diagonal-header > div {
-                transform: translate(1rem, 7rem) rotate(315deg);
-                width: 2rem;
+            #test-health th.diagonal-head > div {
+                transform: translate(1.1rem, 7.5rem) rotate(315deg);
+                transform-origin: center left;
+                width: 2.2rem;
                 border: 0rem;
             }
+            #test-health tbody {
+                display: block;
+                overflow: auto;
+                height: calc(100vh - 24rem);
+            }
             #test-health td.status-cell {
+                position: relative;
                 margin: 0;
                 padding: 0;
                 max-width: 2.2rem;
@@ -184,6 +465,24 @@ class TestFreshnessPage extends PageWithHeading {
                 min-width: 2.2rem;
                 min-height: 2.2rem;
             }
+            #test-health td.status-cell>a {
+                display: block;
+            }
+            #test-health td.status-cell>a:focus {
+                outline: none;
+            }
+            #test-health td.status-cell>a:focus::after {
+                position: absolute;
+                content: "";
+                bottom: -0.1rem;
+                left: 50%;
+                margin-left: -0.2rem;
+                height: 0rem;
+                border-width: 0.2rem;
+                border-style: solid;
+                border-color: transparent transparent red transparent;
+                outline: none;
+            }
             #test-health td.blank-cell {
                 margin: 0;
                 padding: 0;
@@ -196,32 +495,75 @@ class TestFreshnessPage extends PageWithHeading {
                 background-color: #F9F9F9;
                 height: 1.6rem;
                 width: 1.6rem;
-                margin: 0.1rem;
+                margin: auto;
                 padding: 0;
                 position: relative;
                 overflow: hidden;
             }
             #test-health td.blank-cell > div::before {
-              content: "";
-              position: absolute;
-              top: -1px;
-              left: -1px;
-              display: block;
-              width: 0px;
-              height: 0px;
-              border-right: calc(1.6rem + 1px) solid #ddd;
-              border-top: calc(1.6rem + 1px) solid transparent;
+                content: "";
+                position: absolute;
+                top: -1px;
+                left: -1px;
+                display: block;
+                width: 0px;
+                height: 0px;
+                border-right: calc(1.6rem + 1px) solid #ddd;
+                border-top: calc(1.6rem + 1px) solid transparent;
             }
             #test-health td.blank-cell > div::after {
-              content: "";
-              display: block;
-              position: absolute;
-              top: 1px;
-              left: 1px;
-              width: 0px;
-              height: 0px;
-              border-right: calc(1.6rem - 1px) solid #F9F9F9;
-              border-top: calc(1.6rem - 1px) solid transparent;
+                content: "";
+                display: block;
+                position: absolute;
+                top: 1px;
+                left: 1px;
+                width: 0px;
+                height: 0px;
+                border-right: calc(1.6rem - 1px) solid #F9F9F9;
+                border-top: calc(1.6rem - 1px) solid transparent;
+            }
+            #tooltip-anchor {
+                width: 0;
+                height: 0;
+                position: absolute;
+            }
+            #tooltip-table {
+                position: absolute;
+                width: 22rem;
+                background-color: #696969;
+                opacity: 0.96;
+                margin: 0.3rem;
+                padding: 0.3rem;
+                border-radius: 0.4rem;
+                z-index: 1;
+                text-align: center;
+                display: inline-table;
+                color: white;
+                bottom: 0;
+                left: -11.3rem;
+            }
+            #tooltip-table td {
+                overflow: hidden;
+                max-width: 22rem;
+                text-overflow: ellipsis;
+            }
+            #tooltip-table::after {
+                content: " ";
+                position: absolute;
+                top: 100%;
+                left: 50%;
+                margin-left: -0.3rem;
+                border-width: 0.3rem;
+                border-style: solid;
+                border-color: #696969 transparent transparent transparent;
+            }
+            #tooltip-table a {
+                color: white;
+                font-weight: bold;
+            }
+            #tooltip-table a:focus {
+                background-color: #AAB7B8;
+                outline: none;
             }
         `;
     }