From 0e32acce21da3cd65dbcb4675b9367e94dbd2c93 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 11 Oct 2022 08:26:43 -0500 Subject: [PATCH] Adding possible-value dropdowns to forms and filters --- package.json | 6 +- src/qqq/components/EntityForm/index.tsx | 41 ++- src/qqq/components/QDynamicForm/index.tsx | 18 +- .../components/QDynamicFormField/index.tsx | 10 +- .../QDynamicSelect/QDynamicSelect.tsx | 215 ++++++++++++ src/qqq/components/QRecordSidebar/index.tsx | 2 +- .../entity-list/QGridFilterOperators.tsx | 320 ++++++++++++++++++ src/qqq/pages/entity-list/index.tsx | 173 ++++------ src/qqq/styles/qqq-override-styles.css | 37 +- src/qqq/utils/QFilterUtils.ts | 119 +++++-- src/qqq/utils/QValueUtils.tsx | 34 ++ 11 files changed, 828 insertions(+), 147 deletions(-) create mode 100644 src/qqq/components/QDynamicSelect/QDynamicSelect.tsx create mode 100644 src/qqq/pages/entity-list/QGridFilterOperators.tsx diff --git a/package.json b/package.json index d7d9708..168d3d0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/qqq/components/EntityForm/index.tsx b/src/qqq/components/EntityForm/index.tsx index a6689ef..7ee6160 100644 --- a/src/qqq/components/EntityForm/index.tsx +++ b/src/qqq/components/EntityForm/index.tsx @@ -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
- + @@ -313,7 +340,7 @@ function EntityForm({table, id}: Props): JSX.Element { t1sectionName && formFields ? ( - + {getFormSection(values, touched, formFields.get(t1sectionName), errors)} @@ -324,11 +351,11 @@ function EntityForm({table, id}: Props): JSX.Element {formFields && nonT1Sections.length ? nonT1Sections.map((section: QTableSection) => ( - + {section.label} - + { getFormSection(values, touched, formFields.get(section.name), errors) diff --git a/src/qqq/components/QDynamicForm/index.tsx b/src/qqq/components/QDynamicForm/index.tsx index 629a344..312b7ff 100644 --- a/src/qqq/components/QDynamicForm/index.tsx +++ b/src/qqq/components/QDynamicForm/index.tsx @@ -19,13 +19,13 @@ * along with this program. If not, see . */ -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 ( + + + + ); + } + // todo? inputProps={{ autoComplete: "" }} // todo? placeholder={password.placeholder} return ( diff --git a/src/qqq/components/QDynamicFormField/index.tsx b/src/qqq/components/QDynamicFormField/index.tsx index 169952f..f4ecdbb 100644 --- a/src/qqq/components/QDynamicFormField/index.tsx +++ b/src/qqq/components/QDynamicFormField/index.tsx @@ -87,7 +87,15 @@ function QDynamicFormField({ (type == "checkbox" ? : <> - + + { + if(e.key === "Enter") + { + e.preventDefault(); + } + }} + /> {!isDisabled &&
} diff --git a/src/qqq/components/QDynamicSelect/QDynamicSelect.tsx b/src/qqq/components/QDynamicSelect/QDynamicSelect.tsx new file mode 100644 index 0000000..2f23e15 --- /dev/null +++ b/src/qqq/components/QDynamicSelect/QDynamicSelect.tsx @@ -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 . + */ + +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([]); + 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 ( +
  • + {option.label} +
  • + ); + } + + return ( + + { + 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) => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ); +} + +export default QDynamicSelect; diff --git a/src/qqq/components/QRecordSidebar/index.tsx b/src/qqq/components/QRecordSidebar/index.tsx index f87b48d..6892eb5 100644 --- a/src/qqq/components/QRecordSidebar/index.tsx +++ b/src/qqq/components/QRecordSidebar/index.tsx @@ -65,7 +65,7 @@ function QRecordSidebar({tableSections, widgetMetaDataList, light}: Props): JSX. return ( - borderRadius.lg, position: "sticky", top: "1%"}}> + borderRadius.lg, position: "sticky", top: "100px"}}> { sidebarEntries ? sidebarEntries.map((entry: SidebarEntry, key: number) => ( diff --git a/src/qqq/pages/entity-list/QGridFilterOperators.tsx b/src/qqq/pages/entity-list/QGridFilterOperators.tsx new file mode 100644 index 0000000..bdd5ca9 --- /dev/null +++ b/src/qqq/pages/entity-list/QGridFilterOperators.tsx @@ -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 . + */ + +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(); + 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 ( + + + sync} : {}} + /> + + ); +} + + +////////////////////// +// 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(); + const [ filterValueState, setFilterValueState ] = useState(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 ( + + sync} : {}} + /> + + ); +} + + +////////////////////////////////// +// possible value set operators // +////////////////////////////////// +export const buildQGridPvsOperators = (tableName: string, field: QFieldMetaData): GridFilterOperator[] => +{ + return ([ + { + label: "is", + value: "is", + getApplyFilterFn: () => null, + InputComponent: (props: GridFilterInputValueProps) => InputPossibleValueSourceSingle(tableName, field, props) + }, + { + label: "is not", + value: "isNot", + getApplyFilterFn: () => null, + InputComponent: (props: GridFilterInputValueProps) => 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 + } + ]); +}; \ No newline at end of file diff --git a/src/qqq/pages/entity-list/index.tsx b/src/qqq/pages/entity-list/index.tsx index 44cc555..e8ae9ba 100644 --- a/src/qqq/pages/entity-list/index.tsx +++ b/src/qqq/pages/entity-list/index.tsx @@ -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 { 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[] = getGridStringOperators(); + let filterOperators: GridFilterOperator[] = 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 = new Map([ + ["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) diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 7c7150a..d73e359 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -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; } \ No newline at end of file diff --git a/src/qqq/utils/QFilterUtils.ts b/src/qqq/utils/QFilterUtils.ts index c0c776e..30363dd 100644 --- a/src/qqq/utils/QFilterUtils.ts +++ b/src/qqq/utils/QFilterUtils.ts @@ -19,8 +19,11 @@ * along with this program. If not, see . */ +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; +import {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]); } } diff --git a/src/qqq/utils/QValueUtils.tsx b/src/qqq/utils/QValueUtils.tsx index 81cc1e2..338faef 100644 --- a/src/qqq/utils/QValueUtils.tsx +++ b/src/qqq/utils/QValueUtils.tsx @@ -218,6 +218,40 @@ class QValueUtils ); } + + /******************************************************************************* + ** 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;