Skip to content

Commit

Permalink
Bug 1517700 - Add a fuzzy job finder to Treeherder
Browse files Browse the repository at this point in the history
  • Loading branch information
KWierso authored and Cameron Dawson committed Mar 28, 2019
1 parent 1b63222 commit 2476432
Show file tree
Hide file tree
Showing 9 changed files with 460 additions and 0 deletions.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions 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;
}
26 changes: 26 additions & 0 deletions 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;
}
};
1 change: 1 addition & 0 deletions ui/job-view/index.jsx
Expand Up @@ -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';
Expand Down
324 changes: 324 additions & 0 deletions 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 (
<div>
<Modal
onOpened={this.filterJobs}
onClosed={this.resetForm}
size="lg"
isOpen={this.props.isOpen}
toggle={this.props.toggle}
className={this.props.className}
>
<ModalHeader>Add New Jobs (Fuzzy)</ModalHeader>
<ModalBody>
<FormGroup row>
<Col sm={10}>
<Input
type="search"
onKeyDown={this.filterJobs}
placeholder="Filter runnable jobs: 'Android', 'Mochitest', 'Build', etc..."
className="my-2"
title="Filter the list of runnable jobs"
/>
</Col>
<Col sm={2}>
<Label
className="my-3"
onChange={evt => this.toggleFullList(evt)}
title="The full list includes thousands of jobs that don't typically get run, and is much slower to render"
>
<Input type="checkbox" /> Use full job list
</Label>
</Col>
</FormGroup>
<h4> Runnable Jobs [{this.state.fuzzyList.length}]</h4>
<div className="fuzzybuttons">
<Button
onClick={this.addJobs}
color="success"
disabled={this.state.addDisabled}
>
Add selected
</Button>
&nbsp;
<Button color="success" onClick={this.addAllJobs}>
Add all
</Button>
</div>
<InputGroup id="addJobsGroup">
<Input type="select" multiple onChange={this.updateAddButton}>
{this.state.fuzzyList.sort(sortAlphaNum).map(e => (
<option
title={`${e.name} - ${e.groupsymbol}(${e.symbol})`}
key={e.name}
className={
this.state.selectedList.includes(e.name) ? 'selected' : ''
}
>
{e.name}
</option>
))}
</Input>
</InputGroup>
<hr />
<h4> Selected Jobs [{this.state.selectedList.length}]</h4>
<div className="fuzzybuttons">
<Button
onClick={this.removeJobs}
color="danger"
disabled={this.state.removeDisabled}
>
Remove selected
</Button>
&nbsp;
<Button
color="danger"
onClick={this.removeAllJobs}
disabled={this.state.selectedList.length === 0}
>
Remove all
</Button>
</div>
<InputGroup id="removeJobsGroup">
<Input type="select" multiple onChange={this.updateRemoveButton}>
{this.state.selectedList.sort(sortAlphaNum).map(e => (
<option title={e} key={e}>
{e}
</option>
))}
</Input>
</InputGroup>
</ModalBody>
<ModalFooter>
<Button
color="primary"
onClick={this.submitJobs}
disabled={
this.state.selectedList.length === 0 ||
this.state.submitDisabled
}
>
Trigger ({this.state.selectedList.length}) Selected Jobs
</Button>{' '}
<Button color="secondary" onClick={this.props.toggle}>
Cancel
</Button>
</ModalFooter>
</Modal>
</div>
);
}
}

FuzzyJobFinder.propTypes = {
className: PropTypes.string.isRequired,
isOpen: PropTypes.bool.isRequired,
notify: PropTypes.func.isRequired,
toggle: PropTypes.func.isRequired,
decisionTaskId: PropTypes.string,
jobList: PropTypes.array,
filteredJobList: PropTypes.array,
};

FuzzyJobFinder.defaultProps = {
jobList: [],
filteredJobList: [],
decisionTaskId: '',
};

export default withNotifications(FuzzyJobFinder);

0 comments on commit 2476432

Please sign in to comment.