ui/perfherder/alerts/AlertsView.jsx (371 lines of code) (raw):
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Container } from 'reactstrap';
import cloneDeep from 'lodash/cloneDeep';
import withValidation from '../Validation';
import {
convertParams,
getFrameworkData,
getStatus,
} from '../perf-helpers/helpers';
import {
summaryStatusMap,
endpoints,
notSupportedAlertFiltersMessage,
} from '../perf-helpers/constants';
import {
createQueryParams,
getApiUrl,
parseQueryParams,
} from '../../helpers/url';
import { getData, processResponse } from '../../helpers/http';
import ErrorMessages from '../../shared/ErrorMessages';
import OptionCollectionModel from '../../models/optionCollection';
import {
genericErrorMessage,
errorMessageClass,
} from '../../helpers/constants';
import ErrorBoundary from '../../shared/ErrorBoundary';
import LoadingSpinner from '../../shared/LoadingSpinner';
import AlertsViewControls from './AlertsViewControls';
class AlertsView extends React.Component {
constructor(props) {
super(props);
const { frameworks, validated } = this.props;
this.extendedOptions = this.extendDropdownOptions(frameworks);
this.state = {
filters: this.getFiltersFromParams(validated),
frameworkOptions: this.extendedOptions,
page: validated.page ? parseInt(validated.page, 10) : 1,
errorMessages: [],
alertSummaries: [],
issueTrackers: [],
notSupportedAlertFilters: [],
loading: false,
optionCollectionMap: null,
count: 0,
id: validated.id,
bugTemplate: null,
totalPages: 0,
};
}
componentDidMount() {
this.fetchAlertSummaries();
}
componentDidUpdate(prevProps, prevState) {
const { count } = this.state;
if (prevState.count !== count) {
this.setState({ totalPages: this.generatePages(count) });
}
const params = parseQueryParams(this.props.location.search);
const prevParams = parseQueryParams(prevProps.location.search);
// we're using local state for id instead of validated.id because once
// the user navigates from the id=<alert> view back to the main alerts view
// the Validation component won't reset the id (since the query param doesn't exist
// unless there is a value)
if (
params.id !== prevParams.id ||
params.status !== prevParams.status ||
params.framework !== prevParams.framework ||
params.filterText !== prevParams.filterText ||
params.hideDwnToInv !== prevParams.hideDwnToInv ||
params.hideAssignedToOthers !== prevParams.hideAssignedToOthers
) {
this.setState(
{ id: params.id || null, filters: this.getFiltersFromParams(params) },
this.fetchAlertSummaries,
);
// all data updates due to page changes happens here so as
// to support back button navigation
} else if (params.page && params.page !== prevParams.page) {
this.fetchAlertSummaries(undefined, false, parseInt(params.page, 10));
}
}
getFiltersFromParams = (
validated,
frameworkOptions = this.extendedOptions,
) => {
return {
status: this.getDefaultStatus(validated),
framework: getFrameworkData({
validated,
frameworks: frameworkOptions,
}),
filterText: this.getDefaultFilterText(validated),
hideDownstream: convertParams(validated, 'hideDwnToInv'),
hideAssignedToOthers: convertParams(validated, 'hideAssignedToOthers'),
};
};
getDefaultStatus = (validated) => {
const statusParam = convertParams(validated, 'status');
if (!statusParam) {
return Object.keys(summaryStatusMap)[1];
}
return getStatus(parseInt(validated.status, 10));
};
getDefaultFilterText = (validated) => {
const { filterText } = validated;
return filterText === undefined || filterText === null ? '' : filterText;
};
setFiltersState = async (updatedFilters, doUpdateParams = true) => {
const { filters } = this.state;
const currentFilters = cloneDeep(filters);
Object.assign(currentFilters, updatedFilters);
if (this.isListMode()) {
if (doUpdateParams) {
this.props.validated.updateParams(
this.getParamsFromFilters(updatedFilters),
);
}
this.setState({ filters: currentFilters }, this.fetchAlertSummaries);
} else {
this.setState({ filters: currentFilters });
}
this.setState({
notSupportedAlertFilters: this.selectNotSupportedFilters(
currentFilters.filterText,
),
});
};
isListMode = () => {
return Boolean(!this.state.id);
};
extendDropdownOptions = (frameworks) => {
const frameworkOptions = cloneDeep(frameworks);
const ignoreFrameworks = { id: -1, name: 'all frameworks' };
frameworkOptions.unshift(ignoreFrameworks);
return frameworkOptions;
};
getParamsFromFilters = (filters) => {
return {
page: 1, // default value
...Object.fromEntries(
Object.entries(filters).map(([filterName, filterValue]) => {
switch (filterName) {
case 'framework':
return [filterName, filterValue.id];
case 'status':
return [filterName, summaryStatusMap[filterValue]];
case 'hideDownstream':
return ['hideDwnToInv', +filterValue];
case 'hideAssignedToOthers':
return [filterName, +filterValue];
default:
return [filterName, filterValue];
}
}),
),
};
};
getCurrentPages = () => {
const { page, totalPages } = this.state;
if (totalPages.length === 5 || !totalPages.length) {
return totalPages;
}
if (page + 4 > totalPages.length) {
return totalPages.slice(-5);
}
return totalPages.slice(page - 1, page + 4);
};
generatePages = (count) => {
const pages = [];
for (let num = 1; num <= count; num++) {
pages.push(num);
}
return pages;
};
composeParams = (id, page, framework, status) => {
const params = id
? { id }
: { framework: framework.id, page, status: summaryStatusMap[status] };
// -1 ('all') is created for UI purposes but is not a valid API parameter
const doNotFilter = -1;
const listMode = !id;
if (listMode && params.status === doNotFilter) {
delete params.status;
}
if (listMode && params.framework === doNotFilter) {
delete params.framework;
}
return params;
};
selectNotSupportedFilters = (userInput) => {
/* Following filters are not supported (see bug 1616215 for more details):
* - `option`, because of technical dept, as described in bug 1616212
* - `repository`, because it has never been enabled & the new dropdown items could falsely hint it is.
*/
const { projects } = this.props;
const { optionCollectionMap } = this.state;
const userInputArray = userInput.split(' ');
const repositories = projects.map(({ name }) => name);
const optionsCollection = Object.values(optionCollectionMap || {});
const allNotSupportedFilters = [...repositories, ...optionsCollection];
return allNotSupportedFilters.filter((elem) =>
userInputArray.includes(elem),
);
};
async fetchAlertSummaries(
id = this.state.id,
update = false,
page = this.state.page,
) {
// turn off loading when update is true (used to update alert statuses)
this.setState({ loading: !update, errorMessages: [] });
const { user } = this.props;
const {
filters,
errorMessages,
issueTrackers,
optionCollectionMap,
alertSummaries,
count,
} = this.state;
const {
status,
framework,
filterText,
hideDownstream,
hideAssignedToOthers,
} = filters;
this.setState({ page });
let updates = { loading: false };
const params = this.composeParams(id, page, framework, status);
if (this.isListMode()) {
if (filterText) {
params.filter_text = filterText;
}
if (status === 'all regressions') {
delete params.status;
params.hide_improvements = true;
}
if (hideDownstream) {
params.hide_related_and_invalid = hideDownstream;
}
if (hideAssignedToOthers) {
params.with_assignee = user.username;
}
}
const url = getApiUrl(
`${endpoints.alertSummary}${createQueryParams(params)}`,
);
if (!issueTrackers.length && !optionCollectionMap) {
const [optionCollectionMap, issueTrackers] = await Promise.all([
OptionCollectionModel.getMap(),
getData(getApiUrl(endpoints.issueTrackers)),
]);
updates = {
...updates,
...{ optionCollectionMap },
...processResponse(issueTrackers, 'issueTrackers', errorMessages),
};
}
const data = await getData(url);
const response = processResponse(data, 'alertSummaries', errorMessages);
if (response.alertSummaries) {
const summary = response.alertSummaries;
// used with the id argument to update one specific alert summary in the array of
// alert summaries that's been updated based on an action taken in the AlertActionPanel
if (update && summary.results.length !== 0) {
const index = alertSummaries.findIndex(
(item) => item.id === summary.results[0].id,
);
alertSummaries.splice(index, 1, summary.results[0]);
}
updates = {
...updates,
...{
alertSummaries: update ? alertSummaries : summary.results,
count: update ? count : Math.ceil(summary.count / 10),
},
};
} else {
updates = { ...updates, ...response };
}
this.setState(updates);
}
render() {
const { user } = this.props;
const {
filters,
errorMessages,
loading,
alertSummaries,
frameworkOptions,
issueTrackers,
notSupportedAlertFilters,
optionCollectionMap,
bugTemplate,
page,
count,
} = this.state;
// this is not strictly accurate since we have no way of knowing the final count
// until the results are filtered (and we're only retrieving 10 results at a time)
const pageNums = this.getCurrentPages();
return (
<ErrorBoundary
errorClasses={errorMessageClass}
message={genericErrorMessage}
>
<Container fluid className="pt-5 max-width-default">
{loading && <LoadingSpinner />}
{errorMessages.length > 0 && (
<Container className="pt-5 px-0 max-width-default">
<ErrorMessages errorMessages={errorMessages} />
</Container>
)}
{!user.isStaff && (
<Alert color="info">
You must be logged into perfherder/treeherder and be a sheriff to
make changes
</Alert>
)}
{notSupportedAlertFilters.length > 0 && (
<Alert color="warning">
{notSupportedAlertFiltersMessage(notSupportedAlertFilters)}
</Alert>
)}
<AlertsViewControls
isListMode={this.isListMode()}
filters={filters}
pageNums={pageNums}
alertSummaries={alertSummaries}
frameworkOptions={frameworkOptions}
issueTrackers={issueTrackers}
optionCollectionMap={optionCollectionMap}
fetchAlertSummaries={(id, update = true, page) =>
this.fetchAlertSummaries(id, update, page)
}
updateViewState={(state) => this.setState(state)}
setFiltersState={this.setFiltersState}
bugTemplate={bugTemplate}
user={user}
page={page}
count={count}
{...this.props}
/>
{!loading && alertSummaries.length === 0 && (
<p className="lead text-center">No alerts to show</p>
)}
</Container>
</ErrorBoundary>
);
}
}
AlertsView.propTypes = {
location: PropTypes.shape({}),
user: PropTypes.shape({}).isRequired,
validated: PropTypes.shape({
updateParams: PropTypes.func.isRequired,
framework: PropTypes.string,
}).isRequired,
projects: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
frameworks: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
performanceTags: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
};
AlertsView.defaultProps = {
location: null,
};
export default withValidation(
{ requiredParams: new Set([]) },
false,
)(AlertsView);