diff --git a/.gitignore b/.gitignore index a32ff609..6f89c4f2 100644 --- a/.gitignore +++ b/.gitignore @@ -60,9 +60,12 @@ docs/_build/ # PyBuilder target/ -#Ipython Notebook +# Ipython Notebook .ipynb_checkpoints +# Editors +.vscode + # NodeJS stuff node_modules package-lock.json @@ -70,6 +73,7 @@ package-lock.json # Virtual environment .ibutsu-env .ibutsu_env +.ibutsu # Frontend frontend/public/version.json diff --git a/frontend/src/components/classify-failures.js b/frontend/src/components/classify-failures.js index c4d0bfa1..67355cdc 100644 --- a/frontend/src/components/classify-failures.js +++ b/frontend/src/components/classify-failures.js @@ -9,9 +9,6 @@ import { Checkbox, Flex, FlexItem, - Select, - SelectOption, - SelectVariant, TextContent, Text, } from '@patternfly/react-core'; @@ -24,13 +21,15 @@ import { HttpClient } from '../services/http'; import { Settings } from '../settings'; import { buildParams, + toAPIFilter, getSpinnerRow, resultToClassificationRow, } from '../utilities'; -import { OPERATIONS } from '../constants'; +import { FILTERABLE_RESULT_FIELDS } from '../constants'; import { FilterTable, MultiClassificationDropdown, + MetaFilter, } from './index'; @@ -38,7 +37,7 @@ export class ClassifyFailuresTable extends React.Component { static propTypes = { filters: PropTypes.object, run_id: PropTypes.string - }; + } constructor(props) { super(props); @@ -56,9 +55,6 @@ export class ClassifyFailuresTable extends React.Component { isError: false, isFieldOpen: false, isOperationOpen: false, - exceptionSelections: [], - isExceptionOpen: false, - exceptions: [], includeSkipped: false, filters: Object.assign({ 'result': {op: 'in', val: 'failed;error'}, @@ -73,39 +69,6 @@ export class ClassifyFailuresTable extends React.Component { this.getResultsForTable(); } - onExceptionToggle = isExpanded => { - this.setState({isExceptionOpen: isExpanded}, this.applyFilter); - } - - applyExceptionFilter = () => { - let { filters, exceptionSelections } = this.state; - if (exceptionSelections.length > 0) { - filters["metadata.exception_name"] = Object.assign({op: 'in', val: exceptionSelections.join(';')}); - this.setState({filters}, this.refreshResults); - } - else { - delete filters["metadata.exception_name"]; - this.setState({filters}, this.refreshResults); - } - } - - onExceptionSelect = (event, selection) => { - const exceptionSelections = this.state.exceptionSelections; - if (exceptionSelections.includes(selection)) { - this.setState({exceptionSelections: exceptionSelections.filter(item => item !== selection)}, this.applyExceptionFilter); - } - else { - this.setState({exceptionSelections: [...exceptionSelections, selection]}, this.applyExceptionFilter); - } - }; - - onExceptionClear = () => { - this.setState({ - exceptionSelections: [], - isExceptionOpen: false - }, this.applyExceptionFilter); - }; - onCollapse(event, rowIndex, isOpen) { const { rows } = this.state; rows[rowIndex].isOpen = isOpen; @@ -146,7 +109,7 @@ export class ClassifyFailuresTable extends React.Component { updateFilters(name, operator, value, callback) { let filters = this.state.filters; - if (!value) { + if ((value === null) || (value.length === 0)) { delete filters[name]; } else { @@ -156,27 +119,20 @@ export class ClassifyFailuresTable extends React.Component { } setFilter = (field, value) => { - this.updateFilters(field, 'eq', value, () => { - this.refreshResults(); - }) - }; + // 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 === "metadata.exception_name") { // only remove exception_name filter - this.updateFilters(id, null, null, () => { - this.setState({exceptionSelections: [], page: 1}, this.applyExceptionFilter); - }); + if ((id !== "result") && (id !== "run_id")) { // Don't allow removal of error/failure filter + this.updateFilters(id, null, null, this.refreshResults) } } onSkipCheck = (checked) => { let { filters } = this.state; - if (checked) { - filters["result"]["val"] += ";skipped;xfailed" - } - else { - filters["result"]["val"] = "failed;error" - } + filters["result"]["val"] = ("failed;error") + ((checked) ? ";skipped;xfailed" : "") this.setState( {includeSkipped: checked, filters}, this.refreshResults @@ -199,17 +155,9 @@ export class ClassifyFailuresTable extends React.Component { this.setState({rows: [getSpinnerRow(5)], isEmpty: false, isError: false}); // get only failed results let params = buildParams(filters); - params['filter'] = []; + params['filter'] = toAPIFilter(filters); params['pageSize'] = this.state.pageSize; params['page'] = this.state.page; - // Convert UI filters to API filters - for (let key in filters) { - if (Object.prototype.hasOwnProperty.call(filters, key) && !!filters[key]) { - let val = filters[key]['val']; - const op = OPERATIONS[filters[key]['op']]; - params.filter.push(key + op + val); - } - } this.setState({rows: [['Loading...', '', '', '', '']]}); HttpClient.get([Settings.serverUrl, 'result'], params) @@ -229,17 +177,8 @@ export class ClassifyFailuresTable extends React.Component { }); } - getExceptions() { - HttpClient.get([Settings.serverUrl, 'widget', 'result-aggregator'], {group_field: 'metadata.exception_name', run_id: this.props.run_id}) - .then(response => response.json()) - .then(data => { - this.setState({exceptions: data}) - }) - } - componentDidMount() { this.getResultsForTable(); - this.getExceptions(); } render() { @@ -248,35 +187,24 @@ export class ClassifyFailuresTable extends React.Component { rows, selectedResults, includeSkipped, - isExceptionOpen, - exceptionSelections, - exceptions, + filters } = this.state; + const { run_id } = this.props const pagination = { pageSize: this.state.pageSize, page: this.state.page, totalItems: this.state.totalItems } - // filters for the exception - const exceptionFilters = [ - - - + // filters for the metadata + const resultFilters = [ + , ] return ( @@ -314,7 +242,7 @@ export class ClassifyFailuresTable extends React.Component { onRowSelect={this.onTableRowSelect} variant={TableVariant.compact} activeFilters={this.state.filters} - filters={exceptionFilters} + filters={resultFilters} onRemoveFilter={this.removeFilter} hideFilters={["run_id", "project_id"]} /> diff --git a/frontend/src/components/filtertable.js b/frontend/src/components/filtertable.js index 9877e020..3157aac6 100644 --- a/frontend/src/components/filtertable.js +++ b/frontend/src/components/filtertable.js @@ -9,7 +9,10 @@ import { Flex, FlexItem, Pagination, - PaginationVariant + PaginationVariant, + Select, + SelectOption, + SelectVariant, } from '@patternfly/react-core'; import { Table, @@ -17,6 +20,10 @@ import { TableHeader } from '@patternfly/react-table'; +import { Settings } from '../settings'; +import { HttpClient } from '../services/http'; +import { toAPIFilter } from '../utilities'; + import { TableEmptyState, TableErrorState } from './tablestates'; export class FilterTable extends React.Component { @@ -160,3 +167,138 @@ export class FilterTable extends React.Component { ); } } + + +export class MetaFilter extends React.Component { + // TODO Extend this to contain the filter handling functions, and better integrate filter state with FilterTable + // https://github.com/ibutsu/ibutsu-server/issues/230 + static propTypes = { + fieldOptions: PropTypes.array, // could reference constants directly + runId: PropTypes.string, // make optional? + setFilter: PropTypes.func, + customFilters: PropTypes.array, // more advanced handling of filter objects? the results-aggregator endpoint takes a string filter + }; + + constructor(props) { + super(props); + this.state = { + fieldSelection: null, + isFieldOpen: false, + isValueOpen: false, + valueOptions: [], + valueSelections: [], + }; + } + + onFieldToggle = isExpanded => { + this.setState({isFieldOpen: isExpanded}) + }; + + onValueToggle = isExpanded => { + this.setState({isValueOpen: isExpanded}) + }; + + onFieldSelect = (event, selection) => { + this.setState( + // clear value state too, otherwise the old selection remains selected but is no longer visible + {fieldSelection: selection, isFieldOpen: false, valueSelections: [], valueOptions: [], isValueOpen: false}, + this.updateValueOptions + ) + + }; + + onValueSelect = (event, selection) => { + // update state and call setFilter + const valueSelections = this.state.valueSelections; + let updated_values = (valueSelections.includes(selection)) + ? valueSelections.filter(item => item !== selection) + : [...valueSelections, selection] + + this.setState( + {valueSelections: updated_values}, + () => this.props.setFilter(this.state.fieldSelection, this.state.valueSelections.join(';')) + ) + }; + + onFieldClear = () => { + this.setState( + {fieldSelection: null, valueSelections: [], isFieldOpen: false, isValueOpen: false}, + ) + }; + + onValueClear = () => { + this.setState( + {valueSelections: [], isValueOpen: false}, + () => this.props.setFilter(this.state.fieldSelection, this.state.valueSelections) + ) + } + + updateValueOptions = () => { + const {fieldSelection} = this.state + const {customFilters} = this.props + + console.log('CUSTOMFILTER: '+customFilters) + if (fieldSelection !== null) { + let api_filter = toAPIFilter(customFilters).join() + console.log('APIFILTER: '+customFilters) + + HttpClient.get( + [Settings.serverUrl, 'widget', 'result-aggregator'], + { + group_field: fieldSelection, + run_id: this.props.runId, + additional_filters: api_filter, + } + ) + .then(response => HttpClient.handleResponse(response)) + .then(data => { + this.setState({valueOptions: data}) + }) + } + } + + render () { + const {isFieldOpen, fieldSelection, isValueOpen, valueOptions, valueSelections} = this.state; + let field_selected = this.state.fieldSelection !== null; + let values_available = valueOptions.length > 0; + let value_placeholder = "Select a field first" ; // default instead of an else block + if (field_selected && values_available){ value_placeholder = "Select value(s)";} + else if (field_selected && !values_available) { value_placeholder = "No values for selected field";} + return ( + + + + + + ) + } +} diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js index 33eb5223..849535fe 100644 --- a/frontend/src/components/index.js +++ b/frontend/src/components/index.js @@ -4,7 +4,7 @@ export { AddTokenModal } from './add-token-modal'; export { DeleteModal } from './delete-modal'; export { EmptyObject } from './empty-object'; export { FileUpload } from './fileupload'; -export { FilterTable } from './filtertable'; +export { FilterTable, MetaFilter } from './filtertable'; export { ParamDropdown } from './widget-components'; export { MultiValueInput } from './multivalueinput'; export { NewDashboardModal } from './new-dashboard-modal'; diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 5606b8df..204145a9 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -60,6 +60,26 @@ export const STRING_RESULT_FIELDS = [ 'start_time', // TODO: handle this with a calendar widget? 'test_id' ]; +export const FILTERABLE_RESULT_FIELDS = [ + 'env', + 'component', + 'metadata.assignee', + // TODO support array filtering + // https://github.com/ibutsu/ibutsu-server/issues/228 + //'metadata.statuses.call', + //'metadata.statuses.setup', + //'metadata.statuses.teardown', + 'metadata.caseautomation', + 'metadata.exception_name', + 'metadata.fspath', + 'metadata.importance', + 'metadata.title', + // TODO support object/dict filtering + // https://github.com/ibutsu/ibutsu-server/issues/229 + //'metadata.params', + //'metadata.markers', + +] export const RESULT_FIELDS = [...NUMERIC_RESULT_FIELDS, ...STRING_RESULT_FIELDS, ...ARRAY_RESULT_FIELDS]; export const ARRAY_RUN_FIELDS = [ 'metadata.tags', diff --git a/frontend/src/utilities.js b/frontend/src/utilities.js index 56a3719e..d577535d 100644 --- a/frontend/src/utilities.js +++ b/frontend/src/utilities.js @@ -101,6 +101,37 @@ export function buildParams(filters) { return getParams; } +export function buildUrl(url, params) { + // shorthand + const esc = encodeURIComponent; + let query = []; + for (const key of Object.keys(params)) { + const value = params[key]; + if (value instanceof Array) { + value.forEach(element => { + query.push(esc(key) + '=' + esc(element)); + }); + } + else { + query.push(esc(key) + '=' + esc(value)); + } + } + return url + '?' + query.join('&'); +} + +export function toAPIFilter(filters) { + // Take UI style filter object with field/op/val keys and generate an array of filter strings for the API + let filter_strings = [] + for (const key in filters) { + if (Object.prototype.hasOwnProperty.call(filters, key) && !!filters[key]) { + const val = filters[key]['val']; + const op = OPERATIONS[filters[key]['op']]; + filter_strings.push(key + op + val); + } + } + return filter_strings +} + export function round(number) { let rounded = Math.round(number * 10); return rounded / 10;