diff --git a/packages/@best/api-db/src/sql/db.ts b/packages/@best/api-db/src/sql/db.ts index f8d3c36f..f2ad2b6b 100644 --- a/packages/@best/api-db/src/sql/db.ts +++ b/packages/@best/api-db/src/sql/db.ts @@ -19,7 +19,7 @@ export abstract class SQLDatabase { abstract query(text: string, params: any[]): Promise fetchProjects(): Promise { - return this.query('SELECT * FROM projects', []) + return this.query('SELECT * FROM projects ORDER BY created_at', []) } fetchSnapshots(projectId: number, since: Date | undefined): Promise { diff --git a/packages/@best/frontend/package.json b/packages/@best/frontend/package.json index c73b9a84..72f3d4b2 100644 --- a/packages/@best/frontend/package.json +++ b/packages/@best/frontend/package.json @@ -8,9 +8,9 @@ "@best/github-integration": "4.0.0", "@best/types": "4.0.0", "@lwc/rollup-plugin": "^1.0.0", + "apicache": "^1.4.0", "compression": "^1.7.4", "express": "^4.17.1", - "helmet": "^3.18.0", "lwc-services": "^1", "query-string": "^6.6.0", "redux": "^4.0.1", @@ -22,11 +22,14 @@ "rollup-plugin-terser": "^5.0.0" }, "devDependencies": { + "@types/apicache": "^1.2.0", "@types/compression": "^0.0.36", "@types/express": "^4.16.1", "@types/helmet": "^0.0.43", "concurrently": "^4.1.0", + "fetch-mock": "^7.3.3", "nodemon": "^1.19.1", + "redux-mock-store": "^1.5.3", "ts-node": "^8.2.0" }, "engines": { diff --git a/packages/@best/frontend/server/api.ts b/packages/@best/frontend/server/api.ts index 87d09ab1..48c19c1c 100644 --- a/packages/@best/frontend/server/api.ts +++ b/packages/@best/frontend/server/api.ts @@ -1,4 +1,5 @@ -import { Router } from 'express' +import { Router, RequestHandler } from 'express' +import apicache from 'apicache' import { loadDbFromConfig } from '@best/api-db' import { GithubApplicationFactory } from '@best/github-integration' import { FrontendConfig } from '@best/types'; @@ -7,7 +8,11 @@ export default (config: FrontendConfig): Router => { const db = loadDbFromConfig(config); const router = Router() - router.get('/info/:commit', async (req, res): Promise => { + let cache = apicache.middleware; + const onlyStatus200: RequestHandler = (req, res): boolean => res.statusCode === 200; + const cacheSuccesses = cache('2 minutes', onlyStatus200); + + router.get('/info/:commit', cacheSuccesses, async (req, res): Promise => { const { commit } = req.params; if (config.githubConfig) { @@ -47,7 +52,7 @@ export default (config: FrontendConfig): Router => { } }) - router.get('/projects', async (req, res): Promise => { + router.get('/projects', cacheSuccesses, async (req, res): Promise => { try { await db.migrate() @@ -61,7 +66,7 @@ export default (config: FrontendConfig): Router => { } }) - router.get('/:project/snapshots', async (req, res): Promise => { + router.get('/:project/snapshots', cacheSuccesses, async (req, res): Promise => { const { project } = req.params const { since } = req.query diff --git a/packages/@best/frontend/server/index.ts b/packages/@best/frontend/server/index.ts index ceb924eb..9d3da79c 100644 --- a/packages/@best/frontend/server/index.ts +++ b/packages/@best/frontend/server/index.ts @@ -1,5 +1,4 @@ import express from 'express' -import helmet from 'helmet' import compression from 'compression' import * as path from 'path' import { FrontendConfig } from '@best/types'; @@ -17,7 +16,6 @@ export const Frontend = (config: FrontendConfig): express.Application => { const app: express.Application = express() - app.use(helmet()) app.use(compression()) // API diff --git a/packages/@best/frontend/src/index.html b/packages/@best/frontend/src/index.html index ebc89d26..1a97471b 100755 --- a/packages/@best/frontend/src/index.html +++ b/packages/@best/frontend/src/index.html @@ -5,7 +5,7 @@ Best Performance - - + diff --git a/packages/@best/frontend/src/index.js b/packages/@best/frontend/src/index.js index 915b6e43..cd69a7c7 100755 --- a/packages/@best/frontend/src/index.js +++ b/packages/@best/frontend/src/index.js @@ -1,8 +1,8 @@ import { buildCustomElementConstructor, register } from 'lwc'; import { registerWireService } from '@lwc/wire-service'; -import MyApp from 'my/app'; +import App from 'view/app'; registerWireService(register); -customElements.define('my-app', buildCustomElementConstructor(MyApp)); \ No newline at end of file +customElements.define('view-app', buildCustomElementConstructor(App)); \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/graph/graph.css b/packages/@best/frontend/src/modules/component/benchmark/benchmark.css similarity index 56% rename from packages/@best/frontend/src/modules/component/graph/graph.css rename to packages/@best/frontend/src/modules/component/benchmark/benchmark.css index 4e9f1961..0bd09b12 100755 --- a/packages/@best/frontend/src/modules/component/graph/graph.css +++ b/packages/@best/frontend/src/modules/component/benchmark/benchmark.css @@ -1,29 +1,46 @@ +.header { + display: flex; + flex-direction: row; + align-items: center; -/* - * We need to have this css here from the Plotly library because otherwise the shadow dom - * restricts this css from applying to our graphs. -*/ + position: relative; + margin: 0 50px; + margin-bottom: 5px; -.js-plotly-plot .plotly, .js-plotly-plot .plotly div { - font-family:'Open Sans', verdana, arial, sans-serif; - margin:0; - padding:0; + z-index: 1; } -.js-plotly-plot .plotly input, .js-plotly-plot .plotly button { - font-family:'Open Sans', verdana, arial, sans-serif; +.title { + margin: 0; + text-align: center; + font-weight: normal; } -.js-plotly-plot .plotly input:focus,.js-plotly-plot .plotly button:focus { - outline:none; +.compare-actions { + position: absolute; + right: 0; } -.js-plotly-plot .plotly a { - text-decoration:none; +.compare-actions > * { + margin: 0 10px; } -.js-plotly-plot .plotly a:hover { - text-decoration:none; +.container[data-first="true"] .graph-wrapper { + height: 460px; +} + +.container[data-first="false"] .graph-wrapper { + height: 400px; +} + +/* + * We need to have this css here from the Plotly library because otherwise the shadow dom + * restricts this css from applying to our graphs. +*/ + +.js-plotly-plot .plotly, .js-plotly-plot .plotly div { + margin:0; + padding:0; } .js-plotly-plot .plotly .crisp { @@ -125,71 +142,6 @@ cursor:ne-resize; } -.js-plotly-plot .plotly .modebar { - position:absolute; - top:2px; - right:2px; - z-index:1001; - background:rgba(255,255,255,0.7); -} - -.js-plotly-plot .plotly .modebar--hover { - opacity:0; - -webkit-transition:opacity 0.3s ease 0s; - -moz-transition:opacity 0.3s ease 0s; - -ms-transition:opacity 0.3s ease 0s; - -o-transition:opacity 0.3s ease 0s; - transition:opacity 0.3s ease 0s; -} - -.js-plotly-plot .plotly:hover .modebar--hover { - opacity:1; -} - -.js-plotly-plot .plotly .modebar-group { - float:left; - display:inline-block; - box-sizing:border-box; - margin-left:8px; - position:relative; - vertical-align:middle; - white-space:nowrap; -} - -.js-plotly-plot .plotly .modebar-group:first-child { - margin-left:0px; -} - -.js-plotly-plot .plotly .modebar-btn { - position:relative; - font-size:16px; - padding:3px 4px; - cursor:pointer; - line-height:normal; - box-sizing:border-box; -} - -.js-plotly-plot .plotly .modebar-btn svg { - position:relative; - top:2px; -} - -.js-plotly-plot .plotly .modebar-btn path { - fill:rgba(0,31,95,0.3); -} - -.js-plotly-plot .plotly .modebar-btn.active path,.js-plotly-plot .plotly .modebar-btn:hover path { - fill:rgba(0,22,72,0.5); -} - -.js-plotly-plot .plotly .modebar-btn.modebar-btn--logo { - padding:3px 1px; -} - -.js-plotly-plot .plotly .modebar-btn.modebar-btn--logo path { - fill:#447adb !important; -} - .js-plotly-plot .plotly [data-title]:before,.js-plotly-plot .plotly [data-title]:after { position:absolute; -webkit-transform:translate3d(0, 0, 0); @@ -246,48 +198,4 @@ .js-plotly-plot .plotly .select-outline-2 { stroke:black; stroke-dasharray:2px 2px; -} - -.plotly-notifier { - font-family:'Open Sans'; - position:fixed; - top:50px; - right:20px; - z-index:10000; - font-size:10pt; - max-width:180px; -} - -.plotly-notifier p { - margin:0; -} - -.plotly-notifier .notifier-note { - min-width:180px; - max-width:250px; - border:1px solid #fff; - z-index:3000; - margin:0; - background-color:#8c97af; - background-color:rgba(140,151,175,0.9); - color:#fff; - padding:10px; -} - -.plotly-notifier .notifier-close { - color:#fff; - opacity:0.8; - float:right; - padding:0 5px; - background:none; - border:none; - font-size:20px; - font-weight:bold; - line-height:20px; -} - -.plotly-notifier .notifier-close:hover { - color:#444; - text-decoration:none; - cursor:pointer; } \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/benchmark/benchmark.html b/packages/@best/frontend/src/modules/component/benchmark/benchmark.html new file mode 100755 index 00000000..f1a07f5f --- /dev/null +++ b/packages/@best/frontend/src/modules/component/benchmark/benchmark.html @@ -0,0 +1,26 @@ + \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/benchmark/benchmark.js b/packages/@best/frontend/src/modules/component/benchmark/benchmark.js new file mode 100755 index 00000000..480e49e1 --- /dev/null +++ b/packages/@best/frontend/src/modules/component/benchmark/benchmark.js @@ -0,0 +1,287 @@ +import { LightningElement, track, api, wire } from 'lwc'; +import { drawPlot, buildTrends, buildLayout, relayout, createAnnotation, removeAnnotation, createInconsistencyAnnotation } from './plots'; +import { findInconsistencies } from './utils'; + +import { connectStore, store } from 'store/store'; +import { fetchComparison, comparisonChanged } from 'store/actions'; + +export default class ComponentBenchmark extends LightningElement { + // PROPERTIES + + element; + hasRegisteredHandlers = false; + currentLayout = {}; + + @track selectedPoints = []; + recentHoverData; + + comparisonElement; + @track pendingCommitsToCompare = new Set(); + @track viewComparisonCommits = []; + @track comparisonResults = {}; + @track comparisonName = null; + + @api metric = 'all'; + + @wire(connectStore, { store }) + storeChanged({ view }) { + this.comparisonResults = view.comparison.results; + this.viewComparisonCommits = view.comparison.commits; + this.comparisonName = view.comparison.benchmarkName; + this.metric = view.metric; + } + + _first; + @api + get first() { + return this._first; + } + + set first(first) { + this._first = first; + + this.currentLayout = buildLayout(this.benchmark.name, this.first); + + if (this.element) { + relayout(this.element, this.currentLayout); + + // eslint-disable-next-line lwc/no-raf, @lwc/lwc/no-async-operation + window.requestAnimationFrame(() => { + this.updateGraphZoom(); + }) + } + } + + allTrends = []; + @track visibleTrends = []; + + _benchmark; + @api + get benchmark() { + return this._benchmark; + } + + set benchmark(benchmark) { + this._benchmark = benchmark; + this.allTrends = buildTrends(benchmark); + } + + hasSetInitialZoom = false; + _zoom; + @api + get zoom() { + return this._zoom; + } + + set zoom(zoom) { + this._zoom = zoom; + + if (!zoom.origin || zoom.origin !== this.benchmark.name) { + this.updateGraphZoom(); + } + } + + // GETTERS + + get comparing() { + return this.pendingCommitsToCompare.size > 0; + } + + get showingComparison() { + return this.viewComparisonCommits.length > 0; + } + + get hasComparisonResults() { + return Object.keys(this.comparisonResults).length > 0; + } + + get containerClassNames() { + return this.comparing ? 'comparing container' : 'container'; + } + + get comparisonModalTitle() { + return `Comparing on ${this.comparisonName}` + } + + // METHODS + + handleRelayout(update) { + const firstKey = Object.keys(update).shift(); + if (this.first && firstKey && firstKey.includes('xaxis')) { // make sure we are talking about zoom updates + this.dispatchEvent(new CustomEvent('zoom', { + detail: { + update: { + ...update, + origin: this.benchmark.name + } + } + })) + } + } + + updateGraphZoom() { + if (this.element) { + this.currentLayout = relayout(this.element, this.zoom); + } + } + + closeCommitInfo(event) { + const { commit } = event.detail; + + this.selectedPoints.every((point, idx) => { + if (point.commit === commit) { + if (! point.pendingCompare) { + this.currentLayout = removeAnnotation(this.element, commit); + } + this.selectedPoints.splice(idx, 1); + return false; + } + + return true; + }) + } + + rawClickHandler(event) { + const grandParent = event.target.parentElement.parentElement; + + if (grandParent !== this.element && this.recentHoverData) { + this.traceClicked(); + } + } + + traceClicked() { + const point = this.recentHoverData.points[0]; + + const { x: commit } = point; + const top = this.first ? 400 * 1.15 : 400; + const left = point.xaxis.l2p(point.xaxis.d2c(point.x)) + point.xaxis._offset; + const commitInfo = { commit, top, left, hidden: false, pendingCompare: this.pendingCommitsToCompare.has(commit) }; + + const needsInsertion = this.selectedPoints.every((pastPoint, idx) => { + if (pastPoint.commit === commit && !pastPoint.hidden) { + return false; + } else if (pastPoint.commit === commit && pastPoint.hidden) { + this.selectedPoints[idx] = { ...commitInfo }; + return false; + } + + return true; + }) + + if (needsInsertion && !this.comparing) { + this.selectedPoints.push(commitInfo); + this.currentLayout = createAnnotation(this.element, point); + } else if (needsInsertion && this.comparing) { + this.pendingCommitsToCompare.add(commit); + this.selectedPoints.push({ ...commitInfo, hidden: true, pendingCompare: true }); + this.currentLayout = createAnnotation(this.element, point); + } + } + + hoverHandler(data) { + this.recentHoverData = data; + } + + updateVisibleTrends() { + if (this.allTrends.length > 0) { + this.visibleTrends = this.metric === 'all' ? this.allTrends : this.allTrends.filter(trend => trend.name.includes(this.metric)); + } + } + + onCompareClick(event) { + const { commit } = event.detail; + + const beingCompared = this.pendingCommitsToCompare.has(commit); + + if (beingCompared) { + this.pendingCommitsToCompare.delete(commit) + + this.selectedPoints.every((pastPoint, idx) => { + if (pastPoint.commit === commit) { + this.selectedPoints[idx] = { ...pastPoint, pendingCompare: false }; + return false; + } + + return true; + }) + } else { + this.pendingCommitsToCompare.add(commit); + + this.selectedPoints.every((pastPoint, idx) => { + if (pastPoint.commit === commit && !pastPoint.hidden) { + this.selectedPoints[idx] = { ...pastPoint, hidden: true, pendingCompare: true }; + return false; + } + + return true; + }) + } + } + + runComparison() { + store.dispatch(fetchComparison(this.benchmark.name, [...this.pendingCommitsToCompare])); + } + + cancelComparison() { + this.pendingCommitsToCompare.forEach(commit => { + this.currentLayout = removeAnnotation(this.element, commit); + }) + + this.pendingCommitsToCompare = new Set(); + this.selectedPoints = []; + } + + closeModal() { + this.comparisonElement = null; + store.dispatch(comparisonChanged()); + } + + displayAnnotationsForInconsistencies() { + findInconsistencies(this.benchmark, 'environmentHashes').forEach(x => { + this.currentLayout = createInconsistencyAnnotation(this.element, x) + }) + findInconsistencies(this.benchmark, 'similarityHashes').forEach(x => { + this.currentLayout = createInconsistencyAnnotation(this.element, x) + }) + } + + async renderedCallback() { + if (!this.element) this.element = this.template.querySelector('.graph'); + + this.updateVisibleTrends(); + + this.currentLayout = await drawPlot(this.element, this.visibleTrends, this.currentLayout); + + if (!this.hasRegisteredHandlers) { + this.hasRegisteredHandlers = true; + this.element.addEventListener('click', event => this.rawClickHandler(event)); + + this.element.on('plotly_hover', data => this.hoverHandler(data)); + + this.element.on('plotly_relayout', update => this.handleRelayout(update)); + } + + if (!this.hasSetInitialZoom) { + this.hasSetInitialZoom = true; + this.updateGraphZoom(); + + // this.displayAnnotationsForInconsistencies(); + } + + // COMPARISON + // fetch comparison results if all we have is the commits from the url + if (this.showingComparison && !this.hasComparisonResults && this.comparisonName === this.benchmark.name) { + store.dispatch(fetchComparison(this.benchmark.name, this.viewComparisonCommits)); + } + + if (this.showingComparison && this.hasComparisonResults) { + if (! this.comparisonElement) this.comparisonElement = this.template.querySelector('.comparison-graph'); + + if (this.comparisonElement) { + const comparisonTrend = buildTrends(this.comparisonResults, true, true); + const initialComparisonLayout = buildLayout(this.comparisonResults.name, false); + await drawPlot(this.comparisonElement, comparisonTrend, initialComparisonLayout) + } + } + } +} \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/graph/plots.js b/packages/@best/frontend/src/modules/component/benchmark/plots.js similarity index 64% rename from packages/@best/frontend/src/modules/component/graph/plots.js rename to packages/@best/frontend/src/modules/component/benchmark/plots.js index 8fcc1950..2f23616f 100755 --- a/packages/@best/frontend/src/modules/component/graph/plots.js +++ b/packages/@best/frontend/src/modules/component/benchmark/plots.js @@ -1,7 +1,24 @@ +const colorForName = (name, fill) => { + const normalizedName = name.replace(/-(low|high)/, '') + + const colors = { + "script": ['#241E4E', 'rgba(36, 30, 78, 0.08)'], + "aggregate": ['#FF1E4E', 'rgba(255, 30, 78, 0.08)'], + "paint": ['#17BECF', 'rgba(23, 190, 207, 0.15)'], + "layout": ['#27D046', 'rgba(39, 208, 70, 0.08)'], + "system": ['#9DA39A', 'rgba(157, 163, 154, 0.08)'], + "idle": ['#54494B', 'rgba(84, 73, 75, 0.08)'], + 'first-paint': ['#17BECF', 'rgba(23, 190, 207, 0.15)'], 'duration': ['#FF1E4E', 'rgba(255, 30, 78, 0.08)'] // old metrics + } + + const colorIndex = fill ? 0 : 1; + + return colors[normalizedName][colorIndex]; +} + export function buildLayout(title, isFirst) { return { height: isFirst ? 400 * 1.15 : 400, - title, xaxis: { title: 'Commits', fixedrange: isFirst ? false : true, @@ -13,9 +30,20 @@ export function buildLayout(title, isFirst) { title: 'ms', zeroline: false }, - showlegend: false, + showlegend: isFirst ? true : false, + legend: { + x: 1, + y: 1.02, + orientation: 'h', + traceorder: 'reversed', + itemclick: false, + itemdoubleclick: false, + xanchor: 'right' + }, side: 'bottom', - colorway: ['#e7a4b6', '#17BECF'] + margin: { + t: 0, + } }; } @@ -24,15 +52,17 @@ function buildLineTrend({ dates, values, name, commits }, showsVariation) { y: values, x: commits.map(commit => commit.slice(0, 7)), text: dates, - mode: 'lines', + mode: 'lines+markers', name, line: { shape: 'spline', - width: 3 + width: 2, + color: colorForName(name, true) }, opacity: 0.8, type: 'scatter', - hoverinfo: 'text+y+x', + hoveron: 'points+fills', + hovertemplate: '%{y}ms
%{text}', fill: showsVariation ? 'none' : 'tozeroy' }; } @@ -49,10 +79,10 @@ function buildVarianceTrend({ dates, values, name, commits }) { color: 'transparent' }, fill: 'tonexty', - fillcolor: name.includes('high') ? 'rgba(0, 0, 50, 0.1)' : 'transparent', - // fillcolor: 'rgba(0, 0, 50, 0.1)', + fillcolor: name.includes('high') ? colorForName(name, false) : 'transparent', showlegend: false, - hoverinfo: 'skip' + hoverinfo: 'skip', + hoveron: 'fills' }; } @@ -123,7 +153,8 @@ export async function drawPlot(element, trends, layout) { displayModeBar: false, scrollZoom: false, showTips: false, - responsive: true + responsive: true, + doubleClick: false }); return element.layout; @@ -148,6 +179,7 @@ export function createAnnotation(element, point) { ax: 0, ay: point.yaxis.range[1], ayref: 'y', + _commit: point.x } const newIndex = (element.layout.annotations || []).length; @@ -160,8 +192,39 @@ export function createAnnotation(element, point) { return relayout(element, update); } -export function removeAnnotation(element, idx) { - window.Plotly.relayout(element, `annotations[${idx}]`, 'remove'); +export function createInconsistencyAnnotation(element, x) { + const annotation = { + x, + y: element.layout.yaxis.range[0], + xref: 'x', + yref: 'y', + showarrow: true, + arrowcolor: '#f00', + text: '⚠️', + arrowhead: 0, + ax: 0, + ay: element.layout.yaxis.range[0], + ayref: 'y' + } + + const newIndex = (element.layout.annotations || []).length; + + const update = { + [`annotations[${newIndex}]`]: annotation, + 'yaxis.range': element.layout.yaxis.range // we don't want Plotly to change the yaxis bc of the annotation + } + + return relayout(element, update); +} + +export function removeAnnotation(element, commit) { + element.layout.annotations.every((annotation, idx) => { + if (annotation._commit === commit) { + window.Plotly.relayout(element, `annotations[${idx}]`, 'remove'); + return false + } + return true; + }) return element.layout; } \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/benchmark/utils.js b/packages/@best/frontend/src/modules/component/benchmark/utils.js new file mode 100644 index 00000000..9f41516f --- /dev/null +++ b/packages/@best/frontend/src/modules/component/benchmark/utils.js @@ -0,0 +1,13 @@ +export const findInconsistencies = (set, property) => { + const values = [...set[property]]; + let previous = values.shift(); + const inconsistencies = values.reduce((badSet, value, idx) => { + if (previous !== value) { + previous = value; + badSet.push(idx + 1); + } + + return badSet; + }, []); + return inconsistencies; +} \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/button/__tests__/__snapshots__/button.spec.js.snap b/packages/@best/frontend/src/modules/component/button/__tests__/__snapshots__/button.spec.js.snap new file mode 100644 index 00000000..df7ff216 --- /dev/null +++ b/packages/@best/frontend/src/modules/component/button/__tests__/__snapshots__/button.spec.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`component-button has correct class with override flavor 1`] = ` + + #shadow-root(open) + + +`; + +exports[`component-button has correct class with override flavor and size 1`] = ` + + #shadow-root(open) + + +`; + +exports[`component-button has correct class with override size 1`] = ` + + #shadow-root(open) + + +`; + +exports[`component-button has correct default class 1`] = ` + + #shadow-root(open) + + +`; diff --git a/packages/@best/frontend/src/modules/component/button/__tests__/button.spec.js b/packages/@best/frontend/src/modules/component/button/__tests__/button.spec.js new file mode 100644 index 00000000..d4580dbd --- /dev/null +++ b/packages/@best/frontend/src/modules/component/button/__tests__/button.spec.js @@ -0,0 +1,59 @@ +import { createElement } from 'lwc' +import Button from 'component/button' + +describe('component-button', () => { + afterEach(() => { + // The jsdom instance is shared across test cases in a single file so reset the DOM + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + }); + + it('has correct default class', () => { + const element = createElement('component-button', { is: Button }) + + document.body.appendChild(element) + + expect(element).toMatchSnapshot(); + const button = element.shadowRoot.querySelector('button'); + const classes = [...button.classList] + expect(classes).toEqual(['primary', 'default']) + }) + + it('has correct class with override size', () => { + const element = createElement('component-button', { is: Button }) + element.size = 'small' + + document.body.appendChild(element) + + expect(element).toMatchSnapshot(); + const button = element.shadowRoot.querySelector('button'); + const classes = [...button.classList] + expect(classes).toEqual(['primary', 'small']) + }) + + it('has correct class with override flavor', () => { + const element = createElement('component-button', { is: Button }) + element.flavor = 'close' + + document.body.appendChild(element) + + expect(element).toMatchSnapshot(); + const button = element.shadowRoot.querySelector('button'); + const classes = [...button.classList] + expect(classes).toEqual(['close', 'default']) + }) + + it('has correct class with override flavor and size', () => { + const element = createElement('component-button', { is: Button }) + element.size = 'small' + element.flavor = 'close' + + document.body.appendChild(element) + + expect(element).toMatchSnapshot(); + const button = element.shadowRoot.querySelector('button'); + const classes = [...button.classList] + expect(classes).toEqual(['close', 'small']) + }) +}) \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/button/button.css b/packages/@best/frontend/src/modules/component/button/button.css new file mode 100644 index 00000000..211d42ae --- /dev/null +++ b/packages/@best/frontend/src/modules/component/button/button.css @@ -0,0 +1,41 @@ +button { + position: relative; + font: inherit; + + border-radius: 3px; + + border: none; + cursor: pointer; + text-transform: uppercase; + transition: all .1s ease; + outline: none; +} + +button:hover { + transform: translateY(-1px); +} + +button:active { + transform: translateY(1px); +} + +button.primary { + background: rgb(0, 163, 255); + box-shadow: 0 5px 8px rgba(0, 163, 255, 0.12); + color: #fff; +} + +button.default { + padding: 10px 14px; +} + +button.small { + padding: 8px 12px; + font-size: 85%; +} + +button.close { + background: rgb(62, 63, 66); + box-shadow: 0 5px 8px rgba(62, 63, 66, 0.14); + color: #fff; +} \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/button/button.html b/packages/@best/frontend/src/modules/component/button/button.html new file mode 100644 index 00000000..8aa7bf6f --- /dev/null +++ b/packages/@best/frontend/src/modules/component/button/button.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/button/button.js b/packages/@best/frontend/src/modules/component/button/button.js new file mode 100644 index 00000000..1ba12b46 --- /dev/null +++ b/packages/@best/frontend/src/modules/component/button/button.js @@ -0,0 +1,10 @@ +import { LightningElement, api } from 'lwc'; + +export default class ComponentButton extends LightningElement { + @api flavor = 'primary'; + @api size = 'default'; + + get classNames() { + return [this.flavor, this.size].join(' '); + } +} \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.css b/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.css index 5c3a8e8c..d620021b 100755 --- a/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.css +++ b/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.css @@ -4,12 +4,16 @@ .commit-info { position: absolute; - z-index: 1; + z-index: 3; margin-top: -30px; display: inline-block; min-width: 400px; } +.commit-info.hidden { + display: none; +} + .container { transform: translateX(-50%); padding: 15px; @@ -47,7 +51,7 @@ .info .user { display: flex; - flex-direction: horizontal; + flex-direction: row; align-items: center; position: relative; } diff --git a/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.html b/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.html index 9644dc2c..94a0ecb8 100755 --- a/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.html +++ b/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.html @@ -1,14 +1,14 @@ \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.js b/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.js index 427453a3..082dd95d 100755 --- a/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.js +++ b/packages/@best/frontend/src/modules/component/commitInfo/commitInfo.js @@ -7,6 +7,8 @@ export default class ComponentCommitInfo extends LightningElement { @api commit; @api top; @api left; + @api hidden; + @api pendingcompare; @track commitInfo = {}; @@ -16,7 +18,7 @@ export default class ComponentCommitInfo extends LightningElement { } get hasError() { - return this.commitInfo.hasOwnProperty('reason'); + return this.commitInfo.hasOwnProperty('error'); } get hasCommitInfo() { @@ -27,6 +29,14 @@ export default class ComponentCommitInfo extends LightningElement { return `transform: translate(${this.left}px, ${this.top}px)`; } + get classNames() { + return this.hidden ? 'hidden commit-info' : 'commit-info'; + } + + get compareButtonText() { + return this.pendingcompare ? 'Uncompare' : 'Compare'; + } + close() { this.dispatchEvent(new CustomEvent('close', { detail: { @@ -35,6 +45,14 @@ export default class ComponentCommitInfo extends LightningElement { })) } + compare() { + this.dispatchEvent(new CustomEvent('compare', { + detail: { + commit: this.commit + } + })) + } + renderedCallback() { if (!this.hasCommitInfo) { store.dispatch(fetchCommitInfoIfNeeded(this.commit)); diff --git a/packages/@best/frontend/src/modules/component/dropdown/__tests__/__snapshots__/dropdown.test.js.snap b/packages/@best/frontend/src/modules/component/dropdown/__tests__/__snapshots__/dropdown.spec.js.snap similarity index 100% rename from packages/@best/frontend/src/modules/component/dropdown/__tests__/__snapshots__/dropdown.test.js.snap rename to packages/@best/frontend/src/modules/component/dropdown/__tests__/__snapshots__/dropdown.spec.js.snap diff --git a/packages/@best/frontend/src/modules/component/dropdown/__tests__/dropdown.test.js b/packages/@best/frontend/src/modules/component/dropdown/__tests__/dropdown.spec.js similarity index 100% rename from packages/@best/frontend/src/modules/component/dropdown/__tests__/dropdown.test.js rename to packages/@best/frontend/src/modules/component/dropdown/__tests__/dropdown.spec.js diff --git a/packages/@best/frontend/src/modules/component/dropdown/dropdown.css b/packages/@best/frontend/src/modules/component/dropdown/dropdown.css index bbd359d9..a17b155f 100755 --- a/packages/@best/frontend/src/modules/component/dropdown/dropdown.css +++ b/packages/@best/frontend/src/modules/component/dropdown/dropdown.css @@ -4,7 +4,10 @@ overflow: visible; text-align: left; min-width: 200px; + max-width: 360px; margin: 0 20px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + box-sizing: border-box; } .control.open { @@ -20,7 +23,7 @@ } .items { - z-index: 1; + z-index: 5; width: calc(100% - 2px); position: absolute; @@ -42,12 +45,14 @@ .selected { border: 1px solid #8c8c8c; border-radius: 3px; - padding: 8px 12px; + padding: 10px 14px; + padding-right: 44px; /* icon padding */ position: relative; } .selected img { - float: right; + position: absolute; + right: 14px; margin-top: 5px; } diff --git a/packages/@best/frontend/src/modules/component/graph/graph.html b/packages/@best/frontend/src/modules/component/graph/graph.html deleted file mode 100755 index d8446db1..00000000 --- a/packages/@best/frontend/src/modules/component/graph/graph.html +++ /dev/null @@ -1,8 +0,0 @@ - \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/graph/graph.js b/packages/@best/frontend/src/modules/component/graph/graph.js deleted file mode 100755 index 5e70653a..00000000 --- a/packages/@best/frontend/src/modules/component/graph/graph.js +++ /dev/null @@ -1,169 +0,0 @@ -import { LightningElement, track, api } from 'lwc'; -import { drawPlot, buildTrends, buildLayout, relayout, createAnnotation, removeAnnotation } from './plots'; - -export default class ComponentGraph extends LightningElement { - element; - hasRegisteredHandlers = false; - currentLayout = {}; - - @track selectedPoints = []; - recentHoverData; - - @api metric; - - _first; - @api - get first() { - return this._first; - } - - set first(first) { - this._first = first; - - this.currentLayout = buildLayout(this.benchmark.name, this.first); - - if (this.element) { - relayout(this.element, this.currentLayout); - - // eslint-disable-next-line lwc/no-raf, @lwc/lwc/no-async-operation - window.requestAnimationFrame(() => { - this.updateGraphZoom(); - }) - } - } - - allTrends = []; - @track visibleTrends = []; - - _benchmark; - @api - get benchmark() { - return this._benchmark; - } - - set benchmark(benchmark) { - this._benchmark = benchmark; - this.allTrends = buildTrends(benchmark); - } - - hasSetInitialZoom = false; - _zoom; - @api - get zoom() { - return this._zoom; - } - - set zoom(zoom) { - this._zoom = zoom; - - if (!zoom.origin || zoom.origin !== this.benchmark.name) { - this.updateGraphZoom(); - } - } - - handleRelayout(update) { - const firstKey = Object.keys(update).shift(); - if (this.first && firstKey && firstKey.includes('xaxis')) { // make sure we are talking about zoom updates - this.dispatchEvent(new CustomEvent('zoom', { - detail: { - update: { - ...update, - origin: this.benchmark.name - } - } - })) - } - } - - updateGraphZoom() { - if (this.element) { - this.currentLayout = relayout(this.element, this.zoom); - } - } - - closeCommitInfo(event) { - const { commit } = event.detail; - - this.selectedPoints.every((point, idx) => { - if (point.commit === commit) { - this.currentLayout = removeAnnotation(this.element, idx); - this.selectedPoints.splice(idx, 1); - return false; - } - - return true; - }) - } - - timeout = null; - rawClickHandler(event) { - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = null; - } else { - const grandParent = event.target.parentElement.parentElement; - // eslint-disable-next-line @lwc/lwc/no-async-operation - this.timeout = setTimeout(() => { - if (grandParent !== this.element && this.recentHoverData) { - this.traceClicked(); - } - - this.timeout = null; - }, 200); - } - } - - traceClicked() { - const point = this.recentHoverData.points[0]; - - const { x: commit } = point; - const top = this.first ? 400 * 1.15 : 400; - const left = point.xaxis.l2p(point.xaxis.d2c(point.x)) + point.xaxis._offset; - const commitInfo = { commit, top, left } - - this.selectedPoints.every((pastPoint, idx) => { - if (pastPoint.commit === commit) { - this.selectedPoints.splice(idx, 1); - this.currentLayout = removeAnnotation(this.element, idx); - return false; - } - - return true; - }) - - this.selectedPoints.push(commitInfo); - this.currentLayout = createAnnotation(this.element, point); - } - - hoverHandler(data) { - this.recentHoverData = data; - } - - updateVisibleTrends() { - if (this.allTrends.length > 0) { - this.visibleTrends = this.metric === 'all' ? this.allTrends : this.allTrends.filter(trend => trend.name.includes(this.metric)); - } - } - - async renderedCallback() { - if (!this.element) this.element = this.template.querySelector('.graph'); - - this.updateVisibleTrends(); - - this.currentLayout = await drawPlot(this.element, this.visibleTrends, this.currentLayout); - - if (!this.hasRegisteredHandlers) { - this.hasRegisteredHandlers = true; - this.element.addEventListener('click', event => this.rawClickHandler(event)); - - this.element.on('plotly_hover', data => this.hoverHandler(data)); - - this.element.on('plotly_relayout', update => this.handleRelayout(update)); - } - - if (!this.hasSetInitialZoom) { - this.hasSetInitialZoom = true; - this.updateGraphZoom(); - } - } -} \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/menubar/menubar.css b/packages/@best/frontend/src/modules/component/menubar/menubar.css index 2abc0ad1..99a0d738 100755 --- a/packages/@best/frontend/src/modules/component/menubar/menubar.css +++ b/packages/@best/frontend/src/modules/component/menubar/menubar.css @@ -1,4 +1,8 @@ section { margin: 40px auto; text-align: center; +} + +.padded { + margin: 0 20px; } \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/menubar/menubar.html b/packages/@best/frontend/src/modules/component/menubar/menubar.html index 0a758c17..904742c1 100755 --- a/packages/@best/frontend/src/modules/component/menubar/menubar.html +++ b/packages/@best/frontend/src/modules/component/menubar/menubar.html @@ -3,5 +3,8 @@ + \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/menubar/menubar.js b/packages/@best/frontend/src/modules/component/menubar/menubar.js index f61af730..a76e1171 100755 --- a/packages/@best/frontend/src/modules/component/menubar/menubar.js +++ b/packages/@best/frontend/src/modules/component/menubar/menubar.js @@ -1,7 +1,7 @@ import { LightningElement, track, wire } from 'lwc'; import { connectStore, store } from 'store/store'; -import { timingChanged, benchmarksChanged, metricsChanged } from 'store/actions'; +import { timingChanged, benchmarksChanged, metricsChanged, zoomChanged } from 'store/actions'; export default class ComponentMenubar extends LightningElement { @@ -11,6 +11,7 @@ export default class ComponentMenubar extends LightningElement { @track viewTiming; @track viewBenchmark; @track viewMetric; + @track viewZoom = {}; @wire(connectStore, { store }) storeChange({ benchmarks, view }) { @@ -22,6 +23,7 @@ export default class ComponentMenubar extends LightningElement { this.viewTiming = view.timing; this.viewBenchmark = view.benchmark; this.viewMetric = view.metric; + this.viewZoom = view.zoom; } get timingOptions() { @@ -83,4 +85,14 @@ export default class ComponentMenubar extends LightningElement { const metric = event.detail.selectedItems[0]; store.dispatch(metricsChanged(metric.id)) } + + get isZoomed() { + return this.viewZoom.hasOwnProperty('xaxis.range') || this.viewZoom.hasOwnProperty('xaxis.range[0]'); + } + + zoomUpdated() { + store.dispatch(zoomChanged({ + 'xaxis.autorange': true + })) + } } \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/modal/modal.css b/packages/@best/frontend/src/modules/component/modal/modal.css new file mode 100644 index 00000000..43e3b8db --- /dev/null +++ b/packages/@best/frontend/src/modules/component/modal/modal.css @@ -0,0 +1,41 @@ +:host { + position: fixed; + top: 0; bottom: 0; + left: 0; right: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 9; + padding-left: 300px; +} + +.modal { + background: #fff; + border-radius: 6px; + width: 65vw; + height: 80vh; + overflow-y: scroll; + margin: 10vh auto; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4); +} + +.header { + display: flex; + flex-direction: row; + align-items: center; + + position: relative; + margin-bottom: 18px; +} + +.title { + margin: 0; + text-align: center; +} + +.close { + position: absolute; + right: 0; +} + +.content { + padding: 18px; +} \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/modal/modal.html b/packages/@best/frontend/src/modules/component/modal/modal.html new file mode 100644 index 00000000..1d464edb --- /dev/null +++ b/packages/@best/frontend/src/modules/component/modal/modal.html @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/component/modal/modal.js b/packages/@best/frontend/src/modules/component/modal/modal.js new file mode 100644 index 00000000..ab36d329 --- /dev/null +++ b/packages/@best/frontend/src/modules/component/modal/modal.js @@ -0,0 +1,9 @@ +import { LightningElement, api } from 'lwc'; + +export default class ComponentModal extends LightningElement { + @api title; + + close() { + this.dispatchEvent(new CustomEvent('close')) + } +} \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/store/actions/__tests__/commit-info-actions.spec.js b/packages/@best/frontend/src/modules/store/actions/__tests__/commit-info-actions.spec.js new file mode 100644 index 00000000..9f4ba19f --- /dev/null +++ b/packages/@best/frontend/src/modules/store/actions/__tests__/commit-info-actions.spec.js @@ -0,0 +1,85 @@ +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import fetchMock from 'fetch-mock' + +import * as types from 'store/shared' +import * as actions from 'store/actions' + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +const mockFetchAPI = (shoudError) => { + if (shoudError) { + const fullCommit = 'abcdefghijklmnop' + const shortCommit = 'abcdefg' + const response = { error: { reason: 'URL failed mocked!' } } + + fetchMock.getOnce(/api\/v1\/info/, { + body: response, + headers: { 'content-type': 'application/json' } + }) + + const expectedAction = { + type: types.COMMIT_INFO_RECEIVED, + commit: shortCommit, + commitInfo: response + } + + return { fullCommit, shortCommit, expectedAction } + } + + const fullCommit = 'abcdefghijklmnop' + const shortCommit = 'abcdefg' + const response = { + fullCommit: fullCommit, + body: 'commit body', + url: 'github.com/blahblah', + username: 'username', + profileImage: 'gravatar_url' + } + + fetchMock.getOnce(/api\/v1\/info/, { + body: { commit: response }, + headers: { 'content-type': 'application/json' } + }) + + const expectedAction = { + type: types.COMMIT_INFO_RECEIVED, + commit: shortCommit, + commitInfo: response + } + + return { fullCommit, shortCommit, expectedAction } +} + +describe('commit info actions', () => { + afterEach(() => { + fetchMock.restore() + }) + + describe('fetchCommitInfoIfNeeded', () => { + it('should dispatch commitInfoReceived with error after failing to fetch commit info', async () => { + const { fullCommit, expectedAction } = mockFetchAPI(true) + + const store = mockStore({ commitInfo: {} }) + await store.dispatch(actions.fetchCommitInfoIfNeeded(fullCommit)) + expect(store.getActions()).toEqual([expectedAction]) + }) + + it('should dispatch commitInfoReceived with success', async () => { + const { fullCommit, expectedAction } = mockFetchAPI(false) + + const store = mockStore({ commitInfo: {} }) + await store.dispatch(actions.fetchCommitInfoIfNeeded(fullCommit)) + expect(store.getActions()).toEqual([expectedAction]) + }) + + it('should NOT dispatch commitInfoReceived if store already has commit', async () => { + const { fullCommit, shortCommit, response } = mockFetchAPI(false) + + const store = mockStore({ commitInfo: { [shortCommit]: response } }) + await store.dispatch(actions.fetchCommitInfoIfNeeded(fullCommit)) + expect(store.getActions()).toEqual([]); + }) + }) +}) \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/store/actions/__tests__/projects-actions.spec.js b/packages/@best/frontend/src/modules/store/actions/__tests__/projects-actions.spec.js new file mode 100644 index 00000000..4e40dab2 --- /dev/null +++ b/packages/@best/frontend/src/modules/store/actions/__tests__/projects-actions.spec.js @@ -0,0 +1,125 @@ +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import fetchMock from 'fetch-mock' + +import * as types from 'store/shared' +import * as actions from 'store/actions' + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +const mockFetchProjects = () => { + const response = [{ + id: 1, + name: 'foo' + }, { + id: 2, + name: 'bar' + }] + + fetchMock.getOnce(/projects/, { + body: { projects: response }, + headers: { 'content-type': 'application/json' } + }) + + const expectedAction = { + type: types.PROJECTS_RECEIVED, + projects: response + } + + return { expectedAction, response } +} + +const mockSelectProject = () => { + const projectId = 1 + + const projects = { + items: [{ + id: projectId, + name: 'Hello' + }], + selectedProjectId: undefined + } + + const response = [{ + id: 1, + projectId, + name: 'bench-1', + commit: 'aaaaaaa', + commitDate: '2019-06-21 17:23:24', + metrics: [{'name': 'metric-a', 'duration': 5, 'stdDeviation': 1}], + environmentHash: 'asdf', + similarityHash: 'asdf' + }] + + const transformedResponse = [{ + commitDates: ['June 21'], + commits: ['aaaaaaa'], + environmentHashes: ['asdf'], + similarityHashes: ['asdf'], + metrics: [{'name': 'metric-a', 'durations': [5], 'stdDeviations': [1]}], + name: 'bench-1' + }] + + fetchMock.getOnce(/snapshots/, { + body: { snapshots: response }, + headers: { 'content-type': 'application/json' } + }) + + const benchmarksReceived = { + type: types.BENCHMARKS_RECEIVED, + snapshots: response, + benchmarks: transformedResponse + } + + const clearBenchmarks = { + type: types.CLEAR_BENCHMARKS + } + + const resetView = { + type: types.VIEW_RESET + } + + const projectSelected = { + type: types.PROJECT_SELECTED, + id: projectId + } + + const expectedActions = [clearBenchmarks, resetView, projectSelected, benchmarksReceived] + + return { expectedActions, projects, projectId } +} + +describe('projects actions', () => { + afterEach(() => { + fetchMock.restore() + }) + + describe('fetchProjectsIfNeeded', () => { + it('should dispatch projectsReceived with projects after fetch', async () => { + const { expectedAction } = mockFetchProjects() + + const store = mockStore({ projects: { items: [] } }) + await store.dispatch(actions.fetchProjectsIfNeeded()) + expect(store.getActions()).toEqual([expectedAction]) + }) + + it('should NOT dispatch projectsReceived if store already has projects', async () => { + const { response } = mockFetchProjects() + + const store = mockStore({ projects: { items: response } }) + await store.dispatch(actions.fetchProjectsIfNeeded()) + expect(store.getActions()).toEqual([]) + }) + }) + + describe('selectProject', () => { + it('should dispatch: clearBenchmarks, resetView, benchmarksReceived, and projectSelected', async () => { + const { expectedActions, projects, projectId } = mockSelectProject(); + + const store = mockStore({ projects, view: { timing: 'all' } }) + await store.dispatch(actions.selectProject({ id: projectId }, true)) + expect(store.getActions()).toEqual(expectedActions) + }) + }) +}) \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/store/actions/__tests__/actions.spec.js b/packages/@best/frontend/src/modules/store/actions/__tests__/view-actions.spec.js similarity index 75% rename from packages/@best/frontend/src/modules/store/actions/__tests__/actions.spec.js rename to packages/@best/frontend/src/modules/store/actions/__tests__/view-actions.spec.js index 03234005..92502d46 100644 --- a/packages/@best/frontend/src/modules/store/actions/__tests__/actions.spec.js +++ b/packages/@best/frontend/src/modules/store/actions/__tests__/view-actions.spec.js @@ -1,5 +1,5 @@ import * as types from 'store/shared' -import { benchmarksChanged, metricsChanged, zoomChanged, resetView } from 'store/actions' +import { benchmarksChanged, metricsChanged, zoomChanged, resetView, comparisonChanged } from 'store/actions' describe('view actions', () => { describe('benchmarksChanged', () => { @@ -41,6 +41,19 @@ describe('view actions', () => { }) }) + describe('comparisonChanged', () => { + it('should create an action for the comparison changing', () => { + const comparison = { commits: ['a', 'b'], results: {}, benchmarkName: 'test' } + + const expectedAction = { + type: types.VIEW_COMPARISON_CHANGED, + comparison + } + + expect(comparisonChanged(comparison)).toEqual(expectedAction) + }) + }) + describe('resetView', () => { it('should create an action for view resetting', () => { const expectedAction = { diff --git a/packages/@best/frontend/src/modules/store/actions/actions.js b/packages/@best/frontend/src/modules/store/actions/actions.js index cefbc8dd..b99cb90c 100755 --- a/packages/@best/frontend/src/modules/store/actions/actions.js +++ b/packages/@best/frontend/src/modules/store/actions/actions.js @@ -7,6 +7,7 @@ import { VIEW_BENCHMARKS_CHANGED, VIEW_METRICS_CHANGED, VIEW_ZOOM_CHANGED, + VIEW_COMPARISON_CHANGED, VIEW_RESET, COMMIT_INFO_RECEIVED } from 'store/shared'; @@ -14,8 +15,12 @@ import { import * as api from 'store/api'; import * as transformer from 'store/transformer'; +function normalizeCommit(commit) { + return commit.slice(0, 7); +} + function shouldFetchProjects(state) { - return !state.projects.length; + return !state.projects.items.length; } function projectsReceived(projects) { @@ -32,13 +37,15 @@ function fetchProjects() { export function fetchProjectsIfNeeded() { return (dispatch, getState) => { if (shouldFetchProjects(getState())) { - dispatch(fetchProjects()); + return dispatch(fetchProjects()); } + + return Promise.resolve() }; } -function benchmarksReceived(benchmarks) { - return { type: BENCHMARKS_RECEIVED, benchmarks }; +function benchmarksReceived(snapshots, benchmarks) { + return { type: BENCHMARKS_RECEIVED, snapshots, benchmarks }; } function clearBenchmarks() { @@ -50,7 +57,7 @@ function fetchBenchmarks(project) { const { timing } = getState().view; const snapshots = await api.fetchSnapshots(project, timing); const benchmarks = transformer.snapshotsToBenchmarks(snapshots); - dispatch(benchmarksReceived(benchmarks)); + dispatch(benchmarksReceived(snapshots, benchmarks)); }; } @@ -58,6 +65,10 @@ function findSelectedProject({ projects }) { return projects.items.find(proj => proj.id === projects.selectedProjectId); } +export function comparisonChanged(comparison) { + return { type: VIEW_COMPARISON_CHANGED, comparison }; +} + export function benchmarksChanged(benchmark) { return { type: VIEW_BENCHMARKS_CHANGED, benchmark }; } @@ -85,14 +96,29 @@ export function timingChanged(timing) { } } +function filterSnapshotsForCommits(benchmarkName, commits, state) { + const snapshotsForCommit = state.benchmarks.snapshots.filter(snap => commits.includes(normalizeCommit(snap.commit))); + const benchmark = transformer.snapshotsToBenchmarks(snapshotsForCommit).find(bench => bench.name === benchmarkName); + + return benchmark; +} + +export function fetchComparison(benchmarkName, commits) { + return (dispatch, getState) => { + const results = filterSnapshotsForCommits(benchmarkName, commits, getState()); + dispatch(comparisonChanged({ results, commits, benchmarkName })) + } +} + export function selectProject(project, shouldResetView) { - return (dispatch) => { + return async (dispatch) => { dispatch(clearBenchmarks()); if (shouldResetView) { dispatch(resetView()) } - dispatch(fetchBenchmarks(project)); dispatch({ type: PROJECT_SELECTED, id: project.id }); + + return dispatch(fetchBenchmarks(project)); }; } @@ -100,10 +126,6 @@ export function selectProject(project, shouldResetView) { * COMMIT INFO */ -function normalizeCommit(commit) { - return commit.slice(0, 7); -} - function shouldFetchCommitInfo(state, commit) { return !state.commitInfo.hasOwnProperty(normalizeCommit(commit)); } @@ -122,7 +144,9 @@ function fetchCommitInfo(commit) { export function fetchCommitInfoIfNeeded(commit) { return (dispatch, getState) => { if (shouldFetchCommitInfo(getState(), commit)) { - dispatch(fetchCommitInfo(commit)); + return dispatch(fetchCommitInfo(commit)); } + + return Promise.resolve() } } \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/store/api/api.js b/packages/@best/frontend/src/modules/store/api/api.js index 60eff718..5dfb3ed7 100755 --- a/packages/@best/frontend/src/modules/store/api/api.js +++ b/packages/@best/frontend/src/modules/store/api/api.js @@ -34,5 +34,5 @@ export async function fetchSnapshots(project, timing) { export async function fetchCommitInfo(commit) { const response = await fetch(createURL(`info/${commit}`)); const { commit: info, error } = await response.json(); - return info || error; + return info || { error }; } \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/store/api/mocked.js b/packages/@best/frontend/src/modules/store/api/mocked.js index 23c29e77..fdb076ea 100644 --- a/packages/@best/frontend/src/modules/store/api/mocked.js +++ b/packages/@best/frontend/src/modules/store/api/mocked.js @@ -10,4 +10,10 @@ export async function fetchProjects() { export async function fetchSnapshots(project, timing) { return getMocked().snapshots[project.id][timing]; +} + +export async function fetchCommitInfo() { + return { + reason: 'Not connected to server.' + } } \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/store/shared/shared.js b/packages/@best/frontend/src/modules/store/shared/shared.js index a41837a9..378943d1 100755 --- a/packages/@best/frontend/src/modules/store/shared/shared.js +++ b/packages/@best/frontend/src/modules/store/shared/shared.js @@ -8,6 +8,7 @@ export const VIEW_TIMING_CHANGED = 'VIEW_TIMING_CHANGED'; export const VIEW_BENCHMARKS_CHANGED = 'VIEW_BENCHMARKS_CHANGED'; export const VIEW_METRICS_CHANGED = 'VIEW_METRICS_CHANGED'; export const VIEW_ZOOM_CHANGED = 'VIEW_ZOOM_CHANGED'; +export const VIEW_COMPARISON_CHANGED = 'VIEW_COMPARISON_CHANGED'; export const VIEW_RESET ='VIEW_RESET'; export const COMMIT_INFO_RECEIVED = 'COMMIT_INFO_RECEIVED'; \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/store/store/__tests__/urlstorage.spec.js b/packages/@best/frontend/src/modules/store/store/__tests__/urlstorage.spec.js new file mode 100644 index 00000000..75e33a6c --- /dev/null +++ b/packages/@best/frontend/src/modules/store/store/__tests__/urlstorage.spec.js @@ -0,0 +1,93 @@ +import * as urlstorage from '../urlstorage'; + +const setLocation = (pathname, hash) => { + global.window = Object.create(window); + Object.defineProperty(window, 'location', { + writable: true, + value: { + pathname, + hash + } + }); +} + +describe('urlstorage', () => { + describe('loadState', () => { + it('loads nothing when state url is empty', () => { + setLocation('/', '') + + expect(urlstorage.loadState()).toEqual({}) + }) + + it('loads only selectedProjectId into projects from path', () => { + setLocation('/1', '') + + expect(urlstorage.loadState()).toEqual({ projects: { items: [], selectedProjectId: 1 } }) + }) + + it('loads only view with emtpy comparison from path', () => { + setLocation('/', '?benchmark=test1&timing=all&metric=first&zoom=4.5,1.2') + + const view = { + benchmark: 'test1', + timing: 'all', + metric: 'first', + zoom: { + 'xaxis.range': ['4.5', '1.2'] + }, + comparison: { + benchmarkName: undefined, + commits: [], + results: {} + } + } + + expect(urlstorage.loadState()).toEqual({ view }) + }) + + it('loads only view with full comparison from path', () => { + setLocation('/', '?benchmark=test1&timing=all&metric=first&zoom=auto&comparison=aaaaaaa,bbbbbbb&comparisonBenchmark=test1') + + const view = { + benchmark: 'test1', + timing: 'all', + metric: 'first', + zoom: { + 'xaxis.autorange': true + }, + comparison: { + benchmarkName: 'test1', + commits: ['aaaaaaa', 'bbbbbbb'], + results: {} + } + } + + expect(urlstorage.loadState()).toEqual({ view }) + }) + + it('loads selectedProjectId and view from path', () => { + setLocation('/2', '?benchmark=test1&timing=all&metric=first&zoom=auto&comparison=aaaaaaa,bbbbbbb&comparisonBenchmark=test1') + + const view = { + benchmark: 'test1', + timing: 'all', + metric: 'first', + zoom: { + 'xaxis.autorange': true + }, + comparison: { + benchmarkName: 'test1', + commits: ['aaaaaaa', 'bbbbbbb'], + results: {} + } + } + + const projects = { + items: [], + selectedProjectId: 2 + } + + expect(urlstorage.loadState()).toEqual({ view, projects }) + }) + }) +}) \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/store/store/reducers.js b/packages/@best/frontend/src/modules/store/store/reducers.js index 7ef0a29c..ace2fab0 100755 --- a/packages/@best/frontend/src/modules/store/store/reducers.js +++ b/packages/@best/frontend/src/modules/store/store/reducers.js @@ -7,6 +7,7 @@ import { VIEW_BENCHMARKS_CHANGED, VIEW_METRICS_CHANGED, VIEW_ZOOM_CHANGED, + VIEW_COMPARISON_CHANGED, VIEW_RESET, COMMIT_INFO_RECEIVED } from 'store/shared'; @@ -36,18 +37,21 @@ export function projects( export function benchmarks( state = { - items: [] + items: [], + snapshots: [] }, action ) { switch (action.type) { case CLEAR_BENCHMARKS: return { - items: [] + items: [], + snapshots: [] }; case BENCHMARKS_RECEIVED: return { - items: action.benchmarks + items: action.benchmarks, + snapshots: action.snapshots }; default: return state; @@ -59,7 +63,8 @@ export function view( timing: 'last-release', benchmark: 'all', metric: 'all', - zoom: {} // this goes directly to/from plotly + zoom: {}, // this goes directly to/from plotly, + comparison: { commits: [], results: {}, benchmarkName: null } }, action ) { @@ -84,13 +89,13 @@ export function view( ...state, zoom: action.zoom } - case VIEW_RESET: + case VIEW_COMPARISON_CHANGED: return { - timing: 'last-release', - benchmark: 'all', - metric: 'all', - zoom: {} + ...state, + comparison: action.comparison || { commits: [], results: {}, benchmarkName: null } } + case VIEW_RESET: + return view(undefined, {}) // returns default state default: return state } diff --git a/packages/@best/frontend/src/modules/store/store/urlstorage.js b/packages/@best/frontend/src/modules/store/store/urlstorage.js index 91514b7c..215a5681 100755 --- a/packages/@best/frontend/src/modules/store/store/urlstorage.js +++ b/packages/@best/frontend/src/modules/store/store/urlstorage.js @@ -46,13 +46,18 @@ function loadProjectFromPath() { function updateViewQueryIfNeeded(view) { const friendlyView = { ...view, - zoom: friendlyZoom(view.zoom) + zoom: friendlyZoom(view.zoom), + comparison: view.comparison.commits + } + + if (view.comparison.benchmarkName) { + friendlyView.comparisonBenchmark = view.comparison.benchmarkName; } - const newQuery = queryString.stringify(friendlyView); + const newQuery = queryString.stringify(friendlyView, { arrayFormat: 'comma' }); if (window.location.hash !== `#${newQuery}`) { - window.location.hash = queryString.stringify(friendlyView); + window.location.hash = newQuery; } } @@ -74,20 +79,21 @@ function loadViewFromQuery() { const hash = window.location.hash; if (hash.length > 0) { const query = hash.slice(1); - const parsedQuery = queryString.parse(query); + const parsedQuery = queryString.parse(query, { arrayFormat: 'comma' }); // TODO: ensure this is not going to be an issue for security const view = { benchmark: parsedQuery.benchmark, timing: parsedQuery.timing, metric: parsedQuery.metric, - zoom: loadFriendlyZoom(parsedQuery.zoom) + zoom: loadFriendlyZoom(parsedQuery.zoom), + comparison: { commits: parsedQuery.comparison || [], results: {}, benchmarkName: parsedQuery.comparisonBenchmark } } return view; } - return {}; + return undefined; } export const loadState = () => { @@ -116,7 +122,7 @@ export const loadState = () => { return state; } -const saveState = ({ projects: { selectedProjectId }, view }) => { +export const saveState = ({ projects: { selectedProjectId }, view }) => { try { updateProjectsPathIfNeeded(selectedProjectId); } catch (err) { diff --git a/packages/@best/frontend/src/modules/store/transformer/transformer.js b/packages/@best/frontend/src/modules/store/transformer/transformer.js index 85523c22..6b82417f 100755 --- a/packages/@best/frontend/src/modules/store/transformer/transformer.js +++ b/packages/@best/frontend/src/modules/store/transformer/transformer.js @@ -30,6 +30,19 @@ const mergeMetrics = (snap, accumulator) => { }) } +const normalizeDates = (dates) => { + return dates.map(d => (new Date(d)).toLocaleDateString('default', { month: 'long', day: 'numeric' })); +} + +const normalizeBenchmark = (benchmark) => { + return Object.keys(benchmark).reduce((normalized, key) => { + if (key === 'commitDates') { + normalized[key] = normalizeDates(normalized[key]); + } + return normalized; + }, benchmark); +} + export const snapshotsToBenchmarks = (snapshots) => { const benchesByKeys = snapshots.reduce((acc, snap) => ({ ...acc, @@ -45,7 +58,7 @@ export const snapshotsToBenchmarks = (snapshots) => { const benchmarks = Object.keys(benchesByKeys).map(key => ({ name: key, - ...benchesByKeys[key] + ...normalizeBenchmark(benchesByKeys[key]) })) return benchmarks diff --git a/packages/@best/frontend/src/modules/my/app/app.css b/packages/@best/frontend/src/modules/view/app/app.css similarity index 78% rename from packages/@best/frontend/src/modules/my/app/app.css rename to packages/@best/frontend/src/modules/view/app/app.css index 55e874a6..45eb85a9 100755 --- a/packages/@best/frontend/src/modules/my/app/app.css +++ b/packages/@best/frontend/src/modules/view/app/app.css @@ -2,4 +2,5 @@ display: grid; grid-template-columns: 300px auto; min-height: 100vh; + background: #8fa6b8; } \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/my/app/app.html b/packages/@best/frontend/src/modules/view/app/app.html similarity index 100% rename from packages/@best/frontend/src/modules/my/app/app.html rename to packages/@best/frontend/src/modules/view/app/app.html diff --git a/packages/@best/frontend/src/modules/my/app/app.js b/packages/@best/frontend/src/modules/view/app/app.js similarity index 100% rename from packages/@best/frontend/src/modules/my/app/app.js rename to packages/@best/frontend/src/modules/view/app/app.js diff --git a/packages/@best/frontend/src/modules/view/benchmarks/benchmarks.css b/packages/@best/frontend/src/modules/view/benchmarks/benchmarks.css index 0a4438d3..735d2504 100755 --- a/packages/@best/frontend/src/modules/view/benchmarks/benchmarks.css +++ b/packages/@best/frontend/src/modules/view/benchmarks/benchmarks.css @@ -1,3 +1,14 @@ -.graph-wrapper { +:host { + box-shadow: 0 0 8px rgba(0, 0, 0, 0.175); + z-index: 1; + background: #fff; +} + +.benchmark-wrapper { position: relative; +} + +.stats { + text-align: center; + color: #444; } \ No newline at end of file diff --git a/packages/@best/frontend/src/modules/view/benchmarks/benchmarks.html b/packages/@best/frontend/src/modules/view/benchmarks/benchmarks.html index a97f93e7..5ef7c2ce 100755 --- a/packages/@best/frontend/src/modules/view/benchmarks/benchmarks.html +++ b/packages/@best/frontend/src/modules/view/benchmarks/benchmarks.html @@ -1,12 +1,15 @@