Bug 1506671 - Convert perf compareChooser view to react (#4297)

Remove compareChooser angular partial and controller and create react components
This commit is contained in:
Sarah Clements 2018-11-29 13:44:12 -08:00 коммит произвёл GitHub
Родитель b002fdede3
Коммит a0482c50e7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
23 изменённых файлов: 589 добавлений и 287 удалений

Просмотреть файл

@ -135,8 +135,8 @@ module.exports = {
// to help prevent unknowingly regressing the bundle size (bug 1384255).
neutrino.config.performance
.hints('error')
.maxAssetSize(1.3 * 1024 * 1024)
.maxEntrypointSize(1.64 * 1024 * 1024);
.maxAssetSize(2 * 1024 * 1024)
.maxEntrypointSize(1.71 * 1024 * 1024);
}
},
],

Просмотреть файл

@ -24,7 +24,6 @@
"ajv": "6.5.5",
"angular": "1.7.5",
"angular-clipboard": "1.6.2",
"angular-local-storage": "0.7.1",
"angular1-ui-bootstrap4": "2.4.22",
"auth0-js": "9.8.2",
"bootstrap": "4.1.3",

Просмотреть файл

@ -78,10 +78,6 @@ input {
font-size: 14px;
}
.hide {
visibility: hidden;
}
.tooltip > .tooltip-inner {
background-color: lightgray;
color: black;

Просмотреть файл

@ -46,6 +46,12 @@ a {
visibility: hidden;
}
/* this is better for drop down menu items because
display: none will change text alignment */
.hide {
visibility: hidden;
}
/* Similar Jobs panel */
.checkbox {
min-height: 20px;

Просмотреть файл

@ -31,3 +31,4 @@ import './js/controllers/perf/alerts';
import './js/components/perf/compare';
import './js/components/loading';
import './js/perfapp';
import './perfherder/CompareSelectorView';

Просмотреть файл

@ -254,10 +254,6 @@ export const thEvents = {
applyNewJobs: 'apply-new-jobs-EVT',
};
export const phCompareDefaultOriginalRepo = 'mozilla-central';
export const phCompareDefaultNewRepo = 'try';
export const phTimeRanges = [
{ value: 86400, text: 'Last day' },
{ value: 86400 * 2, text: 'Last 2 days' },
@ -306,8 +302,12 @@ export const phAlertStatusMap = {
CONFIRMING: { id: 5, text: 'confirming' },
};
export const phCompareBaseLineDefaultTimeRange = 86400 * 2;
export const compareDefaultTimeRange = 86400 * 2;
export const thBugSuggestionLimit = 20;
export const thMaxPushFetchSize = 100;
export const errorMessageClass = 'text-danger py-4 d-block text-center';
export const genericErrorMessage = 'Something went wrong';

Просмотреть файл

@ -43,3 +43,12 @@ export const formatTaskclusterError = function formatTaskclusterError(e) {
return `${TC_ERROR_PREFIX}${errorMessage}`;
};
export const processErrorMessage = function processErrorMessage(error, status) {
if (status === 503) {
return 'There was a problem retrieving the data. Please try again in a minute.';
}
const key = Object.keys(error);
return `${key}: ${error[key]}`;
};

Просмотреть файл

@ -10,6 +10,20 @@ export const dxrBaseUrl = 'https://dxr.mozilla.org/';
export const tcRootUrl = 'https://taskcluster.net';
export const bugsEndpoint = 'failures/';
export const bugDetailsEndpoint = 'failuresbybug/';
export const graphsEndpoint = 'failurecount/';
export const deployedRevisionUrl = '/revision.txt';
export const loginCallbackUrl = '/login.html';
export const pushEndpoint = '/resultset/';
export const repoEndpoint = '/repository/';
export const getUserSessionUrl = function getUserSessionUrl(oidcProvider) {
return `https://login.taskcluster.net/v1/oidc-credentials/${oidcProvider}`;
};
@ -88,12 +102,6 @@ export const getCompareChooserUrl = function getCompareChooserUrl(params) {
return `perf.html#/comparechooser${createQueryParams(params)}`;
};
export const bugsEndpoint = 'failures/';
export const bugDetailsEndpoint = 'failuresbybug/';
export const graphsEndpoint = 'failurecount/';
export const parseQueryParams = function parseQueryParams(search) {
const params = new URLSearchParams(search);
@ -115,10 +123,6 @@ export const bugzillaBugsApi = function bugzillaBugsApi(api, params) {
return `${bzBaseUrl}rest/${api}${query}`;
};
export const deployedRevisionUrl = '/revision.txt';
export const loginCallbackUrl = '/login.html';
export const getRepoUrl = function getRepoUrl(newRepoName) {
const params = getAllUrlParams();

Просмотреть файл

@ -68,6 +68,7 @@ export default class DateOptions extends React.Component {
<DropdownMenuItems
options={dateOptions}
updateData={this.updateDateRange}
selectedItem={dateRange}
/>
</ButtonDropdown>
{dateRange === 'custom range' && (

Просмотреть файл

@ -4,11 +4,11 @@ import PropTypes from 'prop-types';
import Icon from 'react-fontawesome';
import ErrorBoundary from '../shared/ErrorBoundary';
import ErrorMessages from '../shared/ErrorMessages';
import { genericErrorMessage, errorMessageClass } from '../helpers/constants';
import Navigation from './Navigation';
import GraphsContainer from './GraphsContainer';
import ErrorMessages from './ErrorMessages';
import { prettyErrorMessages, errorMessageClass } from './constants';
const Layout = props => {
const {
@ -62,7 +62,7 @@ const Layout = props => {
{header}
<ErrorBoundary
errorClasses={errorMessageClass}
message={prettyErrorMessages.default}
message={genericErrorMessage}
>
{graphOneData && graphTwoData && (
<GraphsContainer
@ -76,7 +76,7 @@ const Layout = props => {
<ErrorBoundary
errorClasses={errorMessageClass}
message={prettyErrorMessages.default}
message={genericErrorMessage}
>
{table}
</ErrorBoundary>

Просмотреть файл

@ -43,19 +43,3 @@ export const treeOptions = [
'comm-esr60',
'comm-releases',
];
// we only want bug_ui and tree_ui to be used for UI validation, because
// if there is a valid type used but its a non-existent repo or bug_id
// we want to see that message from the api response
export const prettyErrorMessages = {
startday: 'startday is required and must be in YYYY-MM-DD format.',
endday: 'endday is required and must be in YYYY-MM-DD format.',
bug_ui: 'bug is required and must be a valid integer.',
tree_ui:
'tree is required and must be a valid repository or repository group.',
default: 'Something went wrong.',
status503:
'There was a problem retrieving the data. Please try again in a minute.',
};
export const errorMessageClass = 'text-danger py-4 d-block';

Просмотреть файл

@ -1,7 +1,5 @@
import moment from 'moment';
import { prettyErrorMessages } from './constants';
// be sure to wrap date arg in a moment()
export const ISODate = function formatISODate(date) {
return date.format('YYYY-MM-DD');
@ -92,28 +90,6 @@ export const sortData = function sortData(data, sortBy, desc) {
return data;
};
export const processErrorMessage = function processErrorMessage(
errorMessage,
status,
) {
const messages = [];
if (status === 503) {
return [prettyErrorMessages.status503];
}
if (Object.keys(errorMessage).length > 0) {
for (const [key, value] of Object.entries(errorMessage)) {
if (prettyErrorMessages[key]) {
messages.push(prettyErrorMessages[key]);
} else {
messages.push(`${key}: ${value}`);
}
}
}
return messages || [prettyErrorMessages.default];
};
export const validateQueryParams = function validateQueryParams(
params,
bugRequired = false,
@ -122,16 +98,18 @@ export const validateQueryParams = function validateQueryParams(
const dateFormat = /\d{4}[-]\d{2}[-]\d{2}/;
if (!params.tree) {
messages.push(prettyErrorMessages.tree_ui);
messages.push(
'tree is required and must be a valid repository or repository group.',
);
}
if (!params.startday || params.startday.search(dateFormat) === -1) {
messages.push(prettyErrorMessages.startday);
messages.push('startday is required and must be in YYYY-MM-DD format.');
}
if (!params.endday || params.endday.search(dateFormat) === -1) {
messages.push(prettyErrorMessages.endday);
messages.push('endday is required and must be in YYYY-MM-DD format.');
}
if (bugRequired && (!params.bug || Number.isNaN(params.bug))) {
messages.push(prettyErrorMessages.bug_ui);
messages.push('bug is required and must be a valid integer.');
}
return messages;
};

Просмотреть файл

@ -7,10 +7,8 @@ import metricsgraphics from 'metrics-graphics';
import perf from '../../perf';
import { endpoints } from '../../../perfherder/constants';
import {
phCompareDefaultOriginalRepo,
phCompareDefaultNewRepo,
phTimeRanges,
phCompareBaseLineDefaultTimeRange,
compareDefaultTimeRange,
} from '../../../helpers/constants';
import PushModel from '../../../models/push';
import RepositoryModel from '../../../models/repository';
@ -20,126 +18,6 @@ import { getCounterMap, getInterval, validateQueryParams, getResultsMap,
import { getApiUrl } from '../../../helpers/url';
import { getData } from '../../../helpers/http';
perf.controller('CompareChooserCtrl', [
'$state', '$stateParams', '$scope', '$q',
'localStorageService',
function CompareChooserCtrl($state, $stateParams, $scope, $q,
localStorageService) {
RepositoryModel.getList().then((projects) => {
$scope.projects = projects;
$scope.originalTipList = [];
$scope.newTipList = [];
$scope.revisionComparison = false;
const getParameter = function (paramName, defaultValue) {
if ($stateParams[paramName]) {
return $stateParams[paramName];
}
if (localStorageService.get(paramName)) {
return localStorageService.get(paramName);
}
return defaultValue;
};
$scope.originalProject = projects.find(project =>
project.name === getParameter('originalProject', phCompareDefaultOriginalRepo),
) || projects[0];
$scope.newProject = projects.find(project =>
project.name === getParameter('newProject', phCompareDefaultNewRepo),
) || projects[0];
$scope.originalRevision = getParameter('originalRevision', '');
$scope.newRevision = getParameter('newRevision', '');
const getRevisionTips = function (projectName, list) {
// due to we push the revision data into list,
// so we need clear the data before we push new data into it.
list.splice(0, list.length);
PushModel.getList({ repo: projectName }).then(async (response) => {
const { results } = await response.json();
results.forEach(function (revisionSet) {
list.push({
revision: revisionSet.revision,
author: revisionSet.author,
});
});
$scope.$apply();
});
};
$scope.updateOriginalRevisionTips = function () {
getRevisionTips($scope.originalProject.name, $scope.originalTipList);
};
$scope.updateNewRevisionTips = function () {
getRevisionTips($scope.newProject.name, $scope.newTipList);
};
$scope.updateOriginalRevisionTips();
$scope.updateNewRevisionTips();
$scope.getOriginalTipRevision = function (tip) {
$scope.originalRevision = tip;
};
$scope.getNewTipRevision = function (tip) {
$scope.newRevision = tip;
};
$scope.runCompare = function () {
const revisionPromises = [];
if ($scope.revisionComparison) {
revisionPromises.push(PushModel.getList({
repo: $scope.originalProject.name,
revision: $scope.originalRevision,
}).then((resp) => {
if (resp.ok) {
$scope.originalRevisionError = undefined;
} else {
$scope.originalRevisionError = resp.statusText;
}
$scope.$apply();
}));
}
revisionPromises.push(PushModel.getList({
repo: $scope.newProject.name,
revision: $scope.newRevision,
}).then((resp) => {
if (resp.ok) {
$scope.newRevisionError = undefined;
} else {
$scope.newRevisionError = resp.statusText;
}
$scope.$apply();
}));
$q.all(revisionPromises).then(function () {
localStorageService.set('originalProject', $scope.originalProject.name, 'sessionStorage');
localStorageService.set('originalRevision', $scope.originalRevision, 'sessionStorage');
localStorageService.set('newProject', $scope.newProject.name, 'sessionStorage');
localStorageService.set('newRevision', $scope.newRevision, 'sessionStorage');
if ($scope.originalRevisionError === undefined && $scope.newRevisionError === undefined) {
if ($scope.revisionComparison) {
$state.go('compare', {
originalProject: $scope.originalProject.name,
originalRevision: $scope.originalRevision,
newProject: $scope.newProject.name,
newRevision: $scope.newRevision,
});
} else {
$state.go('compare', {
originalProject: $scope.originalProject.name,
newProject: $scope.newProject.name,
newRevision: $scope.newRevision,
selectedTimeRange: phCompareBaseLineDefaultTimeRange,
});
}
}
});
};
});
}]);
perf.controller('CompareResultsCtrl', [
'$state', '$stateParams', '$scope',
@ -484,7 +362,7 @@ perf.controller('CompareResultsCtrl', [
} else {
$scope.timeRanges = phTimeRanges;
$scope.selectedTimeRange = $scope.timeRanges.find(timeRange =>
timeRange.value === ($stateParams.selectedTimeRange ? parseInt($stateParams.selectedTimeRange) : phCompareBaseLineDefaultTimeRange),
timeRange.value === ($stateParams.selectedTimeRange ? parseInt($stateParams.selectedTimeRange) : compareDefaultTimeRange),
);
}
$q.all(verifyPromises).then(function () {
@ -676,7 +554,7 @@ perf.controller('CompareSubtestResultsCtrl', [
} else {
$scope.timeRanges = phTimeRanges;
$scope.selectedTimeRange = $scope.timeRanges.find(timeRange =>
timeRange.value === ($stateParams.selectedTimeRange ? parseInt($stateParams.selectedTimeRange) : phCompareBaseLineDefaultTimeRange),
timeRange.value === ($stateParams.selectedTimeRange ? parseInt($stateParams.selectedTimeRange) : compareDefaultTimeRange),
);
}

Просмотреть файл

@ -3,7 +3,6 @@ import angularClipboardModule from 'angular-clipboard';
import uiBootstrap from 'angular1-ui-bootstrap4';
import uiRouter from '@uirouter/angularjs';
import 'ng-text-truncate-2';
import LocalStorageModule from 'angular-local-storage';
import { react2angular } from 'react2angular/index.es2015';
import Login from '../shared/auth/Login';
@ -16,7 +15,6 @@ const perf = angular.module('perf', [
treeherderModule.name,
angularClipboardModule.name,
'ngTextTruncate',
LocalStorageModule,
]);
perf.component('login', react2angular(Login, ['user', 'setUser'], []));

Просмотреть файл

@ -57,7 +57,6 @@ perf.config(['$compileProvider', '$locationProvider', '$httpProvider', '$statePr
title: 'Compare Chooser',
template: compareChooserCtrlTemplate,
url: '/comparechooser?originalProject&originalRevision&newProject&newRevision',
controller: 'CompareChooserCtrl',
})
.state('comparesubtestdistribution', {
title: 'Compare Subtest Distribution',

Просмотреть файл

@ -3,13 +3,11 @@ import { slugid } from 'taskcluster-client-web';
import { thMaxPushFetchSize } from '../helpers/constants';
import { getUrlParam } from '../helpers/location';
import taskcluster from '../helpers/taskcluster';
import { createQueryParams, getProjectUrl } from '../helpers/url';
import { createQueryParams, getProjectUrl, pushEndpoint } from '../helpers/url';
import JobModel from './job';
import TaskclusterModel from './taskcluster';
const uri_base = '/resultset/';
const convertDates = function convertDates(locationParams) {
// support date ranges. we must convert the strings to a timezone
// appropriate timestamp
@ -53,13 +51,13 @@ export default class PushModel {
params.count = thMaxPushFetchSize;
}
return fetch(
`${getProjectUrl(uri_base, repoName)}${createQueryParams(params)}`,
`${getProjectUrl(pushEndpoint, repoName)}${createQueryParams(params)}`,
);
}
static get(pk, options = {}) {
const repoName = options.repo || getUrlParam('repo');
return fetch(getProjectUrl(`${uri_base}${pk}/`, repoName));
return fetch(getProjectUrl(`${pushEndpoint}${pk}/`, repoName));
}
static getJobs(pushIds, options = {}) {

Просмотреть файл

@ -1,6 +1,4 @@
import { getApiUrl } from '../helpers/url';
const uri = getApiUrl('/repository/');
import { getApiUrl, repoEndpoint } from '../helpers/url';
export default class RepositoryModel {
constructor(props) {
@ -20,7 +18,7 @@ export default class RepositoryModel {
}
static getList() {
return fetch(uri)
return fetch(getApiUrl(repoEndpoint))
.then(resp => resp.json())
.then(repos => repos.map(datum => new RepositoryModel(datum)));
}

Просмотреть файл

@ -1,71 +1 @@
<div class="container-fluid vertical-box">
<div class="spacer"></div>
<form class="compare-form centered-element">
<div class="spacer"></div>
<div class="form-group">
<div class="spacer"></div>
<div class="card centered-element">
<div class="card-header">Base</div>
<div class="card-body">
<label for="original-project-selector">Project</label>
<select id="original-project-selector" class="form-control" ng-model="originalProject" ng-options="project.name for project in projects" ng-change="updateOriginalRevisionTips()"/>
<div class="checkbox">
<label><input type="checkbox" ng-model="revisionComparison">Compare with a specific revision</label>
</div>
<div ng-show="!revisionComparison">
<p class="help-block">By default, Perfherder will compare against performance data gathered over the last 2 days from when new revision was pushed</p>
</div>
<div ng-show="revisionComparison">
<label for="original-revision-input">Revision</label>
<div class="input-group" ng-class="{'has-danger': originalRevisionError}">
<input id="original-revision-input" maxlength="40" class="form-control" type="text" ng-model="originalRevision" placeholder="Select or enter a revision"/>
<div class="input-group-btn">
<button type="button" class="btn btn-light-bordered dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Recent</button>
<ul class="dropdown-menu dropdown-menu-right" ng-model="tipRevision">
<li ng-repeat="tip in originalTipList"><a ng-click="getOriginalTipRevision(tip.revision)" class="dropdown-item">{{tip.revision | limitTo: 12}} {{tip.author}}</a></li>
</ul>
</div>
</div>
</div>
</div>
<label class="form-control-label" style="color:#A94442" ng-show="originalRevisionError">{{originalRevisionError}}"</label>
</div>
<div class="card centered-element">
<div class="card-header">New</div>
<div class="card-body">
<label for="new-project-selector">Project</label>
<select id="new-project-selector" class="form-control" ng-model="newProject" ng-options="project.name for project in projects" ng-change="updateNewRevisionTips()"></select>
<label for="new-revision-input">Revision</label>
<div class="input-group" ng-class="{'has-danger': newRevisionError}">
<input id="new-revision-input" maxlength="40" class="form-control" type="text" ng-model="newRevision" placeholder="Select or enter a revision"/>
<div class="input-group-btn">
<button type="button" class="btn btn-light-bordered dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Recent</button>
<ul class="dropdown-menu dropdown-menu-right">
<li ng-repeat="tip in newTipList"><a ng-click="getNewTipRevision(tip.revision);" class="dropdown-item">
{{tip.revision | limitTo: 12}} {{tip.author}}
</a></li>
</ul>
</div>
</div>
<label class="form-control-label" style="color:#A94442" ng-show="newRevisionError">{{newRevisionError}}</label>
</div>
</div>
<div class="spacer"></div>
</div>
<div class="horizontal-box">
<div class="spacer"></div>
<div class="form-group button-container">
<button type="submit" class="btn btn-primary-soft btn-lg btn-block"
ng-click="runCompare()"
ng-disabled="(originalRevision.length > 0 && originalRevision.length < 12) || newRevision.length < 12">
Compare
</button>
<div class="spacer"></div>
</div>
<div class="spacer"></div>
</div>
</form>
<div class="spacer"></div>
</div>
<compare-selector-view />

Просмотреть файл

@ -0,0 +1,244 @@
import React from 'react';
import PropTypes from 'prop-types';
import { react2angular } from 'react2angular/index.es2015';
import { Container, Col, Row, Button } from 'reactstrap';
import perf from '../js/perf';
import {
getApiUrl,
repoEndpoint,
getProjectUrl,
createQueryParams,
pushEndpoint,
} from '../helpers/url';
import { getData } from '../helpers/http';
import ErrorMessages from '../shared/ErrorMessages';
import {
compareDefaultTimeRange,
genericErrorMessage,
errorMessageClass,
} from '../helpers/constants';
import ErrorBoundary from '../shared/ErrorBoundary';
import SelectorCard from './SelectorCard';
// TODO remove $stateParams and $state after switching to react router
export default class CompareSelectorView extends React.Component {
constructor(props) {
super(props);
this.state = {
projects: [],
failureStatus: null,
originalProject: 'mozilla-central',
newProject: 'try',
originalRevision: '',
newRevision: '',
errorMessages: [],
disableButton: true,
};
this.submitData = this.submitData.bind(this);
this.validateQueryParams = this.validateQueryParams.bind(this);
this.validateProject = this.validateProject.bind(this);
this.validateRevision = this.validateRevision.bind(this);
}
async componentDidMount() {
const { data, failureStatus } = await getData(getApiUrl(repoEndpoint));
this.setState({ projects: data, failureStatus });
this.validateQueryParams();
}
validateQueryParams() {
const {
originalProject,
newProject,
originalRevision,
newRevision,
} = this.props.$stateParams;
if (originalProject) {
this.validateProject('originalProject', originalProject);
}
if (newProject) {
this.validateProject('newProject', newProject);
}
if (newRevision) {
this.validateRevision(
'newRevision',
newRevision,
newProject || this.state.newProject,
);
}
if (originalRevision) {
this.validateRevision(
'originalRevision',
originalRevision,
originalProject || this.state.originalProject,
);
}
}
validateProject(projectName, project) {
const { projects, errorMessages } = this.state;
let updates = {};
const validProject = projects.find(item => item.name === project);
if (validProject) {
updates = { [projectName]: project };
} else {
updates = {
errorMessages: [
...errorMessages,
`${projectName} must be a valid project.`,
],
};
}
this.setState(updates);
}
async validateRevision(revisionName, revision, project) {
const { errorMessages } = this.state;
let updates = {};
const url = `${getProjectUrl(pushEndpoint, project)}${createQueryParams({
revision,
})}`;
const { data, failureStatus } = await getData(url);
if (failureStatus || data.meta.count === 0) {
updates = {
errorMessages: [
...errorMessages,
`${revisionName} must be a valid revision.`,
],
};
} else {
updates = { [revisionName]: revision };
}
this.setState(updates);
}
submitData() {
const {
originalProject,
newProject,
originalRevision,
newRevision,
} = this.state;
const { $state } = this.props;
if (newRevision === '') {
return this.setState({ errorMessages: ['New revision is required'] });
}
if (originalRevision !== '') {
$state.go('compare', {
originalProject,
originalRevision,
newProject,
newRevision,
});
} else {
$state.go('compare', {
originalProject,
newProject,
newRevision,
selectedTimeRange: compareDefaultTimeRange,
});
}
}
render() {
const {
originalProject,
newProject,
projects,
originalRevision,
newRevision,
data,
failureStatus,
errorMessages,
disableButton,
} = this.state;
return (
<Container
fluid
style={{ marginBottom: '5rem', marginTop: '5rem', maxWidth: '1200px' }}
>
<ErrorBoundary
errorClasses={errorMessageClass}
message={genericErrorMessage}
>
<div className="mx-auto">
<Row className="justify-content-center">
<Col sm="8" className="text-center">
{(failureStatus || errorMessages.length > 0) && (
<ErrorMessages
failureMessage={data}
failureStatus={failureStatus}
errorMessages={errorMessages}
/>
)}
</Col>
</Row>
<Row className="justify-content-center">
<SelectorCard
projects={projects}
updateState={updates => this.setState(updates)}
selectedRepo={originalProject}
title="Base"
checkbox
text="By default, Perfherder will compare against performance data gathered over the last 2 days from when new revision was pushed"
projectState="originalProject"
revisionState="originalRevision"
selectedRevision={originalRevision}
queryParam={this.props.$stateParams.originalRevision}
/>
<SelectorCard
projects={projects}
updateState={updates => this.setState(updates)}
selectedRepo={newProject}
title="New"
projectState="newProject"
revisionState="newRevision"
selectedRevision={newRevision}
/>
</Row>
<Row className="justify-content-center">
<Col sm="8" className="text-right px-1">
<Button
color="info"
className="mt-2 mx-auto"
onClick={
newRevision !== '' && disableButton ? '' : this.submitData
}
>
Compare
</Button>
</Col>
</Row>
</div>
</ErrorBoundary>
</Container>
);
}
}
CompareSelectorView.propTypes = {
$stateParams: PropTypes.shape({
newRevision: PropTypes.string,
originalRevision: PropTypes.string,
newProject: PropTypes.string,
originalProject: PropTypes.string,
}).isRequired,
$state: PropTypes.shape({}).isRequired,
};
perf.component(
'compareSelectorView',
react2angular(CompareSelectorView, [], ['$stateParams', '$state']),
);

Просмотреть файл

@ -0,0 +1,284 @@
import React from 'react';
import PropTypes from 'prop-types';
import Icon from 'react-fontawesome';
import {
Col,
Card,
CardHeader,
CardText,
CardBody,
DropdownItem,
Input,
CardSubtitle,
Label,
FormGroup,
ButtonDropdown,
DropdownToggle,
DropdownMenu,
InputGroup,
InputGroupButtonDropdown,
} from 'reactstrap';
import { getProjectUrl, createQueryParams, pushEndpoint } from '../helpers/url';
import { getData } from '../helpers/http';
import { genericErrorMessage } from '../helpers/constants';
export default class SelectorCard extends React.Component {
constructor(props) {
super(props);
this.state = {
buttonDropdownOpen: false,
inputDropdownOpen: false,
checkboxSelected: this.props.queryParam,
inputValue: '',
data: {},
failureStatus: null,
invalidInput: false,
};
this.toggle = this.toggle.bind(this);
this.fetchRevisions = this.fetchRevisions.bind(this);
this.validateInput = this.validateInput.bind(this);
this.compareRevisions = this.compareRevisions.bind(this);
this.updateRevision = this.updateRevision.bind(this);
}
async componentDidMount() {
// by default revisions are only needed for the 'New' component dropdown
// so we'll fetch revisions for the 'Base' component only as needed
if (this.props.revisionState === 'newRevision') {
this.fetchRevisions(this.props.selectedRepo);
}
}
async fetchRevisions(selectedRepo) {
const params = {
full: true,
count: 10,
};
const url = `${getProjectUrl(
pushEndpoint,
selectedRepo,
)}${createQueryParams(params)}`;
const { data, failureStatus } = await getData(url);
if (failureStatus) {
this.props.updateState({ errorMessages: genericErrorMessage });
} else {
this.setState({ data, failureStatus });
}
}
toggle(dropdown) {
this.setState({
[dropdown]: !this.state[dropdown],
});
}
updateData(selectedRepo) {
const { updateState, projectState } = this.props;
this.fetchRevisions(selectedRepo);
updateState({ [projectState]: selectedRepo });
}
compareRevisions() {
this.toggle('checkboxSelected');
if (!this.state.data.results) {
this.fetchRevisions(this.props.selectedRepo);
}
}
async validateInput(value) {
const { updateState } = this.props;
if (value.length < 40 && value !== '') {
return this.setState({
invalidInput: 'Revision must be at least 40 characters',
});
}
if (value.length >= 40) {
const url = `${getProjectUrl(
pushEndpoint,
this.props.selectedRepo,
)}${createQueryParams({ revision: value })}`;
const { data, failureStatus } = await getData(url);
if (failureStatus || data.meta.count === 0) {
return this.setState({ invalidInput: 'Invalid revision' });
}
updateState({ disableButton: false });
}
// reset if value is valid but previous value wasn't
if (this.state.invalidInput) {
this.setState({ invalidInput: false });
}
}
updateRevision(value) {
const { updateState, revisionState } = this.props;
this.setState({ invalidInput: false });
updateState({
[revisionState]: value,
errorMessages: [],
disableButton: false,
});
}
render() {
const {
buttonDropdownOpen,
inputDropdownOpen,
checkboxSelected,
data,
invalidInput,
} = this.state;
const {
selectedRepo,
updateState,
projects,
title,
text,
checkbox,
selectedRevision,
revisionState,
} = this.props;
return (
<Col sm="4" className="p-2">
<Card style={{ height: '250px' }}>
<CardHeader style={{ backgroundColor: 'lightgrey' }}>
{title}
</CardHeader>
<CardBody>
<CardSubtitle className="pb-2 pt-3">Project</CardSubtitle>
<ButtonDropdown
className="mr-3 w-25"
isOpen={buttonDropdownOpen}
toggle={() => this.toggle('buttonDropdownOpen')}
>
<DropdownToggle caret outline>
{selectedRepo}
</DropdownToggle>
{projects.length > 0 && (
<DropdownMenu
style={{
overflow: 'auto',
maxHeight: 300,
}}
>
{projects.map(item => (
<DropdownItem
key={item.name}
onClick={event => this.updateData(event.target.innerText)}
>
<Icon
name="check"
className={`pr-1 ${
selectedRepo === item.name ? '' : 'hide'
}`}
/>
{item.name}
</DropdownItem>
))}
</DropdownMenu>
)}
</ButtonDropdown>
{checkbox && (
<FormGroup check className="pt-1">
<Label check className="font-weight-normal">
<Input
type="checkbox"
defaultChecked={checkboxSelected}
onClick={this.compareRevisions}
/>{' '}
Compare with a specific revision
</Label>
</FormGroup>
)}
{!checkboxSelected && text ? (
<CardText className="text-muted py-2">{text}</CardText>
) : (
<React.Fragment>
<CardSubtitle className="pt-4 pb-2">Revision</CardSubtitle>
<InputGroup>
<Input
placeholder="select or enter a revision"
value={selectedRevision}
onChange={event =>
updateState({
[revisionState]: event.target.value,
errorMessages: [],
disableButton: true,
})
}
onBlur={event => this.validateInput(event.target.value)}
onFocus={() => this.setState({ invalidInput: false })}
/>
<InputGroupButtonDropdown
addonType="append"
isOpen={inputDropdownOpen}
toggle={() => this.toggle('inputDropdownOpen')}
>
<DropdownToggle caret outline>
Recent
</DropdownToggle>
{!!data.results && data.results.length > 0 && (
<DropdownMenu>
{data.results.map(item => (
<DropdownItem
key={item.id}
onClick={event =>
this.updateRevision(
event.target.innerText.split(' ')[0],
)
}
>
<Icon
name="check"
className={`pr-1 ${
selectedRevision === item.revision ? '' : 'hide'
}`}
/>
{`${item.revision} ${item.author}`}
</DropdownItem>
))}
</DropdownMenu>
)}
</InputGroupButtonDropdown>
</InputGroup>
{invalidInput && (
<CardText className="text-danger py-2">
{invalidInput}
</CardText>
)}
</React.Fragment>
)}
</CardBody>
</Card>
</Col>
);
}
}
SelectorCard.propTypes = {
projects: PropTypes.arrayOf(PropTypes.shape({})),
selectedRepo: PropTypes.string.isRequired,
selectedRevision: PropTypes.string.isRequired,
revisionState: PropTypes.string.isRequired,
projectState: PropTypes.string.isRequired,
updateState: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
text: PropTypes.string,
checkbox: PropTypes.bool,
queryParam: PropTypes.string,
};
SelectorCard.defaultProps = {
projects: [],
text: null,
checkbox: false,
queryParam: undefined,
};

Просмотреть файл

@ -1,6 +1,6 @@
import chunk from 'lodash/chunk';
import { getApiUrl, createQueryParams } from '../helpers/url';
import { getApiUrl, createQueryParams, repoEndpoint } from '../helpers/url';
import { getData } from '../helpers/http';
import PerfSeriesModel from '../models/perfSeries';
import { phTimeRanges } from '../helpers/constants';
@ -278,7 +278,7 @@ export const validateQueryParams = async function validateQueryParams(params) {
if (!newSignature) errors.push('Missing input: newSignature');
}
const { data, failureStatus } = await getData(getApiUrl('/repository/'));
const { data, failureStatus } = await getData(getApiUrl(repoEndpoint));
if (
!failureStatus &&

Просмотреть файл

@ -2,12 +2,12 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from 'reactstrap';
import { processErrorMessage } from './helpers';
import { processErrorMessage } from '../helpers/errorMessage';
const ErrorMessages = ({ failureMessage, failureStatus, errorMessages }) => {
const messages = errorMessages.length
? errorMessages
: processErrorMessage(failureMessage, failureStatus);
: [processErrorMessage(failureMessage, failureStatus)];
return (
<div>

Просмотреть файл

@ -1111,11 +1111,6 @@ angular-clipboard@1.6.2:
resolved "https://registry.yarnpkg.com/angular-clipboard/-/angular-clipboard-1.6.2.tgz#4708e5a1dc94f3940ab89861ea1e19b26754154f"
integrity sha512-T5kK6X6fFigLPd2szxW8ETEOLNW9z/9RqdlXtLAY7nakrI62BH8FGOxIes36tvAP/9numwokEWKVLy0WpNkB7w==
angular-local-storage@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/angular-local-storage/-/angular-local-storage-0.7.1.tgz#fbd2730763c29fa9af5725e0186c780621e8cdd2"
integrity sha1-+9JzB2PCn6mvVyXgGGx4BiHozdI=
angular-mocks@1.7.5:
version "1.7.5"
resolved "https://registry.yarnpkg.com/angular-mocks/-/angular-mocks-1.7.5.tgz#c8baba5a06ed60b934697026b492169626af384b"