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

@ -13,13 +13,13 @@
"@fullcalendar/interaction": "5.10.0",
"@fullcalendar/react": "5.10.0",
"@fullcalendar/timegrid": "5.10.0",
"@kingsrook/qqq-frontend-core": "1.0.24",
"@kingsrook/qqq-frontend-core": "1.0.25",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.4.1",
"@mui/styled-engine": "5.4.1",
"@mui/styles": "5.10.7",
"@mui/x-data-grid": "5.13.0",
"@mui/x-data-grid-pro": "5.13.0",
"@mui/x-data-grid": "5.17.6",
"@mui/x-data-grid-pro": "5.17.6",
"@mui/x-license-pro": "5.12.3",
"@react-jvectormap/core": "1.0.1",
"@react-jvectormap/unitedstates": "1.0.1",

View File

@ -20,6 +20,7 @@
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
@ -42,6 +43,7 @@ import MDBox from "qqq/components/Temporary/MDBox";
import MDTypography from "qqq/components/Temporary/MDTypography";
import QClient from "qqq/utils/QClient";
import QTableUtils from "qqq/utils/QTableUtils";
import QValueUtils from "qqq/utils/QValueUtils";
interface Props
{
@ -120,9 +122,10 @@ function EntityForm({table, id}: Props): JSX.Element
/////////////////////////////////////////////////////////////////////////////////
// if doing an edit, fetch the record and pre-populate the form values from it //
/////////////////////////////////////////////////////////////////////////////////
let record: QRecord = null;
if (id !== null)
{
const record = await qController.get(tableName, id);
record = await qController.get(tableName, id);
setRecord(record);
setFormTitle(`Edit ${tableMetaData?.label}: ${record?.recordLabel}`);
setPageHeader(`Edit ${tableMetaData?.label}: ${record?.recordLabel}`);
@ -130,6 +133,10 @@ function EntityForm({table, id}: Props): JSX.Element
tableMetaData.fields.forEach((fieldMetaData, key) =>
{
initialValues[key] = record.values.get(key);
if(fieldMetaData.type == QFieldType.DATE_TIME)
{
initialValues[key] = QValueUtils.formatDateTimeValueForForm(record.values.get(key));
}
});
setFormValues(formValues);
@ -173,6 +180,24 @@ function EntityForm({table, id}: Props): JSX.Element
{
sectionDynamicFormFields.push(dynamicFormFields[fieldName]);
}
/////////////////////////////////////////
// add props for possible value fields //
/////////////////////////////////////////
if(field.possibleValueSourceName)
{
let initialDisplayValue = null;
if(record && record.displayValues)
{
initialDisplayValue = record.displayValues.get(field.name);
}
dynamicFormFields[fieldName].possibleValueProps =
{
isPossibleValue: true,
tableName: tableName,
initialDisplayValue: initialDisplayValue,
};
}
}
if (sectionDynamicFormFields.length === 0)
@ -245,7 +270,9 @@ function EntityForm({table, id}: Props): JSX.Element
})
.catch((error) =>
{
setAlertContent(error.response.data.error);
console.log("Caught:");
console.log(error);
setAlertContent(error.message);
});
}
else
@ -259,7 +286,7 @@ function EntityForm({table, id}: Props): JSX.Element
})
.catch((error) =>
{
setAlertContent(error.response.data.error);
setAlertContent(error.message);
});
}
})();
@ -298,7 +325,7 @@ function EntityForm({table, id}: Props): JSX.Element
<Form id={formId} autoComplete="off">
<MDBox pb={3} pt={0}>
<Card id={`${t1sectionName}`} sx={{overflow: "visible"}}>
<Card id={`${t1sectionName}`} sx={{overflow: "visible", pb: 2, scrollMarginTop: "100px"}}>
<MDBox display="flex" p={3} pb={1}>
<MDBox mr={1.5}>
<Avatar sx={{bgcolor: colors.info.main}}>
@ -313,7 +340,7 @@ function EntityForm({table, id}: Props): JSX.Element
</MDBox>
{
t1sectionName && formFields ? (
<MDBox pb={3} px={3}>
<MDBox pb={1} px={3}>
<MDBox p={3} width="100%">
{getFormSection(values, touched, formFields.get(t1sectionName), errors)}
</MDBox>
@ -324,11 +351,11 @@ function EntityForm({table, id}: Props): JSX.Element
</MDBox>
{formFields && nonT1Sections.length ? nonT1Sections.map((section: QTableSection) => (
<MDBox key={`edit-card-${section.name}`} pb={3}>
<Card id={section.name} sx={{overflow: "visible"}}>
<Card id={section.name} sx={{overflow: "visible", scrollMarginTop: "100px"}}>
<MDTypography variant="h5" p={3} pb={1}>
{section.label}
</MDTypography>
<MDBox pb={3} px={3}>
<MDBox pb={1} px={3}>
<MDBox p={3} width="100%">
{
getFormSection(values, touched, formFields.get(section.name), errors)

View File

@ -19,13 +19,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {colors} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Grid from "@mui/material/Grid";
import {useFormikContext} from "formik";
import React, {useState} from "react";
import QDynamicFormField from "qqq/components/QDynamicFormField";
import QDynamicSelect from "qqq/components/QDynamicSelect/QDynamicSelect";
import MDBox from "qqq/components/Temporary/MDBox";
import MDTypography from "qqq/components/Temporary/MDTypography";
@ -124,6 +124,22 @@ function QDynamicForm(props: Props): JSX.Element
);
}
// possible values!!
if (field.possibleValueProps)
{
return (
<Grid item xs={12} sm={6} key={fieldName}>
<QDynamicSelect
tableName={field.possibleValueProps.tableName}
fieldName={fieldName}
fieldLabel={field.label}
initialValue={values[fieldName]}
initialDisplayValue={field.possibleValueProps.initialDisplayValue}
/>
</Grid>
);
}
// todo? inputProps={{ autoComplete: "" }}
// todo? placeholder={password.placeholder}
return (

View File

@ -87,7 +87,15 @@ function QDynamicFormField({
(type == "checkbox" ?
<QBooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} /> :
<>
<Field {...rest} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="standard" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled} />
<Field {...rest} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="standard" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled}
onKeyPress={(e: any) =>
{
if(e.key === "Enter")
{
e.preventDefault();
}
}}
/>
<MDBox mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={name} /></div>}

View File

@ -0,0 +1,215 @@
/*
* 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 {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {CircularProgress, FilterOptionsState} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
import {useFormikContext} from "formik";
import React, {useEffect, useState} from "react";
import QClient from "qqq/utils/QClient";
interface Props
{
tableName: string;
fieldName: string;
fieldLabel: string;
inForm: boolean;
initialValue?: any;
initialDisplayValue?: string;
onChange?: any
}
QDynamicSelect.defaultProps = {
inForm: true,
initialValue: null,
initialDisplayValue: null,
onChange: null,
};
const qController = QClient.getInstance();
function QDynamicSelect({tableName, fieldName, fieldLabel, inForm, initialValue, initialDisplayValue, onChange}: Props)
{
const [ open, setOpen ] = useState(false);
const [ options, setOptions ] = useState<readonly QPossibleValue[]>([]);
const [ searchTerm, setSearchTerm ] = useState(null);
const [ firstRender, setFirstRender ] = useState(true);
const [defaultValue, _] = useState(initialValue && initialDisplayValue ? {id: initialValue, label: initialDisplayValue} : null);
// const loading = open && options.length === 0;
const [loading, setLoading] = useState(false);
let setFieldValueRef: (field: string, value: any, shouldValidate?: boolean) => void = null;
if(inForm)
{
const {setFieldValue} = useFormikContext();
setFieldValueRef = setFieldValue;
}
useEffect(() =>
{
if(firstRender)
{
// console.log("First render, so not searching...");
setFirstRender(false);
return;
}
// console.log("Use effect for searchTerm - searching!");
let active = true;
setLoading(true);
(async () =>
{
// console.log(`doing a search with ${searchTerm}`);
const results: QPossibleValue[] = await qController.possibleValues(tableName, fieldName, searchTerm ?? "");
setLoading(false);
// console.log("Results:")
// console.log(`${results}`);
if (active)
{
setOptions([ ...results ]);
}
})();
return () =>
{
active = false;
};
}, [ searchTerm ]);
const inputChanged = (event: React.SyntheticEvent, value: string, reason: string) =>
{
console.log(`input changed. Reason: ${reason}, setting search term to ${value}`);
if(reason !== "reset")
{
// console.log(` -> setting search term to ${value}`);
setSearchTerm(value);
}
};
const handleBlur = (x: any) =>
{
setSearchTerm(null);
}
const handleChanged = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) =>
{
// console.log("handleChanged. value is:");
// console.log(value);
setSearchTerm(null);
if(onChange)
{
onChange(value ? new QPossibleValue(value) : null);
}
else if(setFieldValueRef)
{
setFieldValueRef(fieldName, value ? value.id : null);
}
};
const filterOptions = (options: { id: any; label: string; }[], state: FilterOptionsState<{ id: any; label: string; }>): { id: any; label: string; }[] =>
{
/////////////////////////////////////////////////////////////////////////////////
// this looks like a no-op, but it's important to have, otherwise, we can only //
// get options whose text/label matches the input (e.g., not ids that match) //
/////////////////////////////////////////////////////////////////////////////////
return (options);
}
const renderOption = (props: Object, option: any) =>
{
///////////////////////////////////////////////////////////////////////////////////////////////
// we provide a custom renderOption method, to prevent a bug we saw during development, //
// where if multiple options had an identical label, then the widget would ... i don't know, //
// show more options than it should - it was odd to see, and it could be fixed by changing //
// a PVS's format to include id - so the idea came, that maybe the LI's needed unique key //
// attributes. so, doing this, w/ key=id, seemed to fix it. //
///////////////////////////////////////////////////////////////////////////////////////////////
return (
<li {...props} key={option.id}>
{option.label}
</li>
);
}
return (
<Autocomplete
id={fieldName}
open={open}
fullWidth
onOpen={() =>
{
setOpen(true);
// console.log("setting open...");
if(options.length == 0)
{
// console.log("no options yet, so setting search term to ''...");
setSearchTerm("");
}
}}
onClose={() =>
{
setOpen(false);
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option) => option.label}
options={options}
loading={loading}
onInputChange={inputChanged}
onBlur={handleBlur}
defaultValue={defaultValue}
// @ts-ignore
onChange={handleChanged}
noOptionsText={"No matches found"}
onKeyPress={e =>
{
if (e.key === "Enter")
{
e.preventDefault();
}
}}
renderOption={renderOption}
filterOptions={filterOptions}
renderInput={(params) => (
<TextField
{...params}
label={fieldLabel}
variant="standard"
autoComplete="off"
type="search"
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
/>
);
}
export default QDynamicSelect;

View File

@ -65,7 +65,7 @@ function QRecordSidebar({tableSections, widgetMetaDataList, light}: Props): JSX.
return (
<Card sx={{borderRadius: ({borders: {borderRadius}}) => borderRadius.lg, position: "sticky", top: "1%"}}>
<Card sx={{borderRadius: ({borders: {borderRadius}}) => borderRadius.lg, position: "sticky", top: "100px"}}>
<MDBox component="ul" display="flex" flexDirection="column" p={2} m={0} sx={{listStyle: "none"}}>
{
sidebarEntries ? sidebarEntries.map((entry: SidebarEntry, key: number) => (

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,35 +537,22 @@ 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;
columnWidth = widths.get(width);
}
case "medium":
{
columnWidth = 200;
break;
}
case "large":
{
columnWidth = 400;
break;
}
case "xlarge":
{
columnWidth = 600;
break;
}
default:
else
{
console.log("Unrecognized size.width adornment value: " + width);
}
}
}
const column = {
field: field.name,
@ -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)

View File

@ -126,17 +126,41 @@
}
/* let long field names in filter dropdown wrap instead of get cut off */
.MuiDataGrid-filterForm .MuiDataGrid-filterFormColumnInput .MuiNativeSelect-select.MuiNativeSelect-standard
.MuiDataGrid-filterForm .MuiDataGrid-filterFormColumnInput .MuiNativeSelect-select.MuiNativeSelect-standard,
.MuiDataGrid-filterForm .MuiDataGrid-filterFormValueInput .MuiNativeSelect-select.MuiNativeSelect-standard
{
white-space: normal;
height: auto;
}
.MuiDataGrid-filterForm
{
align-items: flex-end;
}
/* make filter dropdowns a bit wider, less likely to need to wrap. */
.MuiDataGrid-filterForm .MuiDataGrid-filterFormColumnInput
{
width: 200px;
}
.MuiDataGrid-filterForm .MuiDataGrid-filterFormValueInput
{
width: 300px;
}
.MuiDataGrid-filterForm .MuiDataGrid-filterFormOperatorInput
{
width: 150px;
}
/* Make the drop-down icon for autocompletes match the ones on the native dropdowns. */
.MuiDataGrid-filterForm .MuiDataGrid-filterFormValueInput .MuiAutocomplete-root .MuiAutocomplete-endAdornment
{
padding-top: 4px;
}
.MuiDataGrid-filterForm .MuiDataGrid-filterFormValueInput .MuiAutocomplete-root .MuiAutocomplete-endAdornment svg
{
height: 0.625em;
}
/* google drive picker - make it be above our modal */
.picker,
.picker.picker-dialog-bg,
@ -144,3 +168,12 @@
{
z-index: 99999;
}
/* clears the X from Internet Explorer */
input[type=search]::-ms-clear { display: none; width : 0; height: 0; }
input[type=search]::-ms-reveal { display: none; width : 0; height: 0; }
/* clears the X from Chrome */
input[type="search"]::-webkit-search-decoration,
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-results-button,
input[type="search"]::-webkit-search-results-decoration { display: none; }

View File

@ -19,8 +19,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import QValueUtils from "qqq/utils/QValueUtils";
/*******************************************************************************
** Utility class for working with QQQ Filters
@ -37,10 +40,16 @@ class QFilterUtils
{
case "contains":
return QCriteriaOperator.CONTAINS;
case "notContains":
return QCriteriaOperator.NOT_CONTAINS;
case "startsWith":
return QCriteriaOperator.STARTS_WITH;
case "notStartsWith":
return QCriteriaOperator.NOT_STARTS_WITH;
case "endsWith":
return QCriteriaOperator.ENDS_WITH;
case "notEndsWith":
return QCriteriaOperator.NOT_ENDS_WITH;
case "is":
case "equals":
case "=":
@ -68,8 +77,12 @@ class QFilterUtils
return QCriteriaOperator.IS_NOT_BLANK;
case "isAnyOf":
return QCriteriaOperator.IN;
case "isNone": // todo - verify - not seen in UI
case "isNone":
return QCriteriaOperator.NOT_IN;
case "between":
return QCriteriaOperator.BETWEEN;
case "notBetween":
return QCriteriaOperator.NOT_BETWEEN;
default:
return QCriteriaOperator.EQUALS;
}
@ -78,11 +91,18 @@ class QFilterUtils
/*******************************************************************************
** Convert a qqq criteria operator to one expected by the grid.
*******************************************************************************/
public static qqqCriteriaOperatorToGrid = (operator: QCriteriaOperator, fieldType: QFieldType = QFieldType.STRING, criteriaValues: any[]): string =>
public static qqqCriteriaOperatorToGrid = (operator: QCriteriaOperator, field: QFieldMetaData, criteriaValues: any[]): string =>
{
const fieldType = field.type;
switch (operator)
{
case QCriteriaOperator.EQUALS:
if(field.possibleValueSourceName)
{
return ("is");
}
switch (fieldType)
{
case QFieldType.INTEGER:
@ -95,13 +115,13 @@ class QFilterUtils
case QFieldType.BOOLEAN:
if (criteriaValues && criteriaValues[0] === true)
{
return "isTrue";
return ("isTrue");
}
else if (criteriaValues && criteriaValues[0] === false)
{
return "isFalse";
return ("isFalse");
}
return "is";
return ("is");
case QFieldType.STRING:
case QFieldType.TEXT:
case QFieldType.HTML:
@ -111,6 +131,12 @@ class QFilterUtils
return ("is");
}
case QCriteriaOperator.NOT_EQUALS:
if(field.possibleValueSourceName)
{
return ("isNot");
}
switch (fieldType)
{
case QFieldType.INTEGER:
@ -131,7 +157,7 @@ class QFilterUtils
case QCriteriaOperator.IN:
return ("isAnyOf");
case QCriteriaOperator.NOT_IN:
return ("isNone"); // todo verify - not seen in UI
return ("isNone");
case QCriteriaOperator.STARTS_WITH:
return ("startsWith");
case QCriteriaOperator.ENDS_WITH:
@ -139,11 +165,11 @@ class QFilterUtils
case QCriteriaOperator.CONTAINS:
return ("contains");
case QCriteriaOperator.NOT_STARTS_WITH:
return (""); // todo - not supported in grid?
return ("notStartsWith");
case QCriteriaOperator.NOT_ENDS_WITH:
return (""); // todo - not supported in grid?
return ("notEndsWith");
case QCriteriaOperator.NOT_CONTAINS:
return (""); // todo - not supported in grid?
return ("notContains");
case QCriteriaOperator.LESS_THAN:
switch (fieldType)
{
@ -189,9 +215,9 @@ class QFilterUtils
case QCriteriaOperator.IS_NOT_BLANK:
return ("isNotEmpty");
case QCriteriaOperator.BETWEEN:
return (""); // todo - not supported in grid?
return ("between");
case QCriteriaOperator.NOT_BETWEEN:
return (""); // todo - not supported in grid?
return ("notBetween");
default:
console.warn(`Unhandled criteria operator: ${operator}`);
return ("=");
@ -220,24 +246,65 @@ class QFilterUtils
{
return (null);
}
else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN)
else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN)
{
return (value);
if(value == null && (operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN))
{
/////////////////////////////////////////////////////////////////////////////////////////////////
// if we send back null, we get a 500 - bad look every time you try to set up a BETWEEN filter //
// but array of 2 nulls? comes up sunshine. //
/////////////////////////////////////////////////////////////////////////////////////////////////
return ([null, null]);
}
return (QFilterUtils.extractIdsFromPossibleValueList(value));
}
return ([value]);
return (QFilterUtils.extractIdsFromPossibleValueList([value]));
};
/*******************************************************************************
**
** Helper method - take a list of values, which may be possible values, and
** either return the original list, or a new list that is just the ids of the
** possible values (if it was a list of possible values)
*******************************************************************************/
public static qqqCriteriaValuesToGrid = (operator: QCriteriaOperator, values: any[], fieldType: QFieldType): any | any[] =>
private static extractIdsFromPossibleValueList = (param: any[]): number[] | string[] =>
{
if(param === null || param === undefined)
{
return (param);
}
let rs = [];
for(let i = 0; i < param.length; i++)
{
console.log(param[i]);
if(param[i] && param[i].id && param[i].label)
{
/////////////////////////////////////////////////////////////
// if the param looks like a possible value, return its id //
/////////////////////////////////////////////////////////////
rs.push(param[i].id);
}
else
{
rs.push(param[i]);
}
}
return (rs);
}
/*******************************************************************************
** Convert a filter field's value from the style that qqq uses, to the style that
** the grid uses.
*******************************************************************************/
public static qqqCriteriaValuesToGrid = (operator: QCriteriaOperator, values: any[], field: QFieldMetaData): any | any[] =>
{
const fieldType = field.type;
if (operator === QCriteriaOperator.IS_BLANK || operator === QCriteriaOperator.IS_NOT_BLANK)
{
return (null); // todo - verify
return (null);
}
else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN)
else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN)
{
return (values);
}
@ -249,21 +316,7 @@ class QFilterUtils
////////////////////////////////////////////////////////////////////////////////////////////////
if (fieldType === QFieldType.DATE_TIME)
{
const inputValue = values[0];
if(inputValue.match(/^\d{4}-\d{2}-\d{2}$/))
{
//////////////////////////////////////////////////////////////////
// if we just passed in a date (w/o time), attach T00:00 to it. //
//////////////////////////////////////////////////////////////////
values[0] = inputValue + "T00:00";
}
else if(inputValue.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}.*/))
{
///////////////////////////////////////////////////////////////////////////////////
// if we passed in something too long (e.g., w/ seconds and fractions), trim it. //
///////////////////////////////////////////////////////////////////////////////////
values[0] = inputValue.substring(0, 16);
}
values[0] = QValueUtils.formatDateTimeValueForForm(values[0]);
}
}

View File

@ -218,6 +218,40 @@ class QValueUtils
</Fragment>
);
}
/*******************************************************************************
** Take a date-time value, and format it the way the ui's date-times want it
** to be.
*******************************************************************************/
public static formatDateTimeValueForForm(value: string): string
{
if(value === null || value === undefined)
{
return (value);
}
if(value.match(/^\d{4}-\d{2}-\d{2}$/))
{
//////////////////////////////////////////////////////////////////
// if we just passed in a date (w/o time), attach T00:00 to it. //
//////////////////////////////////////////////////////////////////
return(value + "T00:00");
}
else if(value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}.*/))
{
///////////////////////////////////////////////////////////////////////////////////
// if we passed in something too long (e.g., w/ seconds and fractions), trim it. //
///////////////////////////////////////////////////////////////////////////////////
return(value.substring(0, 16));
}
else
{
////////////////////////////////////////
// by default, return the input value //
////////////////////////////////////////
return (value);
}
}
}
export default QValueUtils;