From f8ed19ec15b38778119e4177f330720a1414efac Mon Sep 17 00:00:00 2001 From: john-dupuy Date: Wed, 12 Jan 2022 14:09:40 -0800 Subject: [PATCH] Add test history tab to Result page --- frontend/src/components/result.js | 25 ++- frontend/src/components/test-history.js | 245 ++++++++++++++++++++++++ frontend/src/utilities.js | 41 +++- 3 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/test-history.js diff --git a/frontend/src/components/result.js b/frontend/src/components/result.js index 5a158998..5ca89235 100644 --- a/frontend/src/components/result.js +++ b/frontend/src/components/result.js @@ -17,7 +17,7 @@ import { Tabs, Tab } from '@patternfly/react-core'; -import { FileAltIcon, FileImageIcon, InfoCircleIcon, CodeIcon } from '@patternfly/react-icons'; +import { FileAltIcon, FileImageIcon, InfoCircleIcon, CodeIcon, SearchIcon } from '@patternfly/react-icons'; import { Link } from 'react-router-dom'; import Linkify from 'react-linkify'; import ReactJson from 'react-json-view'; @@ -29,6 +29,7 @@ import { linkifyDecorator } from './decorators' import { Settings } from '../settings'; import { getIconForResult, round } from '../utilities'; import { TabTitle } from './tabs'; +import { TestHistoryTable } from './test-history'; const MockTest = { id: null, @@ -60,6 +61,7 @@ export class ResultView extends React.Component { resultId: PropTypes.string, hideSummary: PropTypes.bool, hideTestObject: PropTypes.bool, + hideTestHistory: PropTypes.bool, history: PropTypes.object, location: PropTypes.object } @@ -71,7 +73,8 @@ export class ResultView extends React.Component { id: this.props.resultId || null, artifacts: [], activeTab: this.getTabIndex(this.getDefaultTab()), - artifactTabs: [] + artifactTabs: [], + testHistoryTable: null, }; if (this.props.history) { // Watch the history to update tabs @@ -103,6 +106,12 @@ export class ResultView extends React.Component { } } + updateTab(tabIndex) { + if (tabIndex === 'test-history') { + this.getTestHistoryTable(); + } + } + onTabSelect = (event, tabIndex) => { if (this.props.history) { const loc = this.props.history.location; @@ -113,8 +122,13 @@ export class ResultView extends React.Component { }); } this.setState({activeTab: tabIndex}); + this.updateTab(tabIndex); }; + getTestHistoryTable = () => { + this.setState({testHistoryTable: }); + } + getTestResult(resultId) { HttpClient.get([Settings.serverUrl, 'result', resultId]) .then(response => HttpClient.handleResponse(response)) @@ -200,7 +214,7 @@ export class ResultView extends React.Component { } render() { - let { testResult, artifactTabs, activeTab } = this.state; + let { testResult, artifactTabs, activeTab, testHistoryTable } = this.state; if (activeTab === null) { activeTab = this.getDefaultTab(); } @@ -487,6 +501,11 @@ export class ResultView extends React.Component { } {artifactTabs} + {!this.props.hideTestHistory && + } style={{backgroundColor: "white"}}> + {testHistoryTable} + + } {!this.props.hideTestObject && } style={{backgroundColor: "white"}}> diff --git a/frontend/src/components/test-history.js b/frontend/src/components/test-history.js new file mode 100644 index 00000000..4ca994bc --- /dev/null +++ b/frontend/src/components/test-history.js @@ -0,0 +1,245 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + Button, + Card, + CardHeader, + CardBody, + Checkbox, + Dropdown, + DropdownItem, + DropdownToggle, + Flex, + FlexItem, + TextContent, + Text, + Tooltip +} from '@patternfly/react-core'; +import { + TableVariant, + expandable +} from '@patternfly/react-table'; + +import { HttpClient } from '../services/http'; +import { Settings } from '../settings'; +import { + buildParams, + toAPIFilter, + getSpinnerRow, + resultToTestHistoryRow, +} from '../utilities'; +import { + FilterTable +} from './index'; + + +export class TestHistoryTable extends React.Component { + static propTypes = { + filters: PropTypes.object, + testResult: PropTypes.object, + } + + constructor(props) { + super(props); + this.state = { + columns: [{title: 'Result', cellFormatters: [expandable]}, 'Source', 'Exception Name', 'Duration', 'Start Time'], + rows: [getSpinnerRow(5)], + results: [], + cursor: null, + pageSize: 10, + page: 1, + totalItems: 0, + totalPages: 0, + isEmpty: false, + isError: false, + isFieldOpen: false, + isOperationOpen: false, + isDropdownOpen: false, + onlyFailures: false, + dropdownSelection: '2 Weeks', + filters: Object.assign({ + 'result': {op: 'in', val: "passed;skipped;failed;error;xpassed;xfailed"}, + 'test_id': {op: 'eq', val: props.testResult.test_id}, + 'env': {op: 'eq', val: props.testResult.env}, + // default to filter only from 2 weeks ago to the most test's start_time. + 'start_time': {op: 'gt', val: new Date(new Date(props.testResult.start_time).getTime() - (0.5 * 30 * 86400 * 1000)).toISOString()} + }, props.filters), + }; + this.refreshResults = this.refreshResults.bind(this); + this.onCollapse = this.onCollapse.bind(this); + } + + refreshResults = () => { + this.getResultsForTable(); + } + + onCollapse(event, rowIndex, isOpen) { + const { rows } = this.state; + rows[rowIndex].isOpen = isOpen; + this.setState({ + rows + }); + } + + setPage = (_event, pageNumber) => { + this.setState({page: pageNumber}, () => { + this.getResultsForTable(); + }); + } + + pageSizeSelect = (_event, perPage) => { + this.setState({pageSize: perPage}, () => { + this.getResultsForTable(); + }); + } + + updateFilters(name, operator, value, callback) { + let filters = this.state.filters; + if ((value === null) || (value.length === 0)) { + delete filters[name]; + } + else { + filters[name] = {'op': operator, 'val': value}; + } + this.setState({filters: filters, page: 1}, callback); + } + + setFilter = (field, value) => { + // maybe process values array to string format here instead of expecting caller to do it? + let operator = (value.includes(";")) ? 'in' : 'eq' + this.updateFilters(field, operator, value, this.refreshResults) + } + + removeFilter = id => { + if ((id !== "result") && (id !== "test_id")) { // Don't allow removal of error/failure filter + this.updateFilters(id, null, null, this.refreshResults) + } + } + + onFailuresCheck = (checked) => { + let { filters } = this.state; + filters["result"]["val"] = ("failed;error") + ((checked) ? ";skipped;xfailed" : "skipped;xfailed;xpassed;passed") + this.setState( + {onlyFailures: checked, filters}, + this.refreshResults + ); + } + + onDropdownToggle = isOpen => { + this.setState({isDropdownOpen: isOpen}); + } + + onDropdownSelect = event => { + let { filters } = this.state; + let { testResult } = this.props; + let startTime = new Date(testResult.start_time); + let months = event.target.getAttribute('value'); + let selection = event.target.text + // here a month is considered to be 30 days, and there are 86400*1000 ms in a day + let timeRange = new Date(startTime.getTime() - (months * 30 * 86400 * 1000)); + // set the filters + filters["start_time"] = {op: "gt", val: timeRange.toISOString()} + this.setState({filters, isDropdownOpen: false, dropdownSelection: selection}, this.refreshResults); + } + + getResultsForTable() { + const filters = this.state.filters; + this.setState({rows: [getSpinnerRow(4)], isEmpty: false, isError: false}); + // get only failed results + let params = buildParams(filters); + params['filter'] = toAPIFilter(filters); + params['pageSize'] = this.state.pageSize; + params['page'] = this.state.page; + + this.setState({rows: [['Loading...', '', '', '', '']]}); + HttpClient.get([Settings.serverUrl, 'result'], params) + .then(response => HttpClient.handleResponse(response)) + .then(data => this.setState({ + results: data.results, + rows: data.results.map((result, index) => resultToTestHistoryRow(result, index, this.setFilter)).flat(), + page: data.pagination.page, + pageSize: data.pagination.pageSize, + totalItems: data.pagination.totalItems, + totalPages: data.pagination.totalPages, + isEmpty: data.pagination.totalItems === 0, + })) + .catch((error) => { + console.error('Error fetching result data:', error); + this.setState({rows: [], isEmpty: false, isError: true}); + }); + } + + componentDidMount() { + this.getResultsForTable(); + } + + render() { + const { + columns, + rows, + onlyFailures + } = this.state; + const pagination = { + pageSize: this.state.pageSize, + page: this.state.page, + totalItems: this.state.totalItems + } + + return ( + + + + + + Test History + + + + + + + + + + {this.state.dropdownSelection}} + onSelect={this.onDropdownSelect} + isOpen={this.state.isDropdownOpen} + dropdownItems={[ + {'2 Weeks'}, + {'1 Month'}, + {'2 Months'}, + {'3 Months'}, + {'5 Months'}, + ]} + /> + + + + + + + + + + + + ); + } + +} diff --git a/frontend/src/utilities.js b/frontend/src/utilities.js index 2371bc00..dd5ae246 100644 --- a/frontend/src/utilities.js +++ b/frontend/src/utilities.js @@ -246,7 +246,46 @@ export function resultToClassificationRow(result, index, filterFunc) { // child row { "parent": 2*index, - "cells": [{title: }] + "cells": [{title: }] + } + ]; +} + +export function resultToTestHistoryRow(result, index, filterFunc) { + let resultIcon = getIconForResult(result.result); + let hideSummary = true; + let hideTestObject = true; + let exceptionBadge; + + if (filterFunc) { + exceptionBadge = buildBadge('exception_name', result.metadata.exception_name, false, + () => filterFunc('metadata.exception_name', result.metadata.exception_name)); + } + else { + exceptionBadge = buildBadge('exception_name', result.metadata.exception_name, false); + } + + if (["passed", "skipped"].includes(result.result)) { + hideSummary=false; + hideTestObject=false; + } + + return [ + // parent row + { + "isOpen": false, + "cells": [ + {title: {resultIcon} {toTitleCase(result.result)}}, + {title: {result.source}}, + {title: {exceptionBadge}}, + {title: round(result.duration) + 's'}, + {title: (new Date(result.start_time).toLocaleString())}, + ], + }, + // child row + { + "parent": 2*index, + "cells": [{title: }] } ]; }