ui/job-view/pushes/FuzzyJobFinder.jsx (305 lines of code) (raw):
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Button,
Col,
FormGroup,
Label,
Modal,
ModalHeader,
ModalBody,
ModalFooter,
InputGroup,
Input,
} from 'reactstrap';
import Fuse from 'fuse.js';
import PushModel from '../../models/push';
import { formatTaskclusterError } from '../../helpers/errorMessage';
import { sortAlphaNum } from '../../helpers/sort';
import { notify } from '../redux/stores/notifications';
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
useExtendedSearch: true,
keys: ['name', 'symbol'],
threshold: 0.4, // 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).map((job) => job.item)
: currentList,
}));
});
}
} else {
this.setState({
fuzzyList: currentList,
});
}
};
resetForm = () => {
this.setState({
selectedList: [],
removeDisabled: true,
submitDisabled: false,
});
};
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,
this.props.currentRepo,
)
.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 (Search)</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
data-testid="fuzzyList"
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.arrayOf(PropTypes.shape({})),
filteredJobList: PropTypes.arrayOf(PropTypes.shape({})),
currentRepo: PropTypes.shape({}).isRequired,
};
FuzzyJobFinder.defaultProps = {
jobList: [],
filteredJobList: [],
decisionTaskId: '',
};
export default connect(null, { notify })(FuzzyJobFinder);