diff --git a/package.json b/package.json index 0e32a39faa9..6ce8d2b2443 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "auth0-js": "9.10.1", "bootstrap": "4.3.1", "d3": "5.9.2", + "fuse.js": "3.4.4", "history": "4.9.0", "jquery": "3.3.1", "jquery.flot": "0.8.3", diff --git a/ui/css/treeherder-fuzzyfinder.css b/ui/css/treeherder-fuzzyfinder.css new file mode 100644 index 00000000000..632656f5ad1 --- /dev/null +++ b/ui/css/treeherder-fuzzyfinder.css @@ -0,0 +1,12 @@ +/* + * Fuzzy runnable job finder + */ + +.fuzzy-modal .modal-body #addJobsGroup option.selected::before { + content: '• '; + color: red; +} + +div.fuzzybuttons { + margin-bottom: 3px; +} diff --git a/ui/helpers/sort.js b/ui/helpers/sort.js new file mode 100644 index 00000000000..45d17cfa87c --- /dev/null +++ b/ui/helpers/sort.js @@ -0,0 +1,26 @@ +// eslint-disable-next-line import/prefer-default-export +export const sortAlphaNum = (a, b) => { + // Implement a better alphanumeric sort so that mochitest-10 + // is sorted after mochitest 9, not mochitest-1 + const reA = /[^a-zA-Z]/g; + const reN = /[^0-9]/g; + if (a.name) { + a = a.name; + b = b.name; + } + const aA = a.replace(reA, ''); + const bA = b.replace(reA, ''); + if (aA === bA) { + const aN = parseInt(a.replace(reN, ''), 10); + const bN = parseInt(b.replace(reN, ''), 10); + let rv; + if (aN === bN) { + rv = 0; + } else if (aN > bN) { + rv = 1; + } else { + rv = -1; + } + return rv; + } +}; diff --git a/ui/job-view/index.jsx b/ui/job-view/index.jsx index db0f168c420..09b8675710b 100644 --- a/ui/job-view/index.jsx +++ b/ui/job-view/index.jsx @@ -19,6 +19,7 @@ import '../css/treeherder-job-buttons.css'; import '../css/treeherder-resultsets.css'; import '../css/treeherder-pinboard.css'; import '../css/treeherder-bugfiler.css'; +import '../css/treeherder-fuzzyfinder.css'; import '../css/treeherder-loading-overlay.css'; import App from './App'; diff --git a/ui/job-view/pushes/FuzzyJobFinder.jsx b/ui/job-view/pushes/FuzzyJobFinder.jsx new file mode 100644 index 00000000000..4edf9234469 --- /dev/null +++ b/ui/job-view/pushes/FuzzyJobFinder.jsx @@ -0,0 +1,324 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + Col, + FormGroup, + Label, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + InputGroup, + Input, +} from 'reactstrap'; +import Fuse from 'fuse.js'; + +import PushModel from '../../models/push'; +import { withNotifications } from '../../shared/context/Notifications'; +import { formatTaskclusterError } from '../../helpers/errorMessage'; +import { sortAlphaNum } from '../../helpers/sort'; + +class FuzzyJobFinder extends React.Component { + constructor(props) { + super(props); + + this.state = { + fuzzySearch: '', + fuzzyList: [], + selectedList: [], + removeDisabled: true, + addDisabled: true, + submitDisabled: false, + }; + } + + /* + * Filter the list of runnable jobs based on the value of this input. + * Only actually do the filtering when `enter` is pressed, as filtering 13K DOM elements is slow... + * If this input is empty when `enter` is pressed, reset back to the full list of runnable jobs. + */ + filterJobs = ev => { + // By default we show a trimmed down list of runnable jobs, but there's an option to show the full list + let currentList; + if (this.state.useFullList) { + currentList = this.props.jobList; + } else { + currentList = this.props.filteredJobList; + } + + if (ev && ev.type === 'keydown') { + if (ev.key === 'Enter') { + this.setState({ fuzzySearch: ev.target.value }, () => { + const options = { + // http://fusejs.io/ describes the options available + keys: ['name'], + threshold: 0.3, // This seems like a good threshold to remove most false matches, lower is stricter + matchAllTokens: true, + tokenize: true, + }; + + // Always search from the full (or full filtered) list of jobs + const fuse = new Fuse(currentList, options); + + this.setState(prevState => ({ + fuzzyList: prevState.fuzzySearch + ? fuse.search(prevState.fuzzySearch) + : currentList, + })); + }); + } + } else { + this.setState({ + fuzzyList: currentList, + }); + } + }; + + resetForm = () => { + this.setState({ + selectedList: [], + removeDisabled: true, + }); + }; + + addAllJobs = () => { + const selectedOptions = Array.from( + this.state.fuzzyList, + option => option.name, + ); + let { selectedList } = this.state; + + // When adding jobs, add only new, unique job names to avoid duplicates + selectedList = [...new Set([].concat(selectedList, selectedOptions))]; + this.setState({ selectedList }); + }; + + removeAllJobs = () => { + this.setState({ + selectedList: [], + removeDisabled: true, + }); + }; + + addJobs = evt => { + const { selectedList } = this.state; + const { addJobsSelected } = this.state; + + // When adding jobs, add only new, unique job names to avoid duplicates + const newSelectedList = [ + ...new Set([].concat(selectedList, addJobsSelected)), + ]; + this.setState({ selectedList: newSelectedList }); + evt.target.parentNode.previousElementSibling.selectedIndex = -1; + }; + + removeJobs = () => { + const { selectedList } = this.state; + const { removeJobsSelected } = this.state; + + const newSelectedList = selectedList.filter( + value => !removeJobsSelected.includes(value), + ); + + this.setState({ selectedList: newSelectedList }, () => { + this.setState({ + removeDisabled: true, + }); + }); + }; + + submitJobs = () => { + const { notify } = this.props; + if (this.state.selectedList.length > 0) { + notify('Submitting selected jobs...'); + this.setState({ + submitDisabled: true, + }); + PushModel.triggerNewJobs( + this.state.selectedList, + this.props.decisionTaskId, + ) + .then(result => { + notify(result, 'success'); + this.props.toggle(); + }) + .catch(e => { + notify(formatTaskclusterError(e), 'danger', { sticky: true }); + this.setState({ + submitDisabled: false, + }); + }); + } else { + notify('Please select at least one job from the list', 'danger'); + } + }; + + toggleFullList = evt => { + this.setState( + { + useFullList: evt.target.checked, + }, + () => { + // Fake enough state to simulate the enter key being pressed in the search box + this.filterJobs({ + type: 'keydown', + key: 'Enter', + target: { value: this.state.fuzzySearch }, + }); + }, + ); + }; + + updateAddButton = evt => { + const selectedOptions = Array.from( + evt.target.selectedOptions, + option => option.textContent, + ); + + this.setState({ + addDisabled: selectedOptions.length === 0, + addJobsSelected: selectedOptions, + }); + }; + + updateRemoveButton = evt => { + const selectedOptions = Array.from( + evt.target.selectedOptions, + option => option.textContent, + ); + this.setState({ + removeDisabled: selectedOptions.length === 0, + removeJobsSelected: selectedOptions, + }); + }; + + render() { + return ( +