/* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect, useMemo } from "react"; import FilterPopupView from "./FilterPopupView"; import { Operator, OperatorCreiteria } from "../../enum"; import { FilterExpression, FilterExpressionViewModel, FilterTarget, } from "../../data/advancedSearch"; import { buildCriteriaFromRows, getAdvancedSearchIntitialFilterRow, getRowLabel, isFilterValueEmpty, operatorNeedsValue, } from "./helper/AdvancedSearchFunctions"; import _ from "lodash"; import { defaultCriteria } from "./constant"; interface FilterPopupProps { uiElementGroupData: Record; filterableFields: FilterTarget[]; config: { uiElementGroupId: string }; cache?: Record; loadSupportiveData: (propertyToFilter: FilterTarget) =>Promise; handlePopupShow: () => void; onModelUpdate: ( callBack: ((args: any) => void) | null, fieldName: string, value: any, ) => void; loadTemplateSupportiveData?: ( callBack: (args: any) => void, supportiveKeys: any, ) => Promise; } const FilterPopup = (props: FilterPopupProps) => { // If appliedQuery has data but filterRows doesn't, populate filterRows from appliedQuery.filters useEffect(() => { const advancedSearch = props.uiElementGroupData?.advancedSearch; const hasAppliedQuery = advancedSearch?.appliedQuery?.filters?.length > 0; const hasValidFilterRows = advancedSearch?.filterRows?.length > 0 && advancedSearch?.filterRows[0]?.selectedFilter?.propertyToFilter ?.apiPropertyName; if (hasAppliedQuery && !hasValidFilterRows) { const filterRows: FilterExpressionViewModel[] = []; let criteria = advancedSearch.appliedQuery.criteria; advancedSearch.appliedQuery.filters.forEach( (filter: FilterExpression, index: number) => { const filterRow: FilterExpressionViewModel = { id: filter.id, idLabel: `(${(index + 1).toString()})`, selectedFilter: _.cloneDeep(filter), allPropertieseToFilter: props.filterableFields, alloperators: [], allValues: undefined, }; criteria = criteria.replace(filter.id, filterRow.idLabel); filterRows.push(filterRow); }, ); const updatedAdvancedSearch = { ...advancedSearch, filterRows: _.cloneDeep(filterRows), criteria, }; const updatedUiElementGroupData = { ...props.uiElementGroupData, advancedSearch: updatedAdvancedSearch, }; props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); } }, []); const onAdvancedSearchFilterTargetChange = ( value: FilterTarget, id: string, ) => { const filterRows = _.cloneDeep( props.uiElementGroupData.advancedSearch.filterRows, ); const filterRow = filterRows.find( (filterRow: FilterExpressionViewModel) => filterRow.id === id, ); if (filterRow) { filterRow.selectedFilter.propertyToFilter = value; filterRow.selectedFilter.value = undefined; filterRow.selectedFilter.operator = { label: "Select Operator...", value: null, }; } const updatedUiElementGroupData = { ...props.uiElementGroupData, advancedSearch: { ...props.uiElementGroupData.advancedSearch, filterRows: filterRows, }, }; props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); }; const onAdvancedSearchOperatorChange = ( value: { label: string; value: Operator | null; }, id: string, ) => { const filterRows = _.cloneDeep( props.uiElementGroupData.advancedSearch.filterRows, ); const filterRow = filterRows.find( (filterRow: FilterExpressionViewModel) => filterRow.id === id, ); if (filterRow) { filterRow.selectedFilter.operator = value; filterRow.selectedFilter.value = undefined; } const updatedUiElementGroupData = { ...props.uiElementGroupData, advancedSearch: { ...props.uiElementGroupData.advancedSearch, filterRows: filterRows, }, }; props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); }; const onAdvancedSearchFilterValueChange = (value: any, id: string) => { const filterRows = _.cloneDeep( props.uiElementGroupData.advancedSearch.filterRows, ); const filterRow = filterRows.find( (filterRow: FilterExpressionViewModel) => filterRow.id === id, ); if (filterRow) { filterRow.selectedFilter.value = value; } const updatedUiElementGroupData = { ...props.uiElementGroupData, advancedSearch: { ...props.uiElementGroupData.advancedSearch, filterRows: filterRows, }, }; props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); }; const onAdvancedSearchCriteriaToggle = (id: string) => { const filterRows = _.cloneDeep( props.uiElementGroupData.advancedSearch.filterRows, ); const filterRow = filterRows.find( (filterRow: FilterExpressionViewModel) => filterRow.id === id, ); if (filterRow) { filterRow.selectedFilter.selectedCriteria = filterRow.selectedFilter.selectedCriteria === OperatorCreiteria.AND ? OperatorCreiteria.OR : OperatorCreiteria.AND; } // Toggling a chip discards any hand-grouping in the expression, because the // chips can only express a flat left-to-right join. That is the trade the // chip makes and it is now visible: the box updates to the rebuilt // expression in front of the user rather than silently disagreeing with it. const updatedUiElementGroupData = { ...props.uiElementGroupData, advancedSearch: { ...props.uiElementGroupData.advancedSearch, filterRows: filterRows, criteria: buildCriteriaFromRows(filterRows), }, }; props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); }; const onAdvancedSearchFilterAdd = (id: string) => { const idPosition = props.uiElementGroupData.advancedSearch.filterRows.findIndex( (filterRow: FilterExpressionViewModel) => filterRow.id === id, ); const initialFilterRow = _.cloneDeep(getAdvancedSearchIntitialFilterRow()); initialFilterRow.allPropertieseToFilter = props.filterableFields; const updatedFilterRows = _.cloneDeep([ ...props.uiElementGroupData.advancedSearch.filterRows.slice( 0, idPosition + 1, ), initialFilterRow, ...props.uiElementGroupData.advancedSearch.filterRows.slice( idPosition + 1, ), ]).map((row: FilterExpressionViewModel, index: number) => { row.idLabel = getRowLabel(index); return row; }); const updatedUiElementGroupData = { ...props.uiElementGroupData, advancedSearch: { ...props.uiElementGroupData.advancedSearch, filterRows: updatedFilterRows, criteria: buildCriteriaFromRows(updatedFilterRows), }, }; props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); }; const onAdvancedSearchFilterDelete = (id: string) => { let filterRows = props.uiElementGroupData.advancedSearch.filterRows.filter( (filterRow: FilterExpressionViewModel) => filterRow.id !== id, ); filterRows = _.cloneDeep(filterRows)?.map( (filterRow: FilterExpressionViewModel, index: number) => { filterRow.idLabel = getRowLabel(index); return filterRow; }, ); const updatedUiElementGroupData = { ...props.uiElementGroupData, advancedSearch: { ...props.uiElementGroupData.advancedSearch, filterRows: filterRows, criteria: buildCriteriaFromRows(filterRows), }, pagination: { ...props.uiElementGroupData.pagination, skip: 0 }, }; props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); }; const onAdvancedSearchClear = () => { const initialFilterRow = _.cloneDeep(getAdvancedSearchIntitialFilterRow()); initialFilterRow.allPropertieseToFilter = props.filterableFields; const updatedUiElementGroupData = { ...props.uiElementGroupData, advancedSearch: { ...props.uiElementGroupData.advancedSearch, filterRows: [initialFilterRow], criteria: defaultCriteria, appliedQuery: { criteria: "", filters: [], }, }, }; props.handlePopupShow(); props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); }; /** * Push each row's AND/OR chip back into sync with an expression the user * wrote by hand. `rowOperators` comes from the parser and is keyed by row * label; a row the expression leaves in last position has no operator after * it and keeps whatever it had, which is never read. */ const withRowOperators = ( filterRows: FilterExpressionViewModel[], rowOperators: Record, ): FilterExpressionViewModel[] => filterRows.map((filterRow) => { const operator = rowOperators[filterRow.idLabel]; if (operator) { filterRow.selectedFilter.selectedCriteria = operator; } return filterRow; }); /** * Commit a hand-written criteria expression. * * Only ever called with an expression `validateCriteriaExpression` has * accepted and normalized — the view holds the raw draft and does not commit * while it is invalid, so `advancedSearch.criteria` can no longer hold * anything that is not a well-formed expression over the current rows. * * It used to read the box out of the DOM by id (`#tmpl-criteria-input-`), * which is why two toolbars sharing a `uiElementGroupId` read each other's * expression, and why the box could sit there showing text the model had * never seen. The value now travels as an argument. */ const onCriteriaChange = ( expression: string, rowOperators: Record, ) => { const filterRows = withRowOperators( _.cloneDeep(props.uiElementGroupData.advancedSearch.filterRows), rowOperators, ); props.onModelUpdate(null, props.config.uiElementGroupId, { ...props.uiElementGroupData, advancedSearch: { ...props.uiElementGroupData.advancedSearch, filterRows, criteria: expression, }, }); }; const onCopyToClipboard = async (expression: string) => { try { await navigator.clipboard.writeText(expression ?? ""); } catch (err) { console.error("Unable to copy to clipboard", err); } }; const onAdvancedSearchCancel = () => { const clonedFilterRows = _.cloneDeep( props.uiElementGroupData.advancedSearch.filterRows, ); const filterRows: FilterExpressionViewModel[] = []; let criteria = props.uiElementGroupData.advancedSearch.appliedQuery.criteria; props.uiElementGroupData.advancedSearch.appliedQuery?.filters?.map( (filter: any, index: number) => { const clonedFilterRow = clonedFilterRows?.find( (row: any) => row.id === filter.id, ); const filterRow: FilterExpressionViewModel = { id: filter.id, idLabel: `(${(index + 1).toString()})`, selectedFilter: _.cloneDeep(filter), allPropertieseToFilter: props.filterableFields, alloperators: clonedFilterRow?.alloperators ?? [], allValues: clonedFilterRow ? clonedFilterRow?.allValues : undefined, }; criteria = criteria.replace(filter.id, filterRow.idLabel); filterRows.push(filterRow); }, ); if (!filterRows.length) { const initialFilterRow = _.cloneDeep( getAdvancedSearchIntitialFilterRow(), ); initialFilterRow.allPropertieseToFilter = props.filterableFields; filterRows.push(initialFilterRow); criteria = defaultCriteria; } const updatedAdvancedSearch = { ...props.uiElementGroupData.advancedSearch, filterRows: _.cloneDeep(filterRows), criteria, }; const updatedUiElementGroupData = { ...props.uiElementGroupData, advancedSearch: updatedAdvancedSearch, }; // Cancel dismisses the panel, the same way Clear and Apply do — it discards // the edits by restoring `appliedQuery`, so leaving the popup open showed a // reverted form with no way out but a click outside. props.handlePopupShow(); props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); }; /** * Apply takes the expression as an argument rather than reading * `advancedSearch.criteria`, so what is applied is exactly what the box shows * at the moment of the click — including an edit the user had not blurred * out of yet. The view has already validated it; this is the single write * that lands the rows, the expression and the applied query together. */ const onAdvancedSearchApply = ( expression: string, rowOperators: Record, ) => { const filterRows = withRowOperators( _.cloneDeep(props.uiElementGroupData.advancedSearch.filterRows), rowOperators, ); const selectedFilters: FilterExpression[] = []; // Label → id. Each `(n)` appears exactly once (the validator rejects // duplicates) and `(1)` cannot match inside `(11)`, so a first-occurrence // replace per row is exact. let criteria = expression; filterRows?.forEach((filterRow: FilterExpressionViewModel) => { selectedFilters.push(filterRow.selectedFilter); criteria = criteria.replace(filterRow.idLabel, filterRow.id); }); const updatedAdvancedSearch = { ...props.uiElementGroupData.advancedSearch, filterRows, criteria: expression, appliedQuery: _.cloneDeep({ criteria, filters: selectedFilters }), }; const updatedUiElementGroupData = { ...props.uiElementGroupData, advancedSearch: updatedAdvancedSearch, pagination: { ...props.uiElementGroupData.pagination, skip: 0 }, }; props.handlePopupShow(); props.onModelUpdate( null, props.config.uiElementGroupId, updatedUiElementGroupData, ); }; const hasValidFilter = useMemo(() => { const filterRows: FilterExpressionViewModel[] = props.uiElementGroupData?.advancedSearch?.filterRows ?? []; return filterRows.some( (row) => row.selectedFilter?.propertyToFilter?.apiPropertyName && row.selectedFilter?.operator?.value != null && // Is Empty / Is Not Empty are complete without a value — demanding one // leaves Apply disabled on a row that is perfectly valid. (!operatorNeedsValue(row.selectedFilter?.operator?.value) || !isFilterValueEmpty(row.selectedFilter?.value)), ); }, [props.uiElementGroupData?.advancedSearch?.filterRows]); const hasAppliedFilter = useMemo(() => { const appliedFilters = props.uiElementGroupData?.advancedSearch?.appliedQuery?.filters ?? []; return appliedFilters.length > 0; }, [props.uiElementGroupData?.advancedSearch?.appliedQuery?.filters]); const isDirty = useMemo(() => { const filterRows: FilterExpressionViewModel[] = props.uiElementGroupData?.advancedSearch?.filterRows ?? []; const appliedQuery = props.uiElementGroupData?.advancedSearch?.appliedQuery; const appliedFilters = appliedQuery?.filters ?? []; const currentCriteria = props.uiElementGroupData?.advancedSearch?.criteria ?? ""; // Build comparable current filters from filterRows const currentFilters = filterRows.map((row) => ({ apiPropertyName: row.selectedFilter?.propertyToFilter?.apiPropertyName, operator: row.selectedFilter?.operator?.value, value: row.selectedFilter?.value, selectedCriteria: row.selectedFilter?.selectedCriteria, })); // Build comparable applied filters const appliedComparable = appliedFilters.map( (filter: FilterExpression) => ({ apiPropertyName: filter.propertyToFilter?.apiPropertyName, operator: filter.operator?.value, value: filter.value, selectedCriteria: filter.selectedCriteria, }), ); // Also compare criteria expression const appliedCriteria = appliedQuery?.criteria ?? ""; // Reconstruct current criteria with IDs for comparison let currentCriteriaWithIds = currentCriteria; filterRows.forEach((row) => { currentCriteriaWithIds = currentCriteriaWithIds.replace( row.idLabel, row.id, ); }); return ( JSON.stringify(currentFilters) !== JSON.stringify(appliedComparable) || currentCriteriaWithIds !== appliedCriteria ); }, [ props.uiElementGroupData?.advancedSearch?.filterRows, props.uiElementGroupData?.advancedSearch?.appliedQuery, props.uiElementGroupData?.advancedSearch?.criteria, ]); return ( ); }; export default FilterPopup;