diff --git a/src/qqq/components/misc/FieldAutoComplete.tsx b/src/qqq/components/misc/FieldAutoComplete.tsx new file mode 100644 index 0000000..3a10805 --- /dev/null +++ b/src/qqq/components/misc/FieldAutoComplete.tsx @@ -0,0 +1,146 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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 {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; +import React, {ReactNode} from "react"; + +interface FieldAutoCompleteProps +{ + id: string; + metaData: QInstance; + tableMetaData: QTableMetaData; + handleFieldChange: (event: any, newValue: any, reason: string) => void; + defaultValue?: {field: QFieldMetaData, table: QTableMetaData, fieldName: string}; + autoFocus?: boolean + hiddenFieldNames?: string[] +} + +FieldAutoComplete.defaultProps = + { + defaultValue: null, + autoFocus: false, + hiddenFieldNames: [] + }; + +function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[]) +{ + const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label)); + for (let i = 0; i < sortedFields.length; i++) + { + const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name; + + if(hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1) + { + continue; + } + + fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName}); + } +} + +export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element +{ + const fieldOptions: any[] = []; + makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames); + let fieldsGroupBy = null; + + if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0) + { + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const exposedJoin = tableMetaData.exposedJoins[i]; + if (metaData.tables.has(exposedJoin.joinTable.name)) + { + fieldsGroupBy = (option: any) => `${option.table.label} fields`; + makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames); + } + } + } + + + function getFieldOptionLabel(option: any) + { + ///////////////////////////////////////////////////////////////////////////////////////// + // note - we're using renderFieldOption below for the actual select-box options, which // + // are always jut field label (as they are under groupings that show their table name) // + ///////////////////////////////////////////////////////////////////////////////////////// + if (option && option.field && option.table) + { + if (option.table.name == tableMetaData.name) + { + return (option.field.label); + } + else + { + return (option.table.label + ": " + option.field.label); + } + } + + return (""); + } + + + ////////////////////////////////////////////////////////////////////////////////////////////// + // for options, we only want the field label (contrast with what we show in the input box, // + // which comes out of getFieldOptionLabel, which is the table-label prefix for join fields) // + ////////////////////////////////////////////////////////////////////////////////////////////// + function renderFieldOption(props: React.HTMLAttributes, option: any, state: AutocompleteRenderOptionState): ReactNode + { + let label = ""; + if (option && option.field) + { + label = (option.field.label); + } + + return (
  • {label}
  • ); + } + + + function isFieldOptionEqual(option: any, value: any) + { + return option.fieldName === value.fieldName; + } + + + return ( + ()} + // @ts-ignore + defaultValue={defaultValue} + options={fieldOptions} + onChange={handleFieldChange} + isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)} + groupBy={fieldsGroupBy} + getOptionLabel={(option) => getFieldOptionLabel(option)} + renderOption={(props, option, state) => renderFieldOption(props, option, state)} + autoSelect={true} + autoHighlight={true} + slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} + /> + + ); +} diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index 364d52f..bed7987 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -34,6 +34,7 @@ import Select, {SelectChangeEvent} from "@mui/material/Select/Select"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import React, {ReactNode, SyntheticEvent, useState} from "react"; +import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; @@ -177,16 +178,74 @@ interface FilterCriteriaRowProps updateBooleanOperator: (newValue: string) => void; } -FilterCriteriaRow.defaultProps = {}; - -function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean) -{ - const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label)); - for (let i = 0; i < sortedFields.length; i++) +FilterCriteriaRow.defaultProps = { - const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name; - fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName}); + }; + +export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue: OperatorOption) +{ + let criteriaIsValid = true; + let criteriaStatusTooltip = "This condition is fully defined and is part of your filter."; + + function isNotSet(value: any) + { + return (value === null || value == undefined || String(value).trim() === ""); } + + if(!criteria) + { + criteriaIsValid = false; + criteriaStatusTooltip = "This condition is not defined."; + return {criteriaIsValid, criteriaStatusTooltip}; + } + + if (!criteria.fieldName) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must select a field to begin to define this condition."; + } + else if (!criteria.operator) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must select an operator to continue to define this condition."; + } + else + { + if (operatorSelectedValue) + { + if (operatorSelectedValue.valueMode == ValueMode.NONE || operatorSelectedValue.implicitValues) + { + ////////////////////////////////// + // don't need to look at values // + ////////////////////////////////// + } + else if (operatorSelectedValue.valueMode == ValueMode.DOUBLE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE_TIME) + { + if (criteria.values.length < 2 || isNotSet(criteria.values[0]) || isNotSet(criteria.values[1])) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter two values to complete the definition of this condition."; + } + } + else if (operatorSelectedValue.valueMode == ValueMode.MULTI || operatorSelectedValue.valueMode == ValueMode.PVS_MULTI) + { + if (criteria.values.length < 1 || isNotSet(criteria.values[0])) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition."; + } + } + else + { + if (!criteria.values || isNotSet(criteria.values[0])) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter a value to complete the definition of this condition."; + } + } + } + } + return {criteriaIsValid, criteriaStatusTooltip}; } export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator}: FilterCriteriaRowProps): JSX.Element @@ -195,27 +254,6 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, const [operatorSelectedValue, setOperatorSelectedValue] = useState(null as OperatorOption); const [operatorInputValue, setOperatorInputValue] = useState(""); - /////////////////////////////////////////////////////////////// - // set up the array of options for the fields Autocomplete // - // also, a groupBy function, in case there are exposed joins // - /////////////////////////////////////////////////////////////// - const fieldOptions: any[] = []; - makeFieldOptionsForTable(tableMetaData, fieldOptions, false); - let fieldsGroupBy = null; - - if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0) - { - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) - { - const exposedJoin = tableMetaData.exposedJoins[i]; - if (metaData.tables.has(exposedJoin.joinTable.name)) - { - fieldsGroupBy = (option: any) => `${option.table.label} fields`; - makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true); - } - } - } - //////////////////////////////////////////////////////////// // set up array of options for operator dropdown // // only call the function to do it if we have a field set // @@ -383,111 +421,19 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, return (false); }; - function isFieldOptionEqual(option: any, value: any) - { - return option.fieldName === value.fieldName; - } - - function getFieldOptionLabel(option: any) - { - ///////////////////////////////////////////////////////////////////////////////////////// - // note - we're using renderFieldOption below for the actual select-box options, which // - // are always jut field label (as they are under groupings that show their table name) // - ///////////////////////////////////////////////////////////////////////////////////////// - if(option && option.field && option.table) - { - if(option.table.name == tableMetaData.name) - { - return (option.field.label); - } - else - { - return (option.table.label + ": " + option.field.label); - } - } - - return (""); - } - - ////////////////////////////////////////////////////////////////////////////////////////////// - // for options, we only want the field label (contrast with what we show in the input box, // - // which comes out of getFieldOptionLabel, which is the table-label prefix for join fields) // - ////////////////////////////////////////////////////////////////////////////////////////////// - function renderFieldOption(props: React.HTMLAttributes, option: any, state: AutocompleteRenderOptionState): ReactNode - { - let label = "" - if(option && option.field) - { - label = (option.field.label); - } - - return (
  • {label}
  • ); - } - function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption) { return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues)); } - let criteriaIsValid = true; - let criteriaStatusTooltip = "This condition is fully defined and is part of your filter."; + const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue); - function isNotSet(value: any) - { - return (value === null || value == undefined || String(value).trim() === ""); - } - - if(!criteria.fieldName) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must select a field to begin to define this condition."; - } - else if(!criteria.operator) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must select an operator to continue to define this condition."; - } - else - { - if(operatorSelectedValue) - { - if (operatorSelectedValue.valueMode == ValueMode.NONE || operatorSelectedValue.implicitValues) - { - ////////////////////////////////// - // don't need to look at values // - ////////////////////////////////// - } - else if(operatorSelectedValue.valueMode == ValueMode.DOUBLE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE_TIME) - { - if(criteria.values.length < 2 || isNotSet(criteria.values[0]) || isNotSet(criteria.values[1])) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must enter two values to complete the definition of this condition."; - } - } - else if(operatorSelectedValue.valueMode == ValueMode.MULTI || operatorSelectedValue.valueMode == ValueMode.PVS_MULTI) - { - if(criteria.values.length < 1 || isNotSet(criteria.values[0])) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition."; - } - } - else - { - if(!criteria.values || isNotSet(criteria.values[0])) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must enter a value to complete the definition of this condition."; - } - } - } - } + const tooltipEnterDelay = 750; return ( - + - + close @@ -502,24 +448,10 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, : } - ()} - // @ts-ignore - defaultValue={defaultFieldValue} - options={fieldOptions} - onChange={handleFieldChange} - isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)} - groupBy={fieldsGroupBy} - getOptionLabel={(option) => getFieldOptionLabel(option)} - renderOption={(props, option, state) => renderFieldOption(props, option, state)} - autoSelect={true} - autoHighlight={true} - slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} - /> + - + ()} @@ -546,8 +478,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)} /> - - + + { criteriaIsValid ? check diff --git a/src/qqq/components/query/FilterCriteriaRowValues.tsx b/src/qqq/components/query/FilterCriteriaRowValues.tsx index 51cfa3a..90806be 100644 --- a/src/qqq/components/query/FilterCriteriaRowValues.tsx +++ b/src/qqq/components/query/FilterCriteriaRowValues.tsx @@ -44,9 +44,13 @@ interface Props field: QFieldMetaData; table: QTableMetaData; valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void; + initiallyOpenMultiValuePvs?: boolean } -FilterCriteriaRowValues.defaultProps = {}; +FilterCriteriaRowValues.defaultProps = + { + initiallyOpenMultiValuePvs: false + }; export const getTypeForTextField = (field: QFieldMetaData): string => { @@ -110,16 +114,17 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth + autoFocus={true} />; }; -function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler}: Props): JSX.Element +function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler, initiallyOpenMultiValuePvs}: Props): JSX.Element { const [, forceUpdate] = useReducer((x) => x + 1, 0); if (!operatorOption) { - return
    ; + return null; } function saveNewPasterValues(newValues: any[]) @@ -148,7 +153,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC switch (operatorOption.valueMode) { case ValueMode.NONE: - return
    ; + return null; case ValueMode.SINGLE: return makeTextField(field, criteria, valueChangeHandler); case ValueMode.SINGLE_DATE: @@ -241,6 +246,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC isMultiple fieldLabel="Values" initialValues={initialValues} + initiallyOpen={false /*initiallyOpenMultiValuePvs*/} inForm={false} onChange={(value: any) => valueChangeHandler(null, "all", value)} variant="standard" diff --git a/src/qqq/components/query/QuickFilter.tsx b/src/qqq/components/query/QuickFilter.tsx new file mode 100644 index 0000000..4a08f1b --- /dev/null +++ b/src/qqq/components/query/QuickFilter.tsx @@ -0,0 +1,373 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {Badge, Tooltip} from "@mui/material"; +import Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; +import Menu from "@mui/material/Menu"; +import TextField from "@mui/material/TextField"; +import React, {SyntheticEvent, useState} from "react"; +import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; +import {getDefaultCriteriaValue, getOperatorOptions, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow"; +import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; +import TableUtils from "qqq/utils/qqq/TableUtils"; + +type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex"; + +interface QuickFilterProps +{ + tableMetaData: QTableMetaData; + fullFieldName: string; + fieldMetaData: QFieldMetaData; + criteriaParam: CriteriaParamType; + updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean, doRemoveCriteria: boolean) => void; + defaultOperator?: QCriteriaOperator; + toggleQuickFilterField?: (fieldName: string) => void; +} + +const criteriaParamIsCriteria = (param: CriteriaParamType): boolean => +{ + return (param != null && param != "tooComplex"); +}; + +QuickFilter.defaultProps = + { + defaultOperator: QCriteriaOperator.EQUALS, + toggleQuickFilterField: null + }; + +let seedId = new Date().getTime() % 173237; + +const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption => +{ + if(criteria) + { + const filteredOptions = operatorOptions.filter(o => o.value == criteria.operator); + if(filteredOptions.length > 0) + { + return (filteredOptions[0]); + } + } + + const filteredOptions = operatorOptions.filter(o => o.value == defaultOperator); + if(filteredOptions.length > 0) + { + return (filteredOptions[0]); + } + + return (null); +} + +export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, toggleQuickFilterField}: QuickFilterProps): JSX.Element +{ + const operatorOptions = fieldMetaData ? getOperatorOptions(tableMetaData, fullFieldName) : []; + const tableForField = tableMetaData; // todo!! const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName); + + const [isOpen, setIsOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + + const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? criteriaParam as QFilterCriteriaWithId : null); + const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId); + + const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator)); + const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label); + + const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator); + if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue)) + { + setOperatorSelectedValue(maybeNewOperatorSelectedValue) + setOperatorInputValue(maybeNewOperatorSelectedValue.label) + } + + if(!fieldMetaData) + { + return (null); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // handle a change to the criteria from outside this component (e.g., the prop isn't the same as the state) // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (criteriaParamIsCriteria(criteriaParam) && JSON.stringify(criteriaParam) !== JSON.stringify(criteria)) + { + const newCriteria = criteriaParam as QFilterCriteriaWithId; + setCriteria(newCriteria); + const operatorOption = operatorOptions.filter(o => o.value == newCriteria.operator)[0]; + setOperatorSelectedValue(operatorOption); + setOperatorInputValue(operatorOption.label); + } + + const criteriaNeedsReset = (): boolean => + { + if(criteria != null && criteriaParam == null) + { + const defaultOperatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0]; + if(criteria.operator !== defaultOperatorOption.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue())) + { + return (true); + } + } + + return (false); + } + + const makeNewCriteria = (): QFilterCriteria => + { + const operatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0]; + const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption.value, getDefaultCriteriaValue()); + criteria.id = id; + setOperatorSelectedValue(operatorOption); + setOperatorInputValue(operatorOption.label); + setCriteria(criteria); + return(criteria); + } + + if (criteria == null || criteriaNeedsReset()) + { + makeNewCriteria(); + } + + const toggleOpen = (event: any) => + { + setIsOpen(!isOpen); + setAnchorEl(event.currentTarget); + }; + + const closeMenu = () => + { + setIsOpen(false); + setAnchorEl(null); + }; + + ///////////////////////////////////////////// + // event handler for operator Autocomplete // + // todo - too dupe? + ///////////////////////////////////////////// + const handleOperatorChange = (event: any, newValue: any, reason: string) => + { + criteria.operator = newValue ? newValue.value : null; + + if (newValue) + { + setOperatorSelectedValue(newValue); + setOperatorInputValue(newValue.label); + + if (newValue.implicitValues) + { + criteria.values = newValue.implicitValues; + } + } + else + { + setOperatorSelectedValue(null); + setOperatorInputValue(""); + } + + updateCriteria(criteria, false, false); + }; + + function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption) + { + return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues)); + } + + ////////////////////////////////////////////////// + // event handler for value field (of all types) // + // todo - too dupe! + ////////////////////////////////////////////////// + const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) => + { + // @ts-ignore + const value = newValue !== undefined ? newValue : event ? event.target.value : null; + + if (!criteria.values) + { + criteria.values = []; + } + + if (valueIndex == "all") + { + criteria.values = value; + } + else + { + criteria.values[valueIndex] = value; + } + + updateCriteria(criteria, true, false); + }; + + const noop = () => + { + }; + + const getValuesString = (): string => + { + let valuesString = ""; + if (criteria.values && criteria.values.length) + { + let labels = [] as string[]; + + let maxLoops = criteria.values.length; + if (maxLoops > 5) + { + maxLoops = 3; + } + + for (let i = 0; i < maxLoops; i++) + { + if (criteria.values[i] && criteria.values[i].label) + { + labels.push(criteria.values[i].label); + } + else + { + labels.push(criteria.values[i]); + } + } + + if (maxLoops < criteria.values.length) + { + labels.push(" and " + (criteria.values.length - maxLoops) + " other values."); + } + + valuesString = (labels.join(", ")); + } + return valuesString; + } + + const [startIconName, setStartIconName] = useState("filter_alt"); + const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue); + + const resetCriteria = (e: React.MouseEvent) => + { + if(criteriaIsValid) + { + e.stopPropagation(); + const newCriteria = makeNewCriteria(); + updateCriteria(newCriteria, false, true); + setStartIconName("filter_alt"); + } + } + + const startIconMouseOver = () => + { + if(criteriaIsValid) + { + setStartIconName("clear"); + } + } + const startIconMouseOut = () => + { + setStartIconName("filter_alt"); + } + + const tooComplex = criteriaParam == "tooComplex"; + const tooltipEnterDelay = 500; + let startIcon = {startIconName} + if(criteriaIsValid) + { + startIcon = {startIcon} + } + + let buttonContent = {tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label} + if (criteriaIsValid) + { + buttonContent = ( + + {buttonContent} + + ); + } + + let button = fieldMetaData && ; + + if (tooComplex) + { + // wrap button in span, so disabled button doesn't cause disabled tooltip + return ( + + {button} + + ); + } + + const doToggle = () => + { + closeMenu() + toggleQuickFilterField(criteria?.fieldName); + } + + const widthAndMaxWidth = 250 + return ( + <> + {button} + { + isOpen && + { + toggleQuickFilterField && + + highlight_off + + } + + ()} + options={operatorOptions} + value={operatorSelectedValue as any} + inputValue={operatorInputValue} + onChange={handleOperatorChange} + onInputChange={(e, value) => setOperatorInputValue(value)} + isOptionEqualToValue={(option, value) => isOperatorOptionEqual(option, value)} + getOptionLabel={(option: any) => option.label} + autoSelect={true} + autoHighlight={true} + disableClearable + slotProps={{popper: {style: {padding: 0, maxHeight: "unset", width: "250px"}}}} + /> + + + handleValueChange(event, valueIndex, newValue)} + initiallyOpenMultiValuePvs={true} // todo - maybe not? + /> + + + } + + ); +} diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 3f7cbba..3950859 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -22,6 +22,7 @@ import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability"; 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 {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; @@ -29,6 +30,8 @@ import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTa import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete"; import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; +import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {Alert, Collapse, TablePagination, Typography} from "@mui/material"; import Autocomplete from "@mui/material/Autocomplete"; @@ -50,7 +53,7 @@ import MenuItem from "@mui/material/MenuItem"; import Modal from "@mui/material/Modal"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector, useGridApiRef, GridPreferencePanelsValue, GridColumnResizeParams} from "@mui/x-data-grid-pro"; +import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector, useGridApiRef, GridPreferencePanelsValue} from "@mui/x-data-grid-pro"; import {GridRowModel} from "@mui/x-data-grid/models/gridRows"; import FormData from "form-data"; import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; @@ -58,10 +61,12 @@ import {useLocation, useNavigate, useSearchParams} from "react-router-dom"; import QContext from "QContext"; import {QActionsMenuButton, QCancelButton, QCreateNewButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import MenuButton from "qqq/components/buttons/MenuButton"; +import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import {GotoRecordButton} from "qqq/components/misc/GotoRecordDialog"; import SavedFilters from "qqq/components/misc/SavedFilters"; import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; -import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; +import {CustomFilterPanel, QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; +import QuickFilter from "qqq/components/query/QuickFilter"; import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import BaseLayout from "qqq/layouts/BaseLayout"; import ProcessRun from "qqq/pages/processes/ProcessRun"; @@ -83,6 +88,7 @@ const COLUMN_ORDERING_LOCAL_STORAGE_KEY_ROOT = "qqq.columnOrdering"; const COLUMN_WIDTHS_LOCAL_STORAGE_KEY_ROOT = "qqq.columnWidths"; const SEEN_JOIN_TABLES_LOCAL_STORAGE_KEY_ROOT = "qqq.seenJoinTables"; const DENSITY_LOCAL_STORAGE_KEY_ROOT = "qqq.density"; +const QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT = "qqq.quickFilterFieldNames"; export const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant"; @@ -98,6 +104,7 @@ RecordQuery.defaultProps = { }; const qController = Client.getInstance(); +let debounceTimeout: string | number | NodeJS.Timeout; function RecordQuery({table, launchProcess}: Props): JSX.Element { @@ -144,6 +151,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const columnVisibilityLocalStorageKey = `${COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; const filterLocalStorageKey = `${FILTER_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; + const quickFilterFieldNamesLocalStorageKey = `${QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; let defaultSort = [] as GridSortItem[]; let defaultVisibility = {} as { [index: string]: boolean }; let didDefaultVisibilityComeFromLocalStorage = false; @@ -154,6 +162,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element let defaultColumnWidths = {} as {[fieldName: string]: number}; let seenJoinTables: {[tableName: string]: boolean} = {}; let defaultTableVariant: QTableVariant = null; + let defaultQuickFilterFieldNames: Set = new Set(); //////////////////////////////////////////////////////////////////////////////////// // set the to be not per table (do as above if we want per table) at a later port // @@ -197,6 +206,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { defaultTableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey)); } + if (localStorage.getItem(quickFilterFieldNamesLocalStorageKey)) + { + defaultQuickFilterFieldNames = new Set(JSON.parse(localStorage.getItem(quickFilterFieldNamesLocalStorageKey))); + } const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel); const [lastFetchedQFilterJSON, setLastFetchedQFilterJSON] = useState(""); @@ -253,6 +266,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [columnStatsFieldTableName, setColumnStatsFieldTableName] = useState(null as string) const [filterForColumnStats, setFilterForColumnStats] = useState(null as QQueryFilter); + const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null); + const [quickFilterFieldNames, setQuickFilterFieldNames] = useState(defaultQuickFilterFieldNames); + const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0); + const instance = useRef({timer: null}); //////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -1946,6 +1963,129 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } + 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); + const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); + handleFilterChange(gridFilterModel, false); + } + return; + } + + if(!found) + { + if(!queryFilter.criteria) + { + queryFilter.criteria = []; + } + queryFilter.criteria.push(newCriteria); + found = true; + } + + if(found) + { + clearTimeout(debounceTimeout) + debounceTimeout = setTimeout(() => + { + setQueryFilter(queryFilter); + const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); + handleFilterChange(gridFilterModel, false); + }, needDebounce ? 500 : 1); + + forceUpdate(); + } + }; + + + 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"; + } + } + + const toggleQuickFilterField = (fieldName: string): void => + { + if(quickFilterFieldNames.has(fieldName)) + { + quickFilterFieldNames.delete(fieldName); + } + else + { + quickFilterFieldNames.add(fieldName); + } + setQuickFilterFieldNames(new Set([...quickFilterFieldNames.values()])) + localStorage.setItem(quickFilterFieldNamesLocalStorageKey, JSON.stringify([...quickFilterFieldNames.values()])); + + // damnit, not auto-updating in the filter panel... have to click twice most of the time w/o this hacky hack. + setTimeout(() => forceUpdate(), 10); + } + + const openAddQuickFilterMenu = (event: any) => + { + setAddQuickFilterMenu(event.currentTarget); + setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1); + } + + const closeAddQuickFilterMenu = () => + { + setAddQuickFilterMenu(null); + } + + function addQuickFilterField(event: any, newValue: any, reason: string) + { + if(reason == "blur") + { + ////////////////////////////////////////////////////////////////// + // this keeps a click out of the menu from selecting the option // + ////////////////////////////////////////////////////////////////// + return; + } + + const fieldName = newValue ? newValue.fieldName : null + if(fieldName) + { + toggleQuickFilterField(fieldName); + closeAddQuickFilterMenu(); + } + } + return (
    @@ -2010,6 +2150,74 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } + + + + Fields that are frequently used for filter conditions can be added here for quick access.

    + Use the add_circle_outline button to add a field.

    + To remove a field, click it and then use the highlight_off button. +
    } placement="left"> + Quick Filter: + + { + metaData && tableMetaData && + <> + + openAddQuickFilterMenu(e)} size="small" disableRipple>add_circle_outline + + + + + + + + } + { + tableMetaData && + [...quickFilterFieldNames.values()].map((fieldName) => + { + // todo - join fields... + // todo - sometimes i want contains (client.name, for example...) + + const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS + if(field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE) + { + defaultOperator = QCriteriaOperator.GREATER_THAN; + } + + return ( + field && + ) + }) + } + +