ui/job-view/details/PinBoard.jsx (710 lines of code) (raw):

import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { Button, ButtonGroup, FormGroup, Input, FormFeedback, DropdownMenu, DropdownItem, DropdownToggle, UncontrolledDropdown, } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPlusSquare, faTimes } from '@fortawesome/free-solid-svg-icons'; import { thEvents } from '../../helpers/constants'; import { formatModelError } from '../../helpers/errorMessage'; import { findJobInstance, getBtnClass } from '../../helpers/job'; import { isSHAorCommit } from '../../helpers/revision'; import { getBugUrl } from '../../helpers/url'; import BugJobMapModel from '../../models/bugJobMap'; import JobClassificationModel from '../../models/classification'; import JobClassificationTypeAndBugsModel from '../../models/classificationTypeAndBugs'; import JobModel from '../../models/job'; import { notify } from '../redux/stores/notifications'; import { setSelectedJob } from '../redux/stores/selectedJob'; import { recalculateUnclassifiedCounts } from '../redux/stores/pushes'; import { addBug, removeBug, unPinJob, unPinAll, setClassificationId, setClassificationComment, } from '../redux/stores/pinnedJobs'; class PinBoard extends React.Component { constructor(props) { super(props); this.state = { enteringBugNumber: false, newBugNumber: null, }; } componentDidMount() { window.addEventListener(thEvents.saveClassification, this.save); } componentWillUnmount() { window.removeEventListener(thEvents.saveClassification, this.save); } unPinAll = () => { this.props.unPinAll(); this.setState({ enteringBugNumber: false, newBugNumber: null, }); }; save = () => { const { isLoggedIn, pinnedJobs, recalculateUnclassifiedCounts, notify, } = this.props; let errorFree = true; if (this.state.enteringBugNumber) { // we should save this for the user, as they likely // just forgot to hit enter. Returns false if invalid errorFree = this.saveEnteredBugNumber(); if (!errorFree) { notify('Please enter a valid bug number', 'danger'); } } if (!this.canSaveClassifications() && isLoggedIn) { notify('Please classify this failure before saving', 'danger'); errorFree = false; } if (!isLoggedIn) { notify('Must be logged in to save job classifications', 'danger'); errorFree = false; } if (errorFree) { const jobs = Object.values(pinnedJobs); const classifyPromises = jobs.map((job) => this.saveClassification(job)); const bugPromises = jobs.map((job) => this.saveBugs(job)); Promise.all([...classifyPromises, ...bugPromises]).then(() => { window.dispatchEvent(new CustomEvent(thEvents.classificationChanged)); recalculateUnclassifiedCounts(); this.unPinAll(); this.setState({ enteringBugNumber: false, newBugNumber: null, }); }); } }; createNewClassification = () => { const { email } = this.props; const { failureClassificationId, failureClassificationComment, } = this.props; return new JobClassificationModel({ text: failureClassificationComment, who: email, failure_classification_id: failureClassificationId, }); }; saveClassification = async (pinnedJob) => { const { recalculateUnclassifiedCounts, notify, jobMap } = this.props; const classification = this.createNewClassification(); // Ensure the version of the job we have is the one that is displayed in // the main job field. Not the "full" selected job instance only shown in // the job details panel. const job = jobMap[pinnedJob.id]; // classification can be left unset making this a no-op if (classification.failure_classification_id > 0) { job.failure_classification_id = classification.failure_classification_id; // update the unclassified failure count for the page recalculateUnclassifiedCounts(); classification.job_id = job.id; const { data, failureStatus } = await classification.create(); if (!failureStatus) { // update the job to show that it's now classified const jobInstance = findJobInstance(job.id); // Filter in case we are hiding unclassified. Also causes a repaint on the job // to show it if has been newly classified or not. if (jobInstance) { jobInstance.refilter(); } } else { const message = `Error saving classification for ${job.platform} ${job.job_type_name}: ${data}`; notify(message, 'danger'); } } }; saveBugs = (job) => { const { pinnedJobBugs, newBug, notify } = this.props; pinnedJobBugs.forEach((bug) => { const bjm = new BugJobMapModel({ // Use dupe_of by default for BugJobMap creation bug_id: bug.dupe_of ?? bug.id ?? null, internal_id: bug.internal_id ?? null, job_id: job.id, type: 'annotation', bug_open: newBug.has(bug.id), }); bjm.create().catch((response) => { const message = `Error saving bug association for ${job.platform} ${job.job_type_name}`; notify(formatModelError(response, message), 'danger'); }); }); }; // If the pasted data is (or looks like) a 12 or 40 char SHA, // or if the pasted data is an hg.m.o url, automatically select // the 'fixed by commit' classification type pasteSHA = (evt) => { const pastedData = evt.clipboardData.getData('text'); if (isSHAorCommit(pastedData)) { this.props.setClassificationId(2); } }; cancelAllPinnedJobsTitle = () => { if (!this.props.isLoggedIn) { return 'Not logged in'; } if (!this.canCancelAllPinnedJobs()) { return 'No pending / running jobs in pinBoard'; } return 'Cancel all the pinned jobs'; }; canCancelAllPinnedJobs = () => { const cancellableJobs = Object.values(this.props.pinnedJobs).filter( (job) => job.state === 'pending' || job.state === 'running', ); return this.props.isLoggedIn && cancellableJobs.length > 0; }; cancelAllPinnedJobs = () => { const { notify, currentRepo, pinnedJobs, decisionTaskMap } = this.props; if ( window.confirm('This will cancel all the selected jobs. Are you sure?') ) { JobModel.cancel( Object.values(pinnedJobs), currentRepo, notify, decisionTaskMap, ); this.unPinAll(); } }; unclassifyAllPinnedJobsTitle = () => { if (!this.props.isStaff) { return 'Must be employee or sheriff'; } if (!this.canCancelAllPinnedJobs()) { return 'No jobs in pinboard'; } return 'Unclassify all the pinned jobs'; }; canUnclassifyAllPinnedJobs = () => { return ( this.props.isStaff && Object.values(this.props.pinnedJobs).length > 0 ); }; unclassifyAllPinnedJobs = async () => { const { notify, currentRepo, jobMap, pinnedJobs, recalculateUnclassifiedCounts, } = this.props; const { data, failureStatus, } = await JobClassificationTypeAndBugsModel.destroy( Object.values(pinnedJobs), currentRepo, notify, ); if (!failureStatus) { for (const pinnedJob of Object.values(pinnedJobs)) { const job = jobMap[pinnedJob.id]; job.failure_classification_id = 1; // Update the job to show that it's unclassified now. const jobInstance = findJobInstance(job.id); // Filter in case we are hiding unclassified. if (jobInstance) { jobInstance.refilter(); } } this.unPinAll(); window.dispatchEvent(new CustomEvent(thEvents.classificationChanged)); recalculateUnclassifiedCounts(); } else { const message = `Error deleting classifications: ${data}`; notify(message, 'danger'); } }; canSaveClassifications = () => { const { pinnedJobBugs, isLoggedIn, currentRepo } = this.props; const { failureClassificationId, failureClassificationComment, } = this.props; return ( this.hasPinnedJobs() && isLoggedIn && (!!pinnedJobBugs.length || (failureClassificationId !== 4 && failureClassificationId !== 2) || currentRepo.is_try_repo || currentRepo.repository_group.name === 'project repositories' || (failureClassificationId === 4 && failureClassificationComment.length > 0) || (failureClassificationId === 2 && failureClassificationComment.length > 7)) ); }; // Facilitates Clear all if no jobs pinned to reset pinBoard UI pinboardIsDirty = () => { const { failureClassificationId, failureClassificationComment, } = this.props; return ( failureClassificationComment !== '' || !!this.props.pinnedJobBugs.length || failureClassificationId !== 4 ); }; // Dynamic btn/anchor title for classification save saveUITitle = (category) => { let title = ''; if (!this.props.isLoggedIn) { title = title.concat('not logged in / '); } if (category === 'classification') { if (!this.canSaveClassifications()) { title = title.concat('ineligible classification data / '); } if (!this.hasPinnedJobs()) { title = title.concat('no pinned jobs'); } // We don't check pinned jobs because the menu dropdown handles it } else if (category === 'bug') { if (!this.hasPinnedJobBugs()) { title = title.concat('no related bugs'); } } if (title === '') { title = `Save ${category} data`; } else { // Cut off trailing '/ ' if one exists, capitalize first letter title = title.replace(/\/ $/, ''); title = title.replace(/^./, (l) => l.toUpperCase()); } return title; }; hasPinnedJobs = () => !!Object.keys(this.props.pinnedJobs).length; hasPinnedJobBugs = () => !!this.props.pinnedJobBugs.length; toggleEnterBugNumber = (tf) => { this.setState( { enteringBugNumber: tf, }, () => { if (tf) { document.getElementById('related-bug-input').focus(); } }, ); }; isNumber = (text) => !text || /^[0-9]*$/.test(text); saveEnteredBugNumber = () => { const { newBugNumber, enteringBugNumber } = this.state; if (enteringBugNumber) { if (!newBugNumber) { this.toggleEnterBugNumber(false); } else if (this.isNumber(newBugNumber)) { this.props.addBug({ id: parseInt(newBugNumber, 10) }); this.toggleEnterBugNumber(false); } } }; bugNumberKeyPress = (ev) => { if (ev.key === 'Enter') { this.saveEnteredBugNumber(ev.target.value); if (ev.ctrlKey) { // If ctrl+enter, then save the classification this.save(); } ev.preventDefault(); } else if (ev.key === 'Escape') { this.toggleEnterBugNumber(false); } }; retriggerAllPinnedJobs = async () => { const { pinnedJobs, notify, currentRepo, decisionTaskMap } = this.props; const jobs = Object.values(pinnedJobs); JobModel.retrigger(jobs, currentRepo, notify, 1, decisionTaskMap); }; render() { const { selectedJobFull, revisionTips, isLoggedIn, isPinBoardVisible, classificationTypes, pinnedJobs, pinnedJobBugs, removeBug, unPinJob, setSelectedJob, setClassificationId, setClassificationComment, failureClassificationId, failureClassificationComment, } = this.props; const { enteringBugNumber, newBugNumber } = this.state; const selectedJobId = selectedJobFull ? selectedJobFull.id : null; return ( <div id="pinboard-panel" className={isPinBoardVisible ? '' : 'hidden'}> <div id="pinboard-contents"> <div id="pinned-job-list"> <div className="content"> {!this.hasPinnedJobs() && ( <span className="pinboard-preload-txt"> press spacebar to pin a selected job </span> )} {Object.values(pinnedJobs).map((job) => ( <span className="btn-group" key={job.id}> <Button className={`pinned-job mb-1 ${getBtnClass( job.resultStatus, job.failure_classification_id, )} ${selectedJobId === job.id ? 'selected-job' : ''}`} title={job.hoverText} onClick={() => setSelectedJob(job)} data-job-id={job.job_id} size={selectedJobId === job.id ? 'large' : 'small'} outline > {job.job_type_symbol} </Button> <Button color="secondary" outline className={`pinned-job-close-btn ${ selectedJobId === job.id ? 'btn-lg selected-job' : 'btn-xs' }`} onClick={() => unPinJob(job)} title="un-pin this job" > <FontAwesomeIcon icon={faTimes} title="Unpin job" /> </Button> </span> ))} </div> </div> {/* Related bugs */} <div id="pinboard-related-bugs"> <div className="content"> <Button color="link" id="add-related-bug-button" onClick={() => this.toggleEnterBugNumber(!enteringBugNumber)} className="pointable p-0" title="Add a related bug" > <FontAwesomeIcon icon={faPlusSquare} className="add-related-bugs-icon" title="Add related bugs" /> </Button> {!this.hasPinnedJobBugs() && ( <Button color="link" className="pinboard-preload-txt pinboard-related-bug-preload-txt p-0 text-decoration-none" onClick={() => this.toggleEnterBugNumber(!enteringBugNumber)} > click to add a related bug </Button> )} {enteringBugNumber && ( <span className="add-related-bugs-form d-flex align-items-start"> <div> <Input id="related-bug-input" data-bug-input type="text" pattern="[0-9]*" className="add-related-bugs-input" placeholder="enter bug number" invalid={!this.isNumber(newBugNumber)} onKeyPress={this.bugNumberKeyPress} onChange={(ev) => { this.setState({ newBugNumber: ev.target.value }); }} onBlur={this.saveEnteredBugNumber} /> <FormFeedback>Please enter only numbers</FormFeedback> </div> <Button color="link" id="clear-related-bug-button" onClick={() => this.setState({ enteringBugNumber: false, newBugNumber: null, }) } className="pointable p-0" title="Close a related bug" > <FontAwesomeIcon icon={faTimes} className="text-danger ml-2" title="Close related bugs" /> </Button> </span> )} {Array.from(pinnedJobBugs).map((bug) => ( <span key={bug.internal_id}> <span className="btn-group pinboard-related-bugs-btn"> {!bug.id && ( <span className="btn btn-xs"> <em>i{bug.internal_id}</em> </span> )} {bug.id && ( <a className="btn btn-xs related-bugs-link" href={getBugUrl(bug.dupe_of ?? bug.id)} target="_blank" rel="noopener noreferrer" data-testid={`pinboard-bug-${bug.id}`} > <em>{bug.dupe_of ?? bug.id}</em> </a> )} <Button color="secondary" outline className="btn-xs pinned-job-close-btn" onClick={() => removeBug(bug)} title="remove this bug" > <FontAwesomeIcon icon={faTimes} title="Remove bug" /> </Button> </span> </span> ))} </div> </div> {/* Classification dropdown */} <div id="pinboard-classification"> <div className="pinboard-label">classification</div> <div id="pinboard-classification-content" className="content"> <FormGroup> <Input type="select" name="failureClassificationId" id="pinboard-classification-select" className="classification-select" value={failureClassificationId} onChange={(evt) => setClassificationId(parseInt(evt.target.value, 10)) } > {classificationTypes.map((opt) => ( <option value={opt.id} key={opt.id}> {opt.name} </option> ))} </Input> </FormGroup> {/* Classification comment */} <div className="classification-comment-container"> <input id="classification-comment" type="text" className="form-control add-classification-input" onChange={(evt) => setClassificationComment(evt.target.value)} onPaste={this.pasteSHA} placeholder="click to add comment" value={failureClassificationComment} /> {failureClassificationId === 2 && ( <div> <FormGroup> <Input id="pinboard-revision-select" className="classification-select" type="select" defaultValue={0} onChange={(evt) => setClassificationComment(evt.target.value) } > <option value="0" disabled> Choose a recent commit </option> {revisionTips.slice(0, 20).map((tip) => ( <option title={tip.title} value={tip.revision} key={tip.revision} > {tip.revision.slice(0, 12)} {tip.author} </option> ))} </Input> </FormGroup> </div> )} </div> </div> </div> {/* Save UI */} <div id="pinboard-controls" className="btn-group-vertical" title={this.hasPinnedJobs() ? '' : 'No pinned jobs'} > <ButtonGroup className="save-btn-group"> <Button className={`save-btn ${ !isLoggedIn || !this.canSaveClassifications() ? 'disabled' : '' }`} outline size="xs" title={this.saveUITitle('classification')} onClick={this.save} > save </Button> <UncontrolledDropdown> <DropdownToggle size="xs" caret className={`bg-light ${ !this.hasPinnedJobs() && !this.pinboardIsDirty() ? 'disabled' : '' }`} title={ !this.hasPinnedJobs() && !this.pinboardIsDirty() ? 'No pinned jobs' : 'Additional pinboard functions' } outline /> <DropdownMenu className="save-btn-dropdown-menu"> <DropdownItem tag="a" title={ !isLoggedIn ? 'Not logged in' : 'Repeat the pinned jobs' } className={!isLoggedIn ? 'disabled' : ''} onClick={() => !isLoggedIn || this.retriggerAllPinnedJobs()} > Retrigger all </DropdownItem> <DropdownItem tag="a" title={this.cancelAllPinnedJobsTitle()} className={this.canCancelAllPinnedJobs() ? '' : 'disabled'} onClick={() => this.canCancelAllPinnedJobs() && this.cancelAllPinnedJobs() } > Cancel all </DropdownItem> <DropdownItem tag="a" title={this.unclassifyAllPinnedJobsTitle()} className={ this.canUnclassifyAllPinnedJobs() ? '' : 'disabled' } onClick={() => this.canUnclassifyAllPinnedJobs() && this.unclassifyAllPinnedJobs() } > Unclassify all </DropdownItem> <DropdownItem tag="a" onClick={() => this.unPinAll()}> Clear all </DropdownItem> </DropdownMenu> </UncontrolledDropdown> </ButtonGroup> </div> </div> </div> ); } } PinBoard.propTypes = { recalculateUnclassifiedCounts: PropTypes.func.isRequired, decisionTaskMap: PropTypes.shape({}).isRequired, jobMap: PropTypes.shape({}).isRequired, classificationTypes: PropTypes.arrayOf(PropTypes.shape({})).isRequired, isLoggedIn: PropTypes.bool.isRequired, isStaff: PropTypes.bool.isRequired, isPinBoardVisible: PropTypes.bool.isRequired, pinnedJobs: PropTypes.shape({}).isRequired, pinnedJobBugs: PropTypes.shape({}).isRequired, newBug: PropTypes.string.isRequired, addBug: PropTypes.func.isRequired, removeBug: PropTypes.func.isRequired, unPinJob: PropTypes.func.isRequired, unPinAll: PropTypes.func.isRequired, setClassificationId: PropTypes.func.isRequired, setClassificationComment: PropTypes.func.isRequired, setSelectedJob: PropTypes.func.isRequired, notify: PropTypes.func.isRequired, currentRepo: PropTypes.shape({}).isRequired, failureClassificationId: PropTypes.number.isRequired, failureClassificationComment: PropTypes.string.isRequired, selectedJobFull: PropTypes.shape({}), email: PropTypes.string, revisionTips: PropTypes.arrayOf(PropTypes.shape({})), }; PinBoard.defaultProps = { selectedJobFull: null, email: null, revisionTips: [], }; const mapStateToProps = ({ pushes: { revisionTips, decisionTaskMap, jobMap }, pinnedJobs: { isPinBoardVisible, pinnedJobs, pinnedJobBugs, failureClassificationId, failureClassificationComment, newBug, }, }) => ({ revisionTips, decisionTaskMap, jobMap, isPinBoardVisible, pinnedJobs, pinnedJobBugs, failureClassificationId, failureClassificationComment, newBug, }); export default connect(mapStateToProps, { notify, setSelectedJob, recalculateUnclassifiedCounts, addBug, removeBug, unPinJob, unPinAll, setClassificationId, setClassificationComment, })(PinBoard);