Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Bug 1517700 - Add a fuzzy job finder to Treeherder
- Loading branch information
Showing
9 changed files
with
460 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
/* | ||
* Fuzzy runnable job finder | ||
*/ | ||
|
||
.fuzzy-modal .modal-body #addJobsGroup option.selected::before { | ||
content: '• '; | ||
color: red; | ||
} | ||
|
||
div.fuzzybuttons { | ||
margin-bottom: 3px; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
| ||
<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> | ||
| ||
<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); |
Oops, something went wrong.