Adding possible-value dropdowns to forms and filters

This commit is contained in:
2022-10-11 08:26:43 -05:00
parent 0e6c20d6d6
commit 0e32acce21
11 changed files with 828 additions and 147 deletions

View File

@ -0,0 +1,320 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. 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 {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {TextFieldProps} from "@mui/material";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import TextField from "@mui/material/TextField";
import {getGridNumericOperators, getGridStringOperators, GridColDef, GridFilterInputValueProps, GridFilterItem} from "@mui/x-data-grid-pro";
import {GridFilterInputValue} from "@mui/x-data-grid/components/panel/filterPanel/GridFilterInputValue";
import {GridApiCommunity} from "@mui/x-data-grid/internals";
import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator";
import React, {useEffect, useRef, useState} from "react";
import QDynamicSelect from "qqq/components/QDynamicSelect/QDynamicSelect";
//////////////////////
// string operators //
//////////////////////
const stringNotEqualsOperator: GridFilterOperator = {
label: "does not equal",
value: "isNot",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: GridFilterInputValue,
};
const stringNotContainsOperator: GridFilterOperator = {
label: "does not contain",
value: "notContains",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: GridFilterInputValue,
};
const stringNotStartsWithOperator: GridFilterOperator = {
label: "does not start with",
value: "notStartsWith",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: GridFilterInputValue,
};
const stringNotEndWithOperator: GridFilterOperator = {
label: "does not end with",
value: "notEndsWith",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: GridFilterInputValue,
};
let gridStringOperators = getGridStringOperators();
let equals = gridStringOperators.splice(1, 1)[0];
let contains = gridStringOperators.splice(0, 1)[0];
let startsWith = gridStringOperators.splice(0, 1)[0];
let endsWith = gridStringOperators.splice(0, 1)[0];
gridStringOperators = [ equals, stringNotEqualsOperator, contains, stringNotContainsOperator, startsWith, stringNotStartsWithOperator, endsWith, stringNotEndWithOperator, ...gridStringOperators ];
export const QGridStringOperators = gridStringOperators;
///////////////////////////////////////
// input element for numbers-between //
///////////////////////////////////////
function InputNumberInterval(props: GridFilterInputValueProps)
{
const SUBMIT_FILTER_STROKE_TIME = 500;
const {item, applyValue, focusElementRef = null} = props;
const filterTimeout = useRef<any>();
const [ filterValueState, setFilterValueState ] = useState<[ string, string ]>(
item.value ?? "",
);
const [ applying, setIsApplying ] = useState(false);
useEffect(() =>
{
return () =>
{
clearTimeout(filterTimeout.current);
};
}, []);
useEffect(() =>
{
const itemValue = item.value ?? [ undefined, undefined ];
setFilterValueState(itemValue);
}, [ item.value ]);
const updateFilterValue = (lowerBound: string, upperBound: string) =>
{
clearTimeout(filterTimeout.current);
setFilterValueState([ lowerBound, upperBound ]);
setIsApplying(true);
filterTimeout.current = setTimeout(() =>
{
setIsApplying(false);
applyValue({...item, value: [ lowerBound, upperBound ]});
}, SUBMIT_FILTER_STROKE_TIME);
};
const handleUpperFilterChange: TextFieldProps["onChange"] = (event) =>
{
const newUpperBound = event.target.value;
updateFilterValue(filterValueState[0], newUpperBound);
};
const handleLowerFilterChange: TextFieldProps["onChange"] = (event) =>
{
const newLowerBound = event.target.value;
updateFilterValue(newLowerBound, filterValueState[1]);
};
return (
<Box
sx={{
display: "inline-flex",
flexDirection: "row",
alignItems: "end",
height: 48,
pl: "20px",
}}
>
<TextField
name="lower-bound-input"
placeholder="From"
label="From"
variant="standard"
value={Number(filterValueState[0])}
onChange={handleLowerFilterChange}
type="number"
inputRef={focusElementRef}
sx={{mr: 2}}
/>
<TextField
name="upper-bound-input"
placeholder="To"
label="To"
variant="standard"
value={Number(filterValueState[1])}
onChange={handleUpperFilterChange}
type="number"
InputProps={applying ? {endAdornment: <Icon>sync</Icon>} : {}}
/>
</Box>
);
}
//////////////////////
// number operators //
//////////////////////
const betweenOperator: GridFilterOperator = {
label: "is between",
value: "between",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: InputNumberInterval
};
const notBetweenOperator: GridFilterOperator = {
label: "is not between",
value: "notBetween",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: InputNumberInterval
};
export const QGridNumericOperators = [ ...getGridNumericOperators(), betweenOperator, notBetweenOperator ];
///////////////////////
// boolean operators //
///////////////////////
const booleanTrueOperator: GridFilterOperator = {
label: "is yes",
value: "isTrue",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
const booleanFalseOperator: GridFilterOperator = {
label: "is no",
value: "isFalse",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
const booleanEmptyOperator: GridFilterOperator = {
label: "is empty",
value: "isEmpty",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
const booleanNotEmptyOperator: GridFilterOperator = {
label: "is not empty",
value: "isNotEmpty",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
export const QGridBooleanOperators = [ booleanTrueOperator, booleanFalseOperator, booleanEmptyOperator, booleanNotEmptyOperator ];
///////////////////////////////////////
// input element for possible values //
///////////////////////////////////////
function InputPossibleValueSourceSingle(tableName: string, field: QFieldMetaData, props: GridFilterInputValueProps)
{
const SUBMIT_FILTER_STROKE_TIME = 500;
const {item, applyValue, focusElementRef = null} = props;
console.log("Item.value? " + item.value);
const filterTimeout = useRef<any>();
const [ filterValueState, setFilterValueState ] = useState<any>(item.value ?? null);
const [ selectedPossibleValue, setSelectedPossibleValue ] = useState((item.value ?? null) as QPossibleValue);
const [ applying, setIsApplying ] = useState(false);
useEffect(() =>
{
return () =>
{
clearTimeout(filterTimeout.current);
};
}, []);
useEffect(() =>
{
const itemValue = item.value ?? null;
setFilterValueState(itemValue);
}, [ item.value ]);
const updateFilterValue = (value: QPossibleValue) =>
{
clearTimeout(filterTimeout.current);
setFilterValueState(value);
setIsApplying(true);
filterTimeout.current = setTimeout(() =>
{
setIsApplying(false);
applyValue({...item, value: value});
}, SUBMIT_FILTER_STROKE_TIME);
};
const handleChange = (value: QPossibleValue) =>
{
updateFilterValue(value);
};
return (
<Box
sx={{
display: "inline-flex",
flexDirection: "row",
alignItems: "end",
height: 48,
}}
>
<QDynamicSelect
tableName={tableName}
fieldName={field.name}
fieldLabel="Value"
initialValue={selectedPossibleValue?.id}
initialDisplayValue={selectedPossibleValue?.label}
inForm={false}
onChange={handleChange}
// InputProps={applying ? {endAdornment: <Icon>sync</Icon>} : {}}
/>
</Box>
);
}
//////////////////////////////////
// possible value set operators //
//////////////////////////////////
export const buildQGridPvsOperators = (tableName: string, field: QFieldMetaData): GridFilterOperator[] =>
{
return ([
{
label: "is",
value: "is",
getApplyFilterFn: () => null,
InputComponent: (props: GridFilterInputValueProps<GridApiCommunity>) => InputPossibleValueSourceSingle(tableName, field, props)
},
{
label: "is not",
value: "isNot",
getApplyFilterFn: () => null,
InputComponent: (props: GridFilterInputValueProps<GridApiCommunity>) => InputPossibleValueSourceSingle(tableName, field, props)
},
{
label: "is empty",
value: "isEmpty",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
},
{
label: "is not empty",
value: "isNotEmpty",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
}
]);
};

View File

@ -21,7 +21,6 @@
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
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";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
@ -39,14 +38,16 @@ import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Modal from "@mui/material/Modal";
import {
DataGridPro, getGridDateOperators, getGridNumericOperators, getGridStringOperators,
DataGridPro,
getGridDateOperators,
getGridNumericOperators,
GridCallbackDetails,
GridColDef,
GridColumnOrderChangeParams,
GridColumnVisibilityModel,
GridExportMenuItemProps,
GridFilterItem,
GridFilterModel,
GridLinkOperator,
GridRowId,
GridRowParams,
GridRowsProp,
@ -70,6 +71,7 @@ import Navbar from "qqq/components/Navbar";
import {QActionsMenuButton, QCreateNewButton} from "qqq/components/QButtons";
import MDAlert from "qqq/components/Temporary/MDAlert";
import MDBox from "qqq/components/Temporary/MDBox";
import {buildQGridPvsOperators, QGridBooleanOperators, QGridNumericOperators, QGridStringOperators} from "qqq/pages/entity-list/QGridFilterOperators";
import ProcessRun from "qqq/pages/process-run";
import QClient from "qqq/utils/QClient";
import QFilterUtils from "qqq/utils/QFilterUtils";
@ -92,11 +94,13 @@ EntityList.defaultProps = {
launchProcess: null
};
const qController = QClient.getInstance();
/*******************************************************************************
** Get the default filter to use on the page - either from query string, or
** local storage, or a default (empty).
*******************************************************************************/
function getDefaultFilter(tableMetaData: QTableMetaData, searchParams: URLSearchParams, filterLocalStorageKey: string): GridFilterModel
async function getDefaultFilter(tableMetaData: QTableMetaData, searchParams: URLSearchParams, filterLocalStorageKey: string): Promise<GridFilterModel>
{
if (tableMetaData.fields !== undefined)
{
@ -111,16 +115,39 @@ function getDefaultFilter(tableMetaData: QTableMetaData, searchParams: URLSearch
//////////////////////////////////////////////////////////////////
const defaultFilter = {items: []} as GridFilterModel;
let id = 1;
qQueryFilter.criteria.forEach((criteria) =>
for(let i = 0; i < qQueryFilter.criteria.length; i++)
{
const fieldType = tableMetaData.fields.get(criteria.fieldName).type;
const criteria = qQueryFilter.criteria[i];
const field = tableMetaData.fields.get(criteria.fieldName);
let values = criteria.values;
if(field.possibleValueSourceName)
{
//////////////////////////////////////////////////////////////////////////////////
// possible-values in query-string are expected to only be their id values. //
// e.g., ...values=[1]... //
// but we need them to be possibleValue objects (w/ id & label) so the label //
// can be shown in the filter dropdown. So, make backend call to look them up. //
//////////////////////////////////////////////////////////////////////////////////
if(values && values.length > 0)
{
values = await qController.possibleValues(tableMetaData.name, field.name, "", values);
}
}
defaultFilter.items.push({
columnField: criteria.fieldName,
operatorValue: QFilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, fieldType, criteria.values),
value: QFilterUtils.qqqCriteriaValuesToGrid(criteria.operator, criteria.values, fieldType),
operatorValue: QFilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, values),
value: QFilterUtils.qqqCriteriaValuesToGrid(criteria.operator, values, field),
id: id++, // not sure what this id is!!
});
});
}
defaultFilter.linkOperator = GridLinkOperator.And;
if(qQueryFilter.booleanOperator === "OR")
{
defaultFilter.linkOperator = GridLinkOperator.Or;
}
return (defaultFilter);
}
@ -145,7 +172,6 @@ function EntityList({table, launchProcess}: Props): JSX.Element
{
const tableName = table.name;
const [searchParams] = useSearchParams();
const qController = QClient.getInstance();
const location = useLocation();
const navigate = useNavigate();
@ -270,8 +296,11 @@ function EntityList({table, launchProcess}: Props): JSX.Element
setActiveModalProcess(null);
}, [location]);
const buildQFilter = () =>
const buildQFilter = (filterModel: GridFilterModel) =>
{
console.log("Building q filter with model:");
console.log(filterModel);
const qFilter = new QQueryFilter();
if (columnSortModel)
{
@ -280,6 +309,7 @@ function EntityList({table, launchProcess}: Props): JSX.Element
qFilter.addOrderBy(new QFilterOrderBy(gridSortItem.field, gridSortItem.sort === "asc"));
});
}
if (filterModel)
{
filterModel.items.forEach((item) =>
@ -288,6 +318,15 @@ function EntityList({table, launchProcess}: Props): JSX.Element
const values = QFilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue);
qFilter.addCriteria(new QFilterCriteria(item.columnField, operator, values));
});
qFilter.booleanOperator = "AND";
if(filterModel.linkOperator == "or")
{
///////////////////////////////////////////////////////////////////////////////////////////
// by default qFilter uses AND - so only if we see linkOperator=or do we need to set it //
///////////////////////////////////////////////////////////////////////////////////////////
qFilter.booleanOperator = "OR";
}
}
return qFilter;
@ -307,10 +346,12 @@ function EntityList({table, launchProcess}: Props): JSX.Element
// because we need to know field types to translate qqq filter to material filter //
// return here ane wait for the next 'turn' to allow doing the actual query //
////////////////////////////////////////////////////////////////////////////////////////////////
let localFilterModel = filterModel;
if (!defaultFilterLoaded)
{
setDefaultFilterLoaded(true);
setFilterModel(getDefaultFilter(tableMetaData, searchParams, filterLocalStorageKey));
localFilterModel = await getDefaultFilter(tableMetaData, searchParams, filterLocalStorageKey)
setFilterModel(localFilterModel);
return;
}
setTableMetaData(tableMetaData);
@ -325,7 +366,7 @@ function EntityList({table, launchProcess}: Props): JSX.Element
}
setPinnedColumns({left: ["__check__", tableMetaData.primaryKeyField]});
const qFilter = buildQFilter();
const qFilter = buildQFilter(localFilterModel);
//////////////////////////////////////////////////////////////////////////////////////////////////
// assign a new query id to the query being issued here. then run both the count & query async //
@ -394,58 +435,6 @@ function EntityList({table, launchProcess}: Props): JSX.Element
delete countResults[latestQueryId];
}, [receivedCountTimestamp]);
const betweenOperator =
{
label: "Between",
value: "between",
getApplyFilterFn: (filterItem: GridFilterItem) =>
{
if (!Array.isArray(filterItem.value) || filterItem.value.length !== 2)
{
return null;
}
if (filterItem.value[0] == null || filterItem.value[1] == null)
{
return null;
}
// @ts-ignore
return ({value}) =>
{
return (value !== null && filterItem.value[0] <= value && value <= filterItem.value[1]);
};
},
// InputComponent: InputNumberInterval,
};
const booleanTrueOperator: GridFilterOperator = {
label: "is yes",
value: "isTrue",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
const booleanFalseOperator: GridFilterOperator = {
label: "is no",
value: "isFalse",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
const booleanEmptyOperator: GridFilterOperator = {
label: "is empty",
value: "isEmpty",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
const booleanNotEmptyOperator: GridFilterOperator = {
label: "is not empty",
value: "isNotEmpty",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
const getCustomGridBooleanOperators = (): GridFilterOperator[] =>
{
return [booleanTrueOperator, booleanFalseOperator, booleanEmptyOperator, booleanNotEmptyOperator];
};
///////////////////////////
// display query results //
@ -503,11 +492,11 @@ function EntityList({table, launchProcess}: Props): JSX.Element
let columnType = "string";
let columnWidth = 200;
let filterOperators: GridFilterOperator<any>[] = getGridStringOperators();
let filterOperators: GridFilterOperator<any>[] = QGridStringOperators;
if (field.possibleValueSourceName)
{
filterOperators = getGridNumericOperators();
filterOperators = buildQGridPvsOperators(tableName, field);
}
else
{
@ -523,8 +512,7 @@ function EntityList({table, launchProcess}: Props): JSX.Element
columnWidth = 75;
}
// @ts-ignore
filterOperators = getGridNumericOperators();
filterOperators = QGridNumericOperators;
break;
case QFieldType.DATE:
columnType = "date";
@ -539,7 +527,7 @@ function EntityList({table, launchProcess}: Props): JSX.Element
case QFieldType.BOOLEAN:
columnType = "string"; // using boolean gives an odd 'no' for nulls.
columnWidth = 75;
filterOperators = getCustomGridBooleanOperators();
filterOperators = QGridBooleanOperators;
break;
default:
// noop - leave as string
@ -549,33 +537,20 @@ function EntityList({table, launchProcess}: Props): JSX.Element
if (field.hasAdornment(AdornmentType.SIZE))
{
const sizeAdornment = field.getAdornment(AdornmentType.SIZE);
const width = sizeAdornment.getValue("width");
switch (width)
const width: string = sizeAdornment.getValue("width");
const widths: Map<string, number> = new Map<string, number>([
["small", 100],
["medium", 200],
["large", 400],
["xlarge", 600]
]);
if(widths.has(width))
{
case "small":
{
columnWidth = 100;
break;
}
case "medium":
{
columnWidth = 200;
break;
}
case "large":
{
columnWidth = 400;
break;
}
case "xlarge":
{
columnWidth = 600;
break;
}
default:
{
console.log("Unrecognized size.width adornment value: " + width);
}
columnWidth = widths.get(width);
}
else
{
console.log("Unrecognized size.width adornment value: " + width);
}
}
@ -814,7 +789,7 @@ function EntityList({table, launchProcess}: Props): JSX.Element
const d = new Date();
const dateString = `${d.getFullYear()}-${zp(d.getMonth())}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`;
const filename = `${tableMetaData.label} Export ${dateString}.${format}`;
const url = `/data/${tableMetaData.name}/export/${filename}?filter=${encodeURIComponent(JSON.stringify(buildQFilter()))}&fields=${visibleFields.join(",")}`;
const url = `/data/${tableMetaData.name}/export/${filename}?filter=${encodeURIComponent(JSON.stringify(buildQFilter(filterModel)))}&fields=${visibleFields.join(",")}`;
//////////////////////////////////////////////////////////////////////////////////////
// open a window (tab) with a little page that says the file is being generated. //
@ -864,7 +839,7 @@ function EntityList({table, launchProcess}: Props): JSX.Element
{
if (selectFullFilterState === "filter")
{
return `?recordsParam=filterJSON&filterJSON=${JSON.stringify(buildQFilter())}`;
return `?recordsParam=filterJSON&filterJSON=${JSON.stringify(buildQFilter(filterModel))}`;
}
if (selectedIds.length > 0)
@ -879,7 +854,7 @@ function EntityList({table, launchProcess}: Props): JSX.Element
{
if (selectFullFilterState === "filter")
{
return (buildQFilter());
return (buildQFilter(filterModel));
}
if (selectedIds.length > 0)