ui/perfherder/graphs/TestDataModal.jsx (646 lines of code) (raw):

import React from 'react'; import PropTypes from 'prop-types'; import { Button, Col, Form, Input, Label, Modal, ModalHeader, ModalBody, Row, FormGroup, Badge, } from 'reactstrap'; import flatMap from 'lodash/flatMap'; import { createDropdowns } from '../../shared/FilterControls'; import InputFilter from '../../shared/InputFilter'; import { processResponse } from '../../helpers/http'; import PerfSeriesModel from '../../models/perfSeries'; import { thPerformanceBranches } from '../../helpers/constants'; import { containsText, getInitialData, getSeriesData, } from '../perf-helpers/helpers'; import TimeRangeDropdown from './TimeRangeDropdown'; export default class TestDataModal extends React.Component { constructor(props) { super(props); this.state = { platforms: [], framework: { name: 'talos', id: 1 }, repository_name: this.findObject( this.props.projects, 'name', 'mozilla-central', ), platform: 'linux64', pinnedProjects: ['autoland', 'mozilla-central', 'mozilla-beta', 'try'], errorMessages: [], includeSubtests: false, seriesData: [], relatedTests: [], selectedTests: [], filteredData: [], showNoRelatedTests: false, filterText: '', loading: true, selectedUnits: new Set(), activeTags: [], availableTags: [], innerTimeRange: this.props.timeRange, }; } async componentDidMount() { const { errorMessages, framework, innerTimeRange, repository_name: repositoryName, } = this.state; const { getInitialData } = this.props; const updates = await getInitialData( errorMessages, repositoryName, framework, innerTimeRange, ); this.setState(updates, this.processOptions); } componentDidUpdate(prevProps, prevState) { const { activeTags, availableTags, platform, platforms } = this.state; const { testData, timeRange } = this.props; if (prevState.platforms !== platforms) { const newPlatform = platforms.find((item) => item === platform) ? platform : platforms[0]; this.setState({ platform: newPlatform }); } if (prevState.availableTags !== availableTags) { const newActiveTags = activeTags.filter((tag) => availableTags.includes(tag), ); this.setState({ activeTags: newActiveTags }, this.applyFilters); } if (this.props.options !== prevProps.options) { this.processOptions(true); } if (timeRange !== prevProps.timeRange) { this.setState({ innerTimeRange: timeRange }); } if (testData !== prevProps.testData) { this.processOptions(); } } async getPlatforms() { const { errorMessages, framework, innerTimeRange, repository_name: repositoryName, } = this.state; const params = { interval: innerTimeRange.value, framework: framework.id }; const response = await PerfSeriesModel.getPlatformList( repositoryName.name, params, ); const updates = processResponse(response, 'platforms', errorMessages); this.setState(updates); this.processOptions(); } getTagOptions(seriesData) { const newAvailableTags = flatMap(seriesData, (test) => test.tags); return [...new Set(newAvailableTags)]; } getDropdownOptions(options) { return options.length ? options.map((item) => item.name) : []; } addRelatedApplications = async (params) => { const { relatedSeries: relatedSignature } = this.props.options; const { errorMessages } = this.state; let relatedTests = []; const { data, failureStatus } = await PerfSeriesModel.getSeriesList( relatedSignature.repository_name, params, ); if (!failureStatus) { relatedTests = data.filter((signature) => { const differentApplications = signature.application !== relatedSignature.application; const similarTestNames = this.removeSubstring(signature.application, signature.name) === this.removeSubstring( relatedSignature.application, relatedSignature.name, ); const samePlatform = signature.platform === relatedSignature.platform; const sameProject = signature.projectName === relatedSignature.repository_name; return ( differentApplications && similarTestNames && samePlatform && sameProject ); }); } else { errorMessages.push(data); } this.setState({ relatedTests, showNoRelatedTests: relatedTests.length === 0, errorMessages, loading: false, }); }; addRelatedConfigs = async (params) => { const { relatedSeries } = this.props.options; const { errorMessages, repository_name: repositoryName } = this.state; const response = await PerfSeriesModel.getSeriesList( repositoryName.name, params, ); const updates = processResponse(response, 'relatedTests', errorMessages); if (updates.relatedTests.length) { const tests = updates.relatedTests.filter( (series) => series.platform === relatedSeries.platform && series.testName === relatedSeries.test && series.name !== relatedSeries.name, ); updates.relatedTests = tests; } updates.showNoRelatedTests = updates.relatedTests.length === 0; updates.loading = false; this.setState(updates); }; addRelatedBranches = async (params, samePlatform = true) => { const { relatedSeries } = this.props.options; const { errorMessages } = this.state; const relatedProjects = thPerformanceBranches.filter( (repositoryName) => repositoryName !== relatedSeries.repository_name, ); const requests = relatedProjects.map((projectName) => PerfSeriesModel.getSeriesList(projectName, params), ); const responses = await Promise.all(requests); const relatedTests = responses // eslint-disable-next-line array-callback-return .flatMap((item) => { if (!item.failureStatus) { return item.data; } errorMessages.push(item.data); }) .filter( (responseSeries) => responseSeries.name.trim() === relatedSeries.name.trim() && (samePlatform ? responseSeries.platform === relatedSeries.platform : responseSeries.platform !== relatedSeries.platform), ); this.setState({ relatedTests, showNoRelatedTests: relatedTests.length === 0, errorMessages, loading: false, }); }; processOptions = async (relatedTestsMode = false) => { const { option, relatedSeries } = this.props.options; const { errorMessages, filterText, framework, includeSubtests, innerTimeRange, platform, repository_name: repositoryName, } = this.state; const { getSeriesData, testData } = this.props; const params = { interval: innerTimeRange.value, framework: framework.id, subtests: +includeSubtests, }; this.setState({ loading: true }); if (!relatedTestsMode) { params.platform = platform; const updates = await getSeriesData( params, errorMessages, repositoryName, testData, ); const { seriesData } = updates; const availableTags = this.getTagOptions(seriesData); this.setState({ ...updates, availableTags }); this.applyFilters(filterText); } else { params.framework = relatedSeries.framework_id; if (option === 'addRelatedPlatform') { this.addRelatedBranches(params, false); } else if (option === 'addRelatedConfigs') { this.addRelatedConfigs(params); } else if (option === 'addRelatedBranches') { this.addRelatedBranches(params); } else if (option === 'addRelatedApplications') { this.addRelatedApplications(params); } this.applyFilters(filterText); } }; findObject = (list, key, value) => list.find((item) => item[key] === value); filterTestsByText = (tests, filterText) => { return tests.filter((test) => { // spell out all searchable characteristics // into a single encompassing string const textToSearch = `${test.name} ${test.application}`; return containsText(textToSearch, filterText); }); }; applyFilters = (filterText) => { const { seriesData, activeTags } = this.state; let filteredData = activeTags.length ? [] : [...seriesData]; if (activeTags.length) { filteredData = seriesData.filter((test) => activeTags.every((activeTag) => test.tags.includes(activeTag)), ); } if (filterText) { filteredData = this.filterTestsByText(filteredData, filterText); } this.setState({ filteredData, filterText }); }; toggleTag = (tag) => { const { filterText, activeTags } = this.state; let newActiveTags = [...activeTags]; if (activeTags.includes(tag)) { newActiveTags = activeTags.filter((activeTag) => activeTag !== tag); } else { newActiveTags = activeTags.concat(tag); } this.setState({ activeTags: newActiveTags }, () => { this.applyFilters(filterText); }); }; updateSelectedTests = (test, removeTest = false) => { let { selectedTests, selectedUnits } = this.state; const index = selectedTests.indexOf(test); if (index === -1) { selectedTests = [...selectedTests, ...[test]]; selectedUnits = this.extractUniqueUnits(selectedTests); this.setState({ selectedTests, selectedUnits, }); } else if (index !== -1 && removeTest) { selectedTests.splice(index, 1); selectedUnits = this.extractUniqueUnits(selectedTests); this.setState({ selectedTests, selectedUnits }); } }; getFullTestName = (test) => `${test.projectName} ${test.platform} ${test.name} ${ test.application || '' }`; getOriginalTestName = (test) => this.state.relatedTests.length > 0 ? this.getFullTestName(test) : `${test.name} ${test.application || ''}`; closeModal = () => { this.setState( { relatedTests: [], filteredData: [], showNoRelatedTests: false, filterText: '', innerTimeRange: this.props.timeRange, }, this.props.toggle, ); }; submitData = () => { const { selectedTests, innerTimeRange } = this.state; const { getTestData, timeRange: parentTimeRange, updateTestsAndTimeRange, replicates, } = this.props; const displayedTestParams = selectedTests.map((series) => ({ repository_name: series.projectName, signature_id: parseInt(series.id, 10), framework_id: parseInt(series.frameworkId, 10), replicates, })); this.setState({ selectedTests: [], selectedUnits: new Set(), filterText: '', }); if (innerTimeRange.value !== parentTimeRange.value) { updateTestsAndTimeRange(displayedTestParams, innerTimeRange); } else { getTestData(displayedTestParams); } this.closeModal(); }; selectableTestClassName = (test) => { return this.hasDifferentUnit(test) ? 'bg-warning' : ''; }; selectableTestTitle = (test) => { if (this.hasDifferentUnit(test)) { return `Warning: ${this.getOriginalTestName( test, )} has a different measurement unit (${test.measurementUnit}) `; } return this.getOriginalTestName(test); }; hasDifferentUnit = (test) => { const { plottedUnits } = this.props; const { selectedUnits } = this.state; const unit = test.measurementUnit; const differentThanPlottedUnits = plottedUnits.size && !plottedUnits.has(unit); const selectedUnitTypesMismatch = selectedUnits.size >= 2; const differentThanSelectedUnits = selectedUnits.size && !selectedUnits.has(unit); return ( differentThanPlottedUnits || selectedUnitTypesMismatch || differentThanSelectedUnits ); }; removeSubstring = (subString, fromString) => fromString.includes(subString) ? fromString.split(subString).join('').trim() : fromString; extractUniqueUnits(tests) { return new Set(tests.map((aTest) => aTest.measurementUnit)); } render() { const { activeTags, availableTags, filterText, filteredData, framework, includeSubtests, innerTimeRange, loading, pinnedProjects, platform, platforms, relatedTests, repository_name: repositoryName, selectedTests, seriesData, showNoRelatedTests, } = this.state; const { frameworks, projects, showModal } = this.props; const projectOptions = this.getDropdownOptions(projects); const modalOptions = [ { options: frameworks.length ? frameworks.map((item) => item.name) : [], selectedItem: framework.name, updateData: (value) => this.setState( { framework: this.findObject(frameworks, 'name', value), }, this.getPlatforms, ), title: 'Framework', }, { options: projectOptions .sort() .filter((item) => !pinnedProjects.includes(item)), selectedItem: repositoryName.name || '', pinnedProjects: pinnedProjects.filter((item) => projectOptions.includes(item), ), updateData: (value) => this.setState( { repository_name: this.findObject(projects, 'name', value) }, this.getPlatforms, ), title: 'Project', }, { options: platforms.sort(), selectedItem: platform, updateData: (platform) => this.setState({ platform }, this.processOptions), title: 'Platform', }, ]; let tests = []; if (filterText || activeTags.length) { tests = filteredData; } else if (relatedTests.length || showNoRelatedTests) { tests = relatedTests; } else if (seriesData.length && !loading) { tests = seriesData; } return ( <Modal size="lg" isOpen={showModal}> <ModalHeader toggle={this.closeModal}>Add Test Data</ModalHeader> <ModalBody className="container-fluid test-chooser"> <Form> <Row className="justify-content-start"> {createDropdowns(modalOptions, 'p-2', true)} {innerTimeRange && ( <Col sm="auto" className="p-2"> <TimeRangeDropdown timeRangeText={innerTimeRange.text} updateTimeRange={(newTimeRange) => this.setState( { innerTimeRange: newTimeRange }, this.getPlatforms, ) } /> </Col> )} <Col sm="auto" className="p-2"> <Button color="darker-info" outline onClick={() => this.setState( { includeSubtests: !includeSubtests }, this.processOptions, ) } active={includeSubtests} > Include subtests </Button> </Col> </Row> <Row className="justify-content-start"> <Col className="p-2 col-4"> <InputFilter disabled={relatedTests.length > 0} placeholder="filter tests e.g. linux tp5o" updateFilterText={this.applyFilters} /> </Col> </Row> {availableTags.length > 1 && ( <> <Row className="justify-content-start"> <Col className="p-2"> <FormGroup> <Label for="selectMultiTags">Tags</Label> <Input className="fa" type="select" name="selectMultiTags" id="selectMultiTags" multiple > {availableTags.sort().map((tag) => ( <option key={`available-tag-${tag}`} data-testid={`available-tag ${tag}`} onClick={() => this.toggleTag(tag)} > {tag} </option> ))} </Input> </FormGroup> </Col> </Row> <Row className="pb-2 justify-content-start"> <Col className="p-2" sm="auto"> {activeTags.sort().map((tag, index) => ( <React.Fragment key={`active-tag-${tag}`}> <Badge id={`active-tag-${index}`} data-testid={`active-tag ${tag}`} className="mr-2 btn btn-darker-secondary" role="button" title="Click to remove tag" pill onClick={() => this.toggleTag(tag)} > {tag} × </Badge> </React.Fragment> ))} </Col> </Row> </> )} <Row className="p-2 justify-content-start"> <Col className="p-0"> <Label for="exampleSelect"> {relatedTests.length > 0 ? 'Related tests' : 'Tests'} </Label> <Input className="fa" data-testid="tests" type="select" name="selectMulti" id="selectTests" multiple > {tests.length > 0 && tests.sort().map((test) => ( <option key={test.id} className={this.selectableTestClassName(test)} data-testid={test.id.toString()} onClick={() => this.updateSelectedTests(test)} title={this.selectableTestTitle(test)} > {this.getOriginalTestName(test)} </option> ))} </Input> {showNoRelatedTests && ( <p className="text-info pt-2">No related tests found.</p> )} </Col> </Row> <Row className="p-2 justify-content-start"> <Col className="p-0"> <Label for="exampleSelect"> Selected tests{' '} <span className="small">(click a test to remove it)</span> </Label> <Input data-testid="selectedTests" type="select" name="selectMulti" id="selectTests" multiple > {selectedTests.length > 0 && selectedTests.map((test) => ( <option key={test.id} className={this.selectableTestClassName(test)} onClick={() => this.updateSelectedTests(test, true)} title={this.selectableTestTitle(test)} > {this.getFullTestName(test)} </option> ))} </Input> {selectedTests.length > 6 && ( <p className="text-info pt-2"> Displaying more than 6 graphs at a time is not supported in the UI. </p> )} </Col> </Row> <Row className="p-2"> <Col className="py-2 px-0 text-right"> <Button color="darker-info" disabled={!selectedTests.length} onClick={this.submitData} onKeyPress={(event) => event.preventDefault()} > Plot graphs </Button> </Col> </Row> </Form> </ModalBody> </Modal> ); } } TestDataModal.propTypes = { getTestData: PropTypes.func.isRequired, plottedUnits: PropTypes.instanceOf(Set).isRequired, projects: PropTypes.arrayOf(PropTypes.shape({})).isRequired, showModal: PropTypes.bool.isRequired, timeRange: PropTypes.shape({}).isRequired, toggle: PropTypes.func.isRequired, updateTestsAndTimeRange: PropTypes.func.isRequired, frameworks: PropTypes.arrayOf(PropTypes.shape({})), getInitialData: PropTypes.func, getSeriesData: PropTypes.func, options: PropTypes.shape({ option: PropTypes.string, relatedSeries: PropTypes.shape({}), }), testData: PropTypes.arrayOf(PropTypes.shape({})), }; TestDataModal.defaultProps = { frameworks: [], getInitialData, getSeriesData, options: undefined, testData: [], };