Initial build of quick-filters on query screen

This commit is contained in:
2023-09-25 14:21:09 -05:00
parent 16a0970d25
commit 6b13a1f3dd
6 changed files with 833 additions and 154 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<HTMLLIElement>, option: any, state: AutocompleteRenderOptionState): ReactNode
{
let label = "";
if (option && option.field)
{
label = (option.field.label);
}
return (<li {...props}>{label}</li>);
}
function isFieldOptionEqual(option: any, value: any)
{
return option.fieldName === value.fieldName;
}
return (
<Autocomplete
id={id}
renderInput={(params) => (<TextField {...params} autoFocus={autoFocus} label={"Field"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
// @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"}}}}
/>
);
}

View File

@ -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<HTMLLIElement>, option: any, state: AutocompleteRenderOptionState): ReactNode
{
let label = ""
if(option && option.field)
{
label = (option.field.label);
}
return (<li {...props}>{label}</li>);
}
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 (
<Box className="filterCriteriaRow" pt={0.5} display="flex" alignItems="flex-end">
<Box className="filterCriteriaRow" pt={0.5} display="flex" alignItems="flex-end" pr={0.5}>
<Box display="inline-block">
<Tooltip title="Remove this condition from your filter" enterDelay={750} placement="left">
<Tooltip title="Remove this condition from your filter" enterDelay={tooltipEnterDelay} placement="left">
<IconButton onClick={removeCriteria}><Icon fontSize="small">close</Icon></IconButton>
</Tooltip>
</Box>
@ -502,24 +448,10 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
: <span />}
</Box>
<Box display="inline-block" width={250} className="fieldColumn">
<Autocomplete
id={`field-${id}`}
renderInput={(params) => (<TextField {...params} label={"Field"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
// @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"}}}}
/>
<FieldAutoComplete id={`field-${id}`} metaData={metaData} tableMetaData={tableMetaData} defaultValue={defaultFieldValue} handleFieldChange={handleFieldChange} />
</Box>
<Box display="inline-block" width={200} className="operatorColumn">
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={750}>
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={tooltipEnterDelay}>
<Autocomplete
id={"criteriaOperator"}
renderInput={(params) => (<TextField {...params} label={"Operator"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
@ -546,8 +478,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
/>
</Box>
<Box display="inline-block" pl={0.5} pr={1}>
<Tooltip title={criteriaStatusTooltip} enterDelay={750} placement="right">
<Box display="inline-block">
<Tooltip title={criteriaStatusTooltip} enterDelay={tooltipEnterDelay} placement="bottom">
{
criteriaIsValid
? <Icon color="success">check</Icon>

View File

@ -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 <br />;
return null;
}
function saveNewPasterValues(newValues: any[])
@ -148,7 +153,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
switch (operatorOption.valueMode)
{
case ValueMode.NONE:
return <br />;
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"

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<HTMLSpanElement>) =>
{
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 = <Badge badgeContent={criteriaIsValid && !tooComplex ? 1 : 0} color="warning" variant="dot" onMouseOver={startIconMouseOver} onMouseOut={startIconMouseOut} onClick={resetCriteria}><Icon>{startIconName}</Icon></Badge>
if(criteriaIsValid)
{
startIcon = <Tooltip title={"Remove this condition"} enterDelay={tooltipEnterDelay}>{startIcon}</Tooltip>
}
let buttonContent = <span>{tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label}</span>
if (criteriaIsValid)
{
buttonContent = (
<Tooltip title={`${operatorSelectedValue.label} ${getValuesString()}`} enterDelay={tooltipEnterDelay}>
{buttonContent}
</Tooltip>
);
}
let button = fieldMetaData && <Button
sx={{mr: "0.25rem", px: "1rem", border: isOpen ? "1px solid gray" : "1px solid transparent"}}
startIcon={startIcon}
onClick={tooComplex ? noop : toggleOpen}
disabled={tooComplex}
>{buttonContent}</Button>;
if (tooComplex)
{
// wrap button in span, so disabled button doesn't cause disabled tooltip
return (
<Tooltip title={`Your current filter is too complex to do a Quick Filter on ${fieldMetaData.label}. Use the Filter button to edit.`} enterDelay={tooltipEnterDelay} slotProps={{popper: {sx: {top: "-0.75rem!important"}}}}>
<span>{button}</span>
</Tooltip>
);
}
const doToggle = () =>
{
closeMenu()
toggleQuickFilterField(criteria?.fieldName);
}
const widthAndMaxWidth = 250
return (
<>
{button}
{
isOpen && <Menu open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={closeMenu} sx={{overflow: "visible"}}>
{
toggleQuickFilterField &&
<Tooltip title={"Remove this field from Quick Filters"} placement="right">
<IconButton size="small" sx={{position: "absolute", top: "-8px", right: "-8px", zIndex: 1}} onClick={doToggle}><Icon color="secondary">highlight_off</Icon></IconButton>
</Tooltip>
}
<Box display="inline-block" width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="operatorColumn">
<Autocomplete
id={"criteriaOperator"}
renderInput={(params) => (<TextField {...params} label={"Operator"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
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"}}}}
/>
</Box>
<Box width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="quickFilter filterValuesColumn">
<FilterCriteriaRowValues
operatorOption={operatorSelectedValue}
criteria={criteria}
field={fieldMetaData}
table={tableMetaData} // todo - joins?
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
initiallyOpenMultiValuePvs={true} // todo - maybe not?
/>
</Box>
</Menu>
}
</>
);
}

View File

@ -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<string> = new Set<string>();
////////////////////////////////////////////////////////////////////////////////////
// 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<string>(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<string>([...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 (
<BaseLayout>
<div className="recordQuery">
@ -2010,6 +2150,74 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
<QCreateNewButton tablePath={metaData?.getTablePathByName(tableName)} />
}
</Box>
<Box display="flex" alignItems="center" flexWrap="wrap" position="relative" top={"-0.5rem"} left={"0.5rem"} minHeight="2.5rem">
<Tooltip enterDelay={1000} title={
<Box textAlign="left">
Fields that are frequently used for filter conditions can be added here for quick access.<br /><br />
Use the <Icon fontSize="medium" sx={{position: "relative", top: "0.5rem"}}>add_circle_outline</Icon> button to add a field.<br /><br />
To remove a field, click it and then use the <Icon fontSize="medium" sx={{position: "relative", top: "0.5rem"}}>highlight_off</Icon> button.
</Box>} placement="left">
<Typography variant="h6" sx={{cursor: "default"}}>Quick Filter:</Typography>
</Tooltip>
{
metaData && tableMetaData &&
<>
<Tooltip enterDelay={500} title="Add a Quick Filter field" placement="top">
<IconButton onClick={(e) => openAddQuickFilterMenu(e)} size="small" disableRipple><Icon>add_circle_outline</Icon></IconButton>
</Tooltip>
<Menu
anchorEl={addQuickFilterMenu}
anchorOrigin={{vertical: "bottom", horizontal: "center"}}
transformOrigin={{vertical: "top", horizontal: "left"}}
open={Boolean(addQuickFilterMenu)}
onClose={closeAddQuickFilterMenu}
keepMounted
>
<Box width="250px">
<FieldAutoComplete
key={addQuickFilterOpenCounter} // use a unique key each time we open it, because we don't want the user's last selection to stick.
id={"add-quick-filter-field"}
metaData={metaData}
tableMetaData={tableMetaData}
defaultValue={null}
handleFieldChange={addQuickFilterField}
autoFocus={true}
hiddenFieldNames={[...quickFilterFieldNames.values()]}
/>
</Box>
</Menu>
</>
}
{
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 && <QuickFilter
key={fieldName}
fullFieldName={fieldName}
tableMetaData={tableMetaData}
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
defaultOperator={defaultOperator}
toggleQuickFilterField={toggleQuickFilterField} />
)
})
}
</Box>
<Card>
<Box height="100%">
<DataGridPro
@ -2037,7 +2245,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
tableMetaData: tableMetaData,
metaData: metaData,
queryFilter: queryFilter,
updateFilter: updateFilterFromFilterPanel
updateFilter: updateFilterFromFilterPanel,
quickFilterFieldNames: quickFilterFieldNames,
showQuickFilterPin: true,
toggleQuickFilterField: toggleQuickFilterField,
}
}}
localeText={{

View File

@ -424,6 +424,13 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
top: -60px !important;
}
/* within the data-grid, the filter-panel's container has a max-height. mirror that, and set an overflow-y */
.MuiDataGrid-panel .customFilterPanel
{
max-height: 450px;
overflow-y: auto;
}
/* tighten the text in the field select dropdown in custom filters */
.customFilterPanel .MuiAutocomplete-paper
{
@ -487,7 +494,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
}
/* change tags in any-of value fields to not be black bg with white text */
.customFilterPanel .filterValuesColumn .MuiChip-root
.customFilterPanel .filterValuesColumn .MuiChip-root,
.quickFilter.filterValuesColumn .MuiChip-root
{
background: none;
color: black;
@ -495,20 +503,23 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
}
/* change 'x' icon in tags in any-of value */
.customFilterPanel .filterValuesColumn .MuiChip-root .MuiChip-deleteIcon
.customFilterPanel .filterValuesColumn .MuiChip-root .MuiChip-deleteIcon,
.quickFilter.filterValuesColumn .MuiChip-root .MuiChip-deleteIcon
{
color: gray;
}
/* change tags in any-of value fields to not be black bg with white text */
.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag
.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag,
.quickFilter.filterValuesColumn .MuiAutocomplete-tag
{
color: #191919;
background: none;
}
/* default hover color for the 'x' to remove a tag from an 'any-of' value was white, which made it disappear */
.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag .MuiSvgIcon-root:hover
.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag .MuiSvgIcon-root:hover,
.quickFilter.filterValuesColumn .MuiAutocomplete-tag .MuiSvgIcon-root:hover
{
color: lightgray;
}