From a2664b8cf4dc42137f9a9dbd36e82bfdd706b8a5 Mon Sep 17 00:00:00 2001 From: Mike Shriver Date: Tue, 7 Dec 2021 14:08:17 -0500 Subject: [PATCH] Implement MetaFilter with separate field and value (#225) Add a metadata filtering class for use on filtertable result pages. Adding to classify failures first, where its needed most right now. This could be applied to run and results views as well, replacing their current filtering selections. Add some lines to .gitignore for vscode and venv --- .gitignore | 6 +- frontend/src/components/classify-failures.js | 124 ++++------------ frontend/src/components/filtertable.js | 144 ++++++++++++++++++- frontend/src/components/index.js | 2 +- frontend/src/constants.js | 20 +++ frontend/src/utilities.js | 31 ++++ 6 files changed, 226 insertions(+), 101 deletions(-) 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;