From 5626e15d35181dfa8e7199cdc40ea774e62ec849 Mon Sep 17 00:00:00 2001 From: jasquat <2487833+jasquat@users.noreply.github.com> Date: Wed, 2 Aug 2023 04:31:38 -0400 Subject: [PATCH] Feature/metadata filtering (#418) * added ability to filter metadata by is and contains w/ burnettk * added the empty options for metadata filtering * remove the filter when removing the corresponding column on the frontend --------- Co-authored-by: jasquat Co-authored-by: burnettk --- .../process_instance_report_service.py | 17 +- .../components/ProcessInstanceListTable.tsx | 148 ++++++++++++++---- spiffworkflow-frontend/src/helpers.tsx | 13 ++ spiffworkflow-frontend/src/interfaces.ts | 9 ++ 4 files changed, 152 insertions(+), 35 deletions(-) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py index b89fb60e..b963c923 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py @@ -549,7 +549,22 @@ class ProcessInstanceReportService: ] if filter_for_column: isouter = False - conditions.append(instance_metadata_alias.value == filter_for_column["field_value"]) + if "operator" not in filter_for_column or filter_for_column["operator"] == "equals": + conditions.append(instance_metadata_alias.value == filter_for_column["field_value"]) + elif filter_for_column["operator"] == "not equals": + conditions.append(instance_metadata_alias.value != filter_for_column["field_value"]) + elif filter_for_column["operator"] == "contains": + conditions.append(instance_metadata_alias.value.like(f"%{filter_for_column['field_value']}%")) + elif filter_for_column["operator"] == "is_empty": + # we still need to return results if the metadata value is null so make sure it's outer join + isouter = True + conditions.append( + or_(instance_metadata_alias.value.is_(None), instance_metadata_alias.value == "") + ) + elif filter_for_column["operator"] == "is_not_empty": + conditions.append( + or_(instance_metadata_alias.value.is_not(None), instance_metadata_alias.value != "") + ) process_instance_query = process_instance_query.join( instance_metadata_alias, and_(*conditions), isouter=isouter ).add_columns(func.max(instance_metadata_alias.value).label(column["accessor"])) diff --git a/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx b/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx index 2581e7a4..8c5f4835 100644 --- a/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx +++ b/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx @@ -41,6 +41,7 @@ import { convertSecondsToFormattedDateString, convertSecondsToFormattedDateTime, convertSecondsToFormattedTimeHoursMinutes, + getKeyByValue, getPageInfoFromSearchParams, modifyProcessIdentifierForPathParam, refreshAtInterval, @@ -67,6 +68,7 @@ import { User, ErrorForDisplay, PermissionsToCheck, + FilterOperatorMapping, } from '../interfaces'; import ProcessModelSearch from './ProcessModelSearch'; import ProcessInstanceReportSearch from './ProcessInstanceReportSearch'; @@ -164,6 +166,14 @@ export default function ProcessInstanceListTable({ const preferredUsername = UserService.getPreferredUsername(); const userEmail = UserService.getUserEmail(); + const filterOperatorMappings: FilterOperatorMapping = { + Is: { id: 'equals', requires_value: true }, + 'Is Not': { id: 'not equals', requires_value: true }, + Contains: { id: 'contains', requires_value: true }, + 'Is Empty': { id: 'is_empty', requires_value: false }, + 'Is Not Empty': { id: 'is_not_empty', requires_value: false }, + }; + const processInstanceListPathPrefix = variant === 'all' ? '/admin/process-instances/all' @@ -361,6 +371,27 @@ export default function ProcessInstanceListTable({ setProcessInstanceReportSelection(processInstanceReport); } } + if (additionalReportFilters) { + additionalReportFilters.forEach((arf: ReportFilter) => { + if (!reportMetadataBodyToUse.filter_by.includes(arf)) { + reportMetadataBodyToUse.filter_by.push(arf); + } + }); + } + + // If the showActionColumn is set to true, we need to include the with_oldest_open_task in the query params + if ( + showActionsColumn && + !reportMetadataBodyToUse.filter_by.some( + (rf: ReportFilter) => rf.field_name === 'with_oldest_open_task' + ) + ) { + const withOldestReportFilter = { + field_name: 'with_oldest_open_task', + field_value: true, + }; + reportMetadataBodyToUse.filter_by.push(withOldestReportFilter); + } // a bit hacky, clear out all filters before setting them from report metadata // to ensure old filters are cleared out. @@ -417,6 +448,13 @@ export default function ProcessInstanceListTable({ setShowFilterOptions(true); } + if (filtersEnabled) { + HttpService.makeCallToBackend({ + path: `/user-groups/for-current-user`, + successCallback: setUserGroups, + }); + } + // eslint-disable-next-line prefer-const let { page, perPage } = getPageInfoFromSearchParams( searchParams, @@ -428,30 +466,8 @@ export default function ProcessInstanceListTable({ // eslint-disable-next-line prefer-destructuring perPage = perPageOptions[1]; } + const queryParamString = `per_page=${perPage}&page=${page}`; - if (additionalReportFilters) { - additionalReportFilters.forEach((arf: ReportFilter) => { - if (!reportMetadataBodyToUse.filter_by.includes(arf)) { - reportMetadataBodyToUse.filter_by.push(arf); - } - }); - } - - // If the showActionColumn is set to true, we need to include the with_oldest_open_task in the query params - if (showActionsColumn) { - reportMetadataBodyToUse.filter_by.push({ - field_name: 'with_oldest_open_task', - field_value: true, - }); - } - - if (filtersEnabled) { - HttpService.makeCallToBackend({ - path: `/user-groups/for-current-user`, - successCallback: setUserGroups, - }); - } - HttpService.makeCallToBackend({ path: `${processInstanceApiSearchPath}?${queryParamString}`, successCallback: setProcessInstancesFromResult, @@ -952,6 +968,13 @@ export default function ProcessInstanceListTable({ (rc: ReportColumn) => rc.accessor !== reportColumn.accessor ); Object.assign(reportMetadataCopy, { columns: newColumns }); + const newFilters = reportMetadataCopy.filter_by.filter( + (rf: ReportFilter) => rf.field_name !== reportColumn.accessor + ); + Object.assign(reportMetadataCopy, { + columns: newColumns, + filter_by: newFilters, + }); setReportMetadata(reportMetadataCopy); setRequiresRefilter(true); } @@ -963,6 +986,18 @@ export default function ProcessInstanceListTable({ setReportColumnToOperateOn(null); }; + const getFilterOperatorFromReportColumn = ( + reportColumnForEditing: ReportColumnForEditing + ) => { + if (reportColumnForEditing.filter_operator) { + // eslint-disable-next-line prefer-destructuring + return Object.entries(filterOperatorMappings).filter(([_key, value]) => { + return value.id === reportColumnForEditing.filter_operator; + })[0][1]; + } + return null; + }; + const getNewFiltersFromReportForEditing = ( reportColumnForEditing: ReportColumnForEditing ) => { @@ -980,14 +1015,23 @@ export default function ProcessInstanceListTable({ const existingReportFilter = getFilterByFromReportMetadata( reportColumnForEditing.accessor ); + const filterOperator = getFilterOperatorFromReportColumn( + reportColumnForEditing + ); if (existingReportFilter) { const existingReportFilterIndex = reportMetadataCopy.filter_by.indexOf(existingReportFilter); - if (reportColumnForEditing.filter_field_value) { + if (filterOperator && !filterOperator.requires_value) { + newReportFilter.field_value = ''; + newReportFilters[existingReportFilterIndex] = newReportFilter; + } else if (reportColumnForEditing.filter_field_value) { newReportFilters[existingReportFilterIndex] = newReportFilter; } else { newReportFilters.splice(existingReportFilterIndex, 1); } + } else if (filterOperator && !filterOperator.requires_value) { + newReportFilter.field_value = ''; + newReportFilters = newReportFilters.concat([newReportFilter]); } else if (reportColumnForEditing.filter_field_value) { newReportFilters = newReportFilters.concat([newReportFilter]); } @@ -1073,6 +1117,19 @@ export default function ProcessInstanceListTable({ } }; + const setReportColumnConditionOperator = (selectedItem: string) => { + if (reportColumnToOperateOn) { + const reportColumnToOperateOnCopy = { + ...reportColumnToOperateOn, + }; + const filterOperator = filterOperatorMappings[selectedItem]; + reportColumnToOperateOnCopy.filter_operator = filterOperator.id; + setReportColumnToOperateOn(reportColumnToOperateOnCopy); + setRequiresRefilter(true); + } + }; + + // eslint-disable-next-line sonarjs/cognitive-complexity const reportColumnForm = () => { if (reportColumnFormMode === '') { return null; @@ -1120,18 +1177,40 @@ export default function ProcessInstanceListTable({ ]); if (reportColumnToOperateOn && reportColumnToOperateOn.filterable) { formElements.push( - { + setReportColumnConditionOperator(value.selectedItem); + setRequiresRefilter(true); + }} /> ); + + const filterOperator = getFilterOperatorFromReportColumn( + reportColumnToOperateOn + ); + if (filterOperator && filterOperator.requires_value) { + formElements.push( + + ); + } } formElements.push(
@@ -1273,6 +1352,7 @@ export default function ProcessInstanceListTable({ labelText="Include oldest open task information" id="with-oldest-open-task-checkbox" checked={withOldestOpenTask} + disabled={showActionsColumn} onChange={(value: any) => { setWithOldestOpenTask(value.target.checked); setRequiresRefilter(true); diff --git a/spiffworkflow-frontend/src/helpers.tsx b/spiffworkflow-frontend/src/helpers.tsx index 394b36aa..963765c3 100644 --- a/spiffworkflow-frontend/src/helpers.tsx +++ b/spiffworkflow-frontend/src/helpers.tsx @@ -37,6 +37,19 @@ export const underscorizeString = (inputString: string) => { return slugifyString(inputString).replace(/-/g, '_'); }; +export const getKeyByValue = ( + object: any, + value: string, + onAttribute?: string +) => { + return Object.keys(object).find((key) => { + if (onAttribute) { + return object[key][onAttribute] === value; + } + return object[key] === value; + }); +}; + export const recursivelyChangeNullAndUndefined = (obj: any, newValue: any) => { if (obj === null || obj === undefined) { return newValue; diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index 6a39dc9a..6b81fb1c 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -128,6 +128,15 @@ export interface ProcessReference { export type ObjectWithStringKeysAndValues = { [key: string]: string }; +export interface FilterOperator { + id: string; + requires_value: boolean; +} + +export interface FilterOperatorMapping { + [key: string]: FilterOperator; +} + export interface ProcessFile { content_type: string; last_modified: string;