ui/job-view/headerbars/SecondaryNavBar.jsx (380 lines of code) (raw):

import React from 'react'; import { Button } from 'reactstrap'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCircle, faDotCircle } from '@fortawesome/free-regular-svg-icons'; import { faExclamationCircle, faFilter, faTimesCircle, } from '@fortawesome/free-solid-svg-icons'; import { push as pushRoute } from 'connected-react-router'; import { getBtnClass } from '../../helpers/job'; import { hasUrlFilterChanges, thFilterGroups } from '../../helpers/filter'; import { getRepo, getUrlParam, setUrlParams } from '../../helpers/location'; import RepositoryModel from '../../models/repository'; import ErrorBoundary from '../../shared/ErrorBoundary'; import { recalculateUnclassifiedCounts } from '../redux/stores/pushes'; import TierIndicator from './TierIndicator'; import WatchedRepo from './WatchedRepo'; const MAX_WATCHED_REPOS = 3; const WATCHED_REPOS_STORAGE_KEY = 'thWatchedRepos'; const getSearchStrFromUrl = function getSearchStrFromUrl() { const searchStr = getUrlParam('searchStr'); return searchStr ? searchStr.replace(/,/g, ' ') : ''; }; class SecondaryNavBar extends React.PureComponent { constructor(props) { super(props); this.filterChicklets = [ 'failures', thFilterGroups.nonfailures, 'in progress', ].reduce((acc, val) => acc.concat(val), []); this.state = { searchQueryStr: getSearchStrFromUrl(), watchedRepoNames: [], repoName: getRepo(), }; } componentDidMount() { this.loadWatchedRepos(); } componentDidUpdate(prevProps, prevState) { const { repoName } = this.state; if (repoName !== prevState.repoName) { this.loadWatchedRepos(); } if ( prevProps.router.location.search !== this.props.router.location.search ) { this.handleUrlChanges( prevProps.router.location.search, this.props.router.location.search, ); } } setSearchStr(ev) { this.setState({ searchQueryStr: ev.target.value }); } handleUrlChanges = (prevParams, currentParams) => { const { repoName } = this.state; const { recalculateUnclassifiedCounts } = this.props; const newState = { searchQueryStr: getSearchStrFromUrl(), repoName: getRepo(), }; this.setState(newState, () => { if ( hasUrlFilterChanges(prevParams, currentParams) || newState.repoName !== repoName ) { recalculateUnclassifiedCounts(); } }); }; search = (ev) => { const { filterModel } = this.props; const { value } = ev.target; if (ev.key === 'Enter') { if (value && value.length) { filterModel.replaceFilter('searchStr', value.split(' ')); } else { filterModel.removeFilter('searchStr'); } ev.target.parentElement.focus(); } }; isFilterOn = (filter) => { const { filterModel } = this.props; const { resultStatus } = filterModel.urlParams; if (filter in thFilterGroups) { return thFilterGroups[filter].some((val) => resultStatus.includes(val)); } return resultStatus.includes(filter); }; /** * Handle toggling one of the individual result status filter chicklets * on the nav bar */ toggleResultStatusFilterChicklet = (filter) => { const { filterModel } = this.props; const filterValues = filter in thFilterGroups ? thFilterGroups[filter] // this is a filter grouping, so toggle all on/off : [filter]; filterModel.toggleResultStatuses(filterValues); }; toggleShowDuplicateJobs = () => { const { duplicateJobsVisible, pushRoute } = this.props; const duplicateJobs = duplicateJobsVisible ? null : 'visible'; const queryParams = setUrlParams([['duplicate_jobs', duplicateJobs]]); pushRoute({ search: queryParams, }); }; toggleGroupState = () => { const { groupCountsExpanded, pushRoute } = this.props; const groupState = groupCountsExpanded ? null : 'expanded'; const queryParams = setUrlParams([['group_state', groupState]]); pushRoute({ search: queryParams, }); }; toggleUnclassifiedFailures = () => { const { filterModel } = this.props; filterModel.toggleUnclassifiedFailures(); }; clearFilterBox = () => { const { filterModel } = this.props; filterModel.removeFilter('searchStr'); }; unwatchRepo = (name) => { const { watchedRepoNames } = this.state; this.saveWatchedRepos(watchedRepoNames.filter((repo) => repo !== name)); }; loadWatchedRepos() { const { repoName } = this.state; try { const storedWatched = JSON.parse(localStorage.getItem(WATCHED_REPOS_STORAGE_KEY)) || []; // Ensure the current repo is first in the list const watchedRepoNames = [ repoName, ...storedWatched.filter((value) => value !== repoName), ].slice(0, MAX_WATCHED_REPOS); // Re-save the list, in case it has now changed this.saveWatchedRepos(watchedRepoNames); } catch { // localStorage is disabled/not supported. return []; } } saveWatchedRepos(repos) { this.setState({ watchedRepoNames: repos }); try { localStorage.setItem(WATCHED_REPOS_STORAGE_KEY, JSON.stringify(repos)); } catch { // localStorage is disabled/not supported. } } render() { const { updateButtonClick, serverChanged, filterModel, setCurrentRepoTreeStatus, repos, allUnclassifiedFailureCount, filteredUnclassifiedFailureCount, groupCountsExpanded, duplicateJobsVisible, toggleFieldFilterVisible, } = this.props; const { watchedRepoNames, searchQueryStr, repoName } = this.state; // This array needs to be RepositoryModel objects, not strings. // If ``repos`` is not yet populated, then leave as empty array. // We need to filter just in case some of these repo names do not exist. // This could happen if the user typed an invalid ``repo`` param on the URL const watchedRepos = (repos.length && watchedRepoNames .map((name) => RepositoryModel.getRepo(name, repos)) .filter((name) => name)) || []; return ( <div id="watched-repo-navbar" className="th-context-navbar navbar-dark watched-repo-navbar" tabIndex={-1} > <span className="justify-content-between w-100 d-flex flex-wrap"> <span className="d-flex push-left watched-repos"> {watchedRepos.map((watchedRepo) => ( <ErrorBoundary errorClasses="pl-1 pr-1 btn-view-nav border-right" message={`Error watching ${watchedRepo.name}: `} key={watchedRepo.name} > <WatchedRepo repo={watchedRepo} repoName={repoName} unwatchRepo={this.unwatchRepo} setCurrentRepoTreeStatus={setCurrentRepoTreeStatus} {...this.props} /> </ErrorBoundary> ))} </span> <form role="search" className="form-inline flex-row"> {serverChanged && ( <Button size="sm" className="btn-view-nav nav-menu-btn" onClick={updateButtonClick} id="revisionChangedLabel" title="New version of Treeherder has been deployed. Reload to pick up changes." > <FontAwesomeIcon icon={faExclamationCircle} /> &nbsp;Treeherder update available </Button> )} {/* Unclassified Failures Button */} <Button className={`btn btn-sm ${ allUnclassifiedFailureCount ? 'btn-unclassified-failures' : 'btn-view-nav' }${filterModel.isUnclassifiedFailures() ? ' active' : ''}`} title="Loaded failures / toggle filtering for unclassified failures" onClick={this.toggleUnclassifiedFailures} > <span id="unclassified-failure-count"> {allUnclassifiedFailureCount} </span>{' '} unclassified </Button> {/* Filtered Unclassified Failures Button */} {filteredUnclassifiedFailureCount !== allUnclassifiedFailureCount && ( <span className="navbar-badge badge badge-secondary badge-pill" title="Reflects the unclassified failures which pass the current filters" > <span id="filtered-unclassified-failure-count"> {filteredUnclassifiedFailureCount} </span> </span> )} {/* Toggle Duplicate Jobs */} <Button className={`btn btn-view-nav btn-sm btn-toggle-duplicate-jobs bg-transparent border border-0 ${ groupCountsExpanded ? 'disabled' : '' } ${!duplicateJobsVisible ? 'strikethrough' : ''}`} tabIndex="0" role="button" title={ duplicateJobsVisible ? 'Hide duplicate jobs' : 'Show duplicate jobs' } onClick={() => !groupCountsExpanded && this.toggleShowDuplicateJobs() } /> {/* Toggle Group State Button */} <Button className="py-0 px-1 btn-view-nav mr-1" title={ groupCountsExpanded ? 'Collapse job groups' : 'Expand job groups' } onClick={this.toggleGroupState} > ( <span className="group-state-nav-icon mx-1"> {groupCountsExpanded ? '-' : '+'} </span> ) </Button> {/* Result Status Filter Chicklets */} <span className="resultStatusChicklets"> <span id="filter-chicklets"> {this.filterChicklets.map((filterName) => { const isOn = this.isFilterOn(filterName); return ( <span key={filterName}> <FontAwesomeIcon className={`btn btn-view-nav btn-nav-filter ${getBtnClass( filterName, )}-filter-chicklet`} icon={isOn ? faDotCircle : faCircle} onClick={() => this.toggleResultStatusFilterChicklet(filterName) } title={filterName} aria-label={filterName} role="checkbox" aria-checked={isOn} tabIndex={0} /> </span> ); })} </span> </span> <span> <Button size="sm" className="btn-view-nav" onClick={toggleFieldFilterVisible} title="Filter by a job field" > <FontAwesomeIcon icon={faFilter} size="sm" title="Filter by a job field" /> </Button> </span> <span> <TierIndicator filterModel={filterModel} /> </span> {/* Quick Filter Field */} <span id="quick-filter-parent" className="form-group form-inline" tabIndex={-1} > <input id="quick-filter" className="form-control form-control-sm" required value={searchQueryStr} title="Click to enter filter values" onChange={(evt) => this.setSearchStr(evt)} onKeyDown={(evt) => this.search(evt)} type="text" placeholder="Filter platforms & jobs" /> <FontAwesomeIcon id="quick-filter-clear-button" icon={faTimesCircle} title="Clear this filter" onClick={this.clearFilterBox} /> </span> </form> </span> </div> ); } } SecondaryNavBar.propTypes = { updateButtonClick: PropTypes.func.isRequired, serverChanged: PropTypes.bool.isRequired, filterModel: PropTypes.shape({}).isRequired, repos: PropTypes.arrayOf(PropTypes.shape({})).isRequired, setCurrentRepoTreeStatus: PropTypes.func.isRequired, allUnclassifiedFailureCount: PropTypes.number.isRequired, recalculateUnclassifiedCounts: PropTypes.func.isRequired, filteredUnclassifiedFailureCount: PropTypes.number.isRequired, duplicateJobsVisible: PropTypes.bool.isRequired, groupCountsExpanded: PropTypes.bool.isRequired, toggleFieldFilterVisible: PropTypes.func.isRequired, pushRoute: PropTypes.func.isRequired, }; const mapStateToProps = ({ pushes: { allUnclassifiedFailureCount, filteredUnclassifiedFailureCount }, router, }) => ({ allUnclassifiedFailureCount, filteredUnclassifiedFailureCount, router, }); export default connect(mapStateToProps, { recalculateUnclassifiedCounts, pushRoute, })(SecondaryNavBar);