/* * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection"; import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {Badge, ToggleButton, ToggleButtonGroup} from "@mui/material"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; import Icon from "@mui/material/Icon"; import Tooltip from "@mui/material/Tooltip"; import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro"; import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react"; import QContext from "QContext"; import colors from "qqq/assets/theme/base/colors"; import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; import FieldListMenu from "qqq/components/query/FieldListMenu"; import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter"; import XIcon from "qqq/components/query/XIcon"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; interface BasicAndAdvancedQueryControlsProps { metaData: QInstance; tableMetaData: QTableMetaData; savedViewsComponent: JSX.Element; columnMenuComponent: JSX.Element; quickFilterFieldNames: string[]; setQuickFilterFieldNames: (names: string[]) => void; queryFilter: QQueryFilter; setQueryFilter: (queryFilter: QQueryFilter) => void; gridApiRef: React.MutableRefObject; ///////////////////////////////////////////////////////////////////////////////////////////// // this prop is used as a way to recognize changes in the query filter internal structure, // // since the queryFilter object (reference) doesn't get updated // ///////////////////////////////////////////////////////////////////////////////////////////// queryFilterJSON: string; mode: string; setMode: (mode: string) => void; } let debounceTimeout: string | number | NodeJS.Timeout; /******************************************************************************* ** Component to provide the basic & advanced query-filter controls for the ** RecordQueryOrig screen. ** ** Done as a forwardRef, so RecordQueryOrig can call some functions, e.g., when user ** does things on that screen, that we need to know about in here. *******************************************************************************/ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) => { const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props ///////////////////// // state variables // ///////////////////// const [defaultQuickFilterFieldNames, setDefaultQuickFilterFieldNames] = useState(getDefaultQuickFilterFieldNames(tableMetaData)); const [defaultQuickFilterFieldNameMap, setDefaultQuickFilterFieldNameMap] = useState(Object.fromEntries(defaultQuickFilterFieldNames.map(k => [k, true]))); const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null) const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0); const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false); const [mouseOverElement, setMouseOverElement] = useState(null as string); const [, forceUpdate] = useReducer((x) => x + 1, 0); const {accentColor} = useContext(QContext); ////////////////////////////////////////////////////////////////////////////////// // make some functions available to our parent - so it can tell us to do things // ////////////////////////////////////////////////////////////////////////////////// useImperativeHandle(ref, () => { return { ensureAllFilterCriteriaAreActiveQuickFilters(currentFilter: QQueryFilter, reason: string) { ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, currentFilter, reason); }, addField(fieldName: string) { addQuickFilterField({fieldName: fieldName}, "columnMenu"); }, getCurrentMode() { return (mode); } } }); /******************************************************************************* ** *******************************************************************************/ function handleMouseOverElement(name: string) { setMouseOverElement(name); } /******************************************************************************* ** *******************************************************************************/ function handleMouseOutElement() { setMouseOverElement(null); } /******************************************************************************* ** for a given field, set its default operator for quick-filter dropdowns. *******************************************************************************/ function getDefaultOperatorForField(field: QFieldMetaData) { // todo - sometimes i want contains instead of equals on strings (client.name, for example...) let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS; if (field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE) { defaultOperator = QCriteriaOperator.GREATER_THAN; } else if (field?.type == QFieldType.BOOLEAN) { ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // for booleans, if we set a default, since none of them have values, then they are ALWAYS selected, which isn't what we want. // ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// defaultOperator = null; } return defaultOperator; } /******************************************************************************* ** Callback passed into the QuickFilter component, to update the criteria ** after user makes changes to it or to clear it out. *******************************************************************************/ const updateQuickCriteria = (newCriteria: QFilterCriteria, needDebounce = false, doClearCriteria = false) => { let found = false; let foundIndex = null; for (let i = 0; i < queryFilter?.criteria?.length; i++) { if(queryFilter.criteria[i].fieldName == newCriteria.fieldName) { queryFilter.criteria[i] = newCriteria; found = true; foundIndex = i; break; } } if(doClearCriteria) { if(found) { queryFilter.criteria.splice(foundIndex, 1); setQueryFilter(queryFilter); } return; } if(!found) { if(!queryFilter.criteria) { queryFilter.criteria = []; } queryFilter.criteria.push(newCriteria); found = true; } if(found) { clearTimeout(debounceTimeout) debounceTimeout = setTimeout(() => { setQueryFilter(queryFilter); }, needDebounce ? 500 : 1); forceUpdate(); } }; /******************************************************************************* ** Get the QFilterCriteriaWithId object to pass in to the QuickFilter component ** for a given field name. *******************************************************************************/ const getQuickCriteriaParam = (fieldName: string): QFilterCriteriaWithId | null | "tooComplex" => { const matches: QFilterCriteriaWithId[] = []; for (let i = 0; i < queryFilter?.criteria?.length; i++) { if(queryFilter.criteria[i].fieldName == fieldName) { matches.push(queryFilter.criteria[i] as QFilterCriteriaWithId); } } if(matches.length == 0) { return (null); } else if(matches.length == 1) { return (matches[0]); } else { return "tooComplex"; } }; /******************************************************************************* ** Event handler for QuickFilter component, to remove a quick filter field from ** the screen. *******************************************************************************/ const handleRemoveQuickFilterField = (fieldName: string): void => { const index = quickFilterFieldNames.indexOf(fieldName) if(index >= 0) { ////////////////////////////////////// // remove this field from the query // ////////////////////////////////////// const criteria = new QFilterCriteria(fieldName, null, []); updateQuickCriteria(criteria, false, true); quickFilterFieldNames.splice(index, 1); setQuickFilterFieldNames(quickFilterFieldNames); } }; /******************************************************************************* ** Event handler for button that opens the add-quick-filter menu *******************************************************************************/ const openAddQuickFilterMenu = (event: any) => { setAddQuickFilterMenu(event.currentTarget); setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1); } /******************************************************************************* ** Handle closing the add-quick-filter menu *******************************************************************************/ const closeAddQuickFilterMenu = () => { setAddQuickFilterMenu(null); } /******************************************************************************* ** Add a quick-filter field to the screen, from either the user selecting one, ** or from a new query being activated, etc. *******************************************************************************/ const addQuickFilterField = (newValue: any, reason: "blur" | "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | "columnMenu" | "activatedView" | string) => { console.log(`Adding quick filter field as: ${JSON.stringify(newValue)}`); if (reason == "blur") { ////////////////////////////////////////////////////////////////// // this keeps a click out of the menu from selecting the option // ////////////////////////////////////////////////////////////////// return; } const fieldName = newValue ? newValue.fieldName : null; if (fieldName) { if(defaultQuickFilterFieldNameMap[fieldName]) { return; } if (quickFilterFieldNames.indexOf(fieldName) == -1) { ///////////////////////////////// // add the field if we need to // ///////////////////////////////// quickFilterFieldNames.push(fieldName); setQuickFilterFieldNames(quickFilterFieldNames); /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // only do this when user has added the field (e.g., not when adding it because of a selected view or filter-in-url) // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if(reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected" && reason != "activatedView") { setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5); } } else if(reason == "columnMenu") { ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // if field was already on-screen, but user clicked an option from the columnMenu, then open the quick-filter field // ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5); } closeAddQuickFilterMenu(); } }; /******************************************************************************* ** *******************************************************************************/ const handleFieldListMenuSelection = (field: QFieldMetaData, table: QTableMetaData): void => { let fullFieldName = field.name; if(table && table.name != tableMetaData.name) { fullFieldName = `${table.name}.${field.name}`; } addQuickFilterField({fieldName: fullFieldName}, "selectedFromAddFilterMenu"); } /******************************************************************************* ** event handler for the Filter Builder button - e.g., opens the parent's grid's ** filter panel *******************************************************************************/ const openFilterBuilder = (e: React.MouseEvent | React.MouseEvent) => { gridApiRef.current.showFilterPanel(); }; /******************************************************************************* ** event handler for the clear-filters modal *******************************************************************************/ const handleClearFiltersAction = (event: React.KeyboardEvent, isYesButton: boolean = false) => { if (isYesButton || event.key == "Enter") { setShowClearFiltersWarning(false); setQueryFilter(new QQueryFilter([], queryFilter.orderBys)); } }; /******************************************************************************* ** *******************************************************************************/ const removeCriteriaByIndex = (index: number) => { queryFilter.criteria.splice(index, 1); setQueryFilter(queryFilter); } /******************************************************************************* ** format the current query as a string for showing on-screen as a preview. *******************************************************************************/ const queryToAdvancedString = () => { if(queryFilter == null || !queryFilter.criteria) { return (); } let counter = 0; return ( {queryFilter.criteria.map((criteria, i) => { const {criteriaIsValid} = validateCriteria(criteria, null); if(criteriaIsValid) { counter++; return ( handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}> {counter > 1 ? {queryFilter.booleanOperator}  : } {FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)} {mouseOverElement == `queryPreview-${i}` && removeCriteriaByIndex(i)} />} ); } else { return (); } })} ); }; /******************************************************************************* ** event handler for toggling between modes - basic & advanced. *******************************************************************************/ const modeToggleClicked = (newValue: string | null) => { if (newValue) { if(newValue == "basic") { //////////////////////////////////////////////////////////////////////////////// // we're always allowed to go to advanced - // // but if we're trying to go to basic, make sure the filter isn't too complex // //////////////////////////////////////////////////////////////////////////////// const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); if (!canFilterWorkAsBasic) { console.log("Query cannot work as basic - so - not allowing toggle to basic.") return; } //////////////////////////////////////////////////////////////////////////////////////////////// // when going to basic, make sure all fields in the current query are active as quick-filters // //////////////////////////////////////////////////////////////////////////////////////////////// if (queryFilter && queryFilter.criteria) { ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "modeToggleClicked", "basic"); } } ////////////////////////////////////////////////////////////////////////////////////// // note - this is a callback to the parent - as it is responsible for this state... // ////////////////////////////////////////////////////////////////////////////////////// setMode(newValue); } }; /******************************************************************************* ** make sure that all fields in the current query are on-screen as quick-filters ** (that is, if the query can be basic) *******************************************************************************/ const ensureAllFilterCriteriaAreActiveQuickFilters = (tableMetaData: QTableMetaData, queryFilter: QQueryFilter, reason: "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | string, newMode?: string) => { if(!tableMetaData || !queryFilter) { return; } const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); if (!canFilterWorkAsBasic) { console.log("query is too complex for basic - so - switching to advanced"); modeToggleClicked("advanced"); forceUpdate(); return; } const modeToUse = newMode ?? mode; if(modeToUse == "basic") { for (let i = 0; i < queryFilter?.criteria?.length; i++) { const criteria = queryFilter.criteria[i]; if (criteria && criteria.fieldName) { addQuickFilterField(criteria, reason); } } } } /******************************************************************************* ** count how many valid criteria are in the query - for showing badge *******************************************************************************/ const countValidCriteria = (queryFilter: QQueryFilter): number => { let count = 0; for (let i = 0; i < queryFilter?.criteria?.length; i++) { const {criteriaIsValid} = validateCriteria(queryFilter.criteria[i], null); if(criteriaIsValid) { count++; } } return count; } /******************************************************************************* ** Event handler for setting the sort from that menu *******************************************************************************/ const handleSetSort = (field: QFieldMetaData, table: QTableMetaData, isAscending: boolean = true): void => { const fullFieldName = table && table.name != tableMetaData.name ? `${table.name}.${field.name}` : field.name; queryFilter.orderBys = [new QFilterOrderBy(fullFieldName, isAscending)] setQueryFilter(queryFilter); forceUpdate(); } /******************************************************************************* ** event handler for a click on a field's up or down arrow in the sort menu *******************************************************************************/ const handleSetSortArrowClick = (field: QFieldMetaData, table: QTableMetaData, event: any): void => { event.stopPropagation(); //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // make sure this is an event handler for one of our icons (not something else in the dom here in our end-adornments) // //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// const isAscending = event.target.innerHTML == "arrow_upward"; const isDescending = event.target.innerHTML == "arrow_downward"; if(isAscending || isDescending) { handleSetSort(field, table, isAscending); } } /******************************************************************************* ** event handler for clicking the current sort up/down arrow, to toggle direction. *******************************************************************************/ function toggleSortDirection(event: React.MouseEvent): void { event.stopPropagation(); try { queryFilter.orderBys[0].isAscending = !queryFilter.orderBys[0].isAscending; setQueryFilter(queryFilter); forceUpdate(); } catch(e) { console.log(`Error toggling sort: ${e}`) } } ///////////////////////////////// // set up the sort menu button // ///////////////////////////////// let sortButtonContents = <>Sort... if(queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0) { const orderBy = queryFilter.orderBys[0]; const orderByFieldName = orderBy.fieldName; const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName); const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`; sortButtonContents = <>Sort: {fieldLabel} {orderBy.isAscending ? "arrow_upward" : "arrow_downward"} } //////////////////////////////////////////////////////////////////////////////////////////////////////////// // this is being used as a version of like forcing that we get re-rendered if the query filter changes... // //////////////////////////////////////////////////////////////////////////////////////////////////////////// const [lastIndex, setLastIndex] = useState(queryFilterJSON); if(queryFilterJSON != lastIndex) { ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "defaultFilterLoaded"); setLastIndex(queryFilterJSON); } /////////////////////////////////////////////////// // set some status flags based on current filter // /////////////////////////////////////////////////// const hasValidFilters = queryFilter && countValidCriteria(queryFilter) > 0; const {canFilterWorkAsBasic, reasonsWhyItCannot} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); let reasonWhyBasicIsDisabled = null; if(reasonsWhyItCannot && reasonsWhyItCannot.length > 0) { reasonWhyBasicIsDisabled = <> Your current Filter cannot be managed using Basic mode because:
    {reasonsWhyItCannot.map((reason, i) =>
  • {reason}
  • )}
} const borderGray = colors.grayLines.main; const sortMenuComponent = ( arrow_upwardarrow_downward} handleAdornmentClick={handleSetSortArrowClick} />); const filterBuilderMouseEvents = { onMouseOver: () => handleMouseOverElement("filterBuilderButton"), onMouseOut: () => handleMouseOutElement() }; return ( {/* First row: Saved Views button (with Columns button in the middle of it), then space-between, then basic|advanced toggle */} {savedViewsComponent} {columnMenuComponent} modeToggleClicked(newValue)} size="small" sx={{pl: 0.5, width: "10rem"}} > Basic Advanced {/* Second row: Basic or advanced mode - with sort-by control on the right (of each) */} { /////////////////////////////////////////////////////////////////////////////////// // basic mode - wrapping-list of fields & add-field button, then sort-by control // /////////////////////////////////////////////////////////////////////////////////// mode == "basic" && <> { tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) => { const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); let defaultOperator = getDefaultOperatorForField(field); return (); }) } {/* vertical rule */} { tableMetaData && quickFilterFieldNames?.map((fieldName) => { const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); let defaultOperator = getDefaultOperatorForField(field); return (defaultQuickFilterFieldNameMap[fieldName] ? null : ); }) } { tableMetaData && add)}} buttonChildren={"Add Filter"} isModeSelectOne={true} handleSelectedField={handleFieldListMenuSelection} /> } {sortMenuComponent} } { ////////////////////////////////////////////////////////////////////////////////////////////////////////////// // advanced mode - 2 rows - one for Filter Builder button & sort control, 2nd row for the filter-detail box // ////////////////////////////////////////////////////////////////////////////////////////////////////////////// metaData && tableMetaData && mode == "advanced" && <> { hasValidFilters && mouseOverElement == "filterBuilderButton" && setShowClearFiltersWarning(true)} /> } setShowClearFiltersWarning(false)} onKeyPress={(e) => handleClearFiltersAction(e)}> Confirm Are you sure you want to remove all conditions from the current filter? setShowClearFiltersWarning(false)} /> handleClearFiltersAction(null, true)} /> {sortMenuComponent} { {queryToAdvancedString()} } } ); }); export function getDefaultQuickFilterFieldNames(table: QTableMetaData): string[] { const defaultQuickFilterFieldNames: string[] = []; ////////////////////////////////////////////////////////////////////////////////////////////////// // check if there's materialDashboard tableMetaData, and if it has defaultQuickFilterFieldNames // ////////////////////////////////////////////////////////////////////////////////////////////////// const mdbMetaData = table?.supplementalTableMetaData?.get("materialDashboard"); if (mdbMetaData) { if (mdbMetaData?.defaultQuickFilterFieldNames?.length) { for (let i = 0; i < mdbMetaData.defaultQuickFilterFieldNames.length; i++) { defaultQuickFilterFieldNames.push(mdbMetaData.defaultQuickFilterFieldNames[i]); } } } ///////////////////////////////////////////// // if still none, then look for T1 section // ///////////////////////////////////////////// if (defaultQuickFilterFieldNames.length == 0) { if (table.sections) { const t1Sections = table.sections.filter((s: QTableSection) => s.tier == "T1"); if (t1Sections.length) { for (let i = 0; i < t1Sections.length; i++) { if (t1Sections[i].fieldNames) { for (let j = 0; j < t1Sections[i].fieldNames.length; j++) { defaultQuickFilterFieldNames.push(t1Sections[i].fieldNames[j]); } } } } } } return (defaultQuickFilterFieldNames); } export default BasicAndAdvancedQueryControls;