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

    + Use the add_circle_outline button to add a field.

    + To remove a field, click it and then use the highlight_off button. +
    } placement="left"> + Quick Filter: + + { + metaData && tableMetaData && + <> + + openAddQuickFilterMenu(e)} size="small" disableRipple>add_circle_outline + + + + + + + + } + { + tableMetaData && + [...quickFilterFieldNames.values()].map((fieldName) => + { + // todo - join fields... + // todo - sometimes i want contains (client.name, for example...) + + const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS + if(field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE) + { + defaultOperator = QCriteriaOperator.GREATER_THAN; + } + + return ( + field && + ) + }) + } + + Date: Tue, 16 Jan 2024 12:25:25 -0600 Subject: [PATCH 02/40] Missing import from rebase --- src/qqq/pages/records/query/RecordQuery.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 3950859..7044b32 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -53,7 +53,7 @@ import MenuItem from "@mui/material/MenuItem"; import Modal from "@mui/material/Modal"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector, useGridApiRef, GridPreferencePanelsValue} from "@mui/x-data-grid-pro"; +import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector, useGridApiRef, GridPreferencePanelsValue, GridColumnResizeParams} from "@mui/x-data-grid-pro"; import {GridRowModel} from "@mui/x-data-grid/models/gridRows"; import FormData from "form-data"; import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; From d11304b32bbff372094536ed49aff024d1eed370 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 18 Jan 2024 12:18:56 -0600 Subject: [PATCH 03/40] For an app w/ no sections and no widgets, show a list of its child apps (if we have any) --- src/qqq/pages/apps/Home.tsx | 59 ++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/src/qqq/pages/apps/Home.tsx b/src/qqq/pages/apps/Home.tsx index c3e0775..bb53faf 100644 --- a/src/qqq/pages/apps/Home.tsx +++ b/src/qqq/pages/apps/Home.tsx @@ -34,6 +34,7 @@ import Grid from "@mui/material/Grid"; import React, {useContext, useEffect, useState} from "react"; import {Link, useLocation} from "react-router-dom"; import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; import MDTypography from "qqq/components/legacy/MDTypography"; import ProcessLinkCard from "qqq/components/processes/ProcessLinkCard"; import DashboardWidgets from "qqq/components/widgets/DashboardWidgets"; @@ -180,9 +181,6 @@ function AppHome({app}: Props): JSX.Element } }, [qInstance, location]); - const widgetCount = widgets ? widgets.length : 0; - - // eslint-disable-next-line no-nested-ternary const tileSizeLg = 3; const hasTablePermission = (tableName: string) => @@ -200,10 +198,63 @@ function AppHome({app}: Props): JSX.Element return reports.find(r => r.name === reportName && r.hasPermission); }; + const widgetCount = widgets ? widgets.length : 0; + const sectionCount = app.sections ? app.sections.length : 0; + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // if our app has no widgets or sections, but it does have child apps, then return those child apps // + ////////////////////////////////////////////////////////////////////////////////////////////////////// + if(widgetCount == 0 && sectionCount == 0 && childApps && childApps.length > 0) + { + return ( + + + + + + Apps + + + {childApps.map((childApp) => ( + + + + + + + {childApp.iconName || app.iconName} + + + + + {childApp.label} + + + + + + + ))} + + + + + + ) + } + return ( - {app.widgets && ( + {app.widgets && app.widgets.length > 0 && ( From 275dbb2aeacad94b8ac7258f7aa2f946d30588b9 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 18 Jan 2024 12:19:13 -0600 Subject: [PATCH 04/40] Remove no-longer-needed placeholder class --- src/main/java/Placeholder.java | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100755 src/main/java/Placeholder.java diff --git a/src/main/java/Placeholder.java b/src/main/java/Placeholder.java deleted file mode 100755 index e3ca68b..0000000 --- a/src/main/java/Placeholder.java +++ /dev/null @@ -1,10 +0,0 @@ -/******************************************************************************* - ** Placeholder class, because maven really wants some source under src/main? - *******************************************************************************/ -public class Placeholder -{ - public void f() - { - - } -} From c0221ae9fc9b224361e8a2d0a8d6c6016439db5b Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 18 Jan 2024 12:20:11 -0600 Subject: [PATCH 05/40] Final cleanup on initial WIP implementation of quick-filters, getting ready to go into actual story now --- src/qqq/components/forms/DynamicSelect.tsx | 34 +++++++++++++++++++-- src/qqq/components/query/QuickFilter.tsx | 2 +- src/qqq/pages/records/query/RecordQuery.tsx | 12 +++++--- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/qqq/components/forms/DynamicSelect.tsx b/src/qqq/components/forms/DynamicSelect.tsx index 6c40a0e..ccaeb92 100644 --- a/src/qqq/components/forms/DynamicSelect.tsx +++ b/src/qqq/components/forms/DynamicSelect.tsx @@ -51,6 +51,7 @@ interface Props bulkEditSwitchChangeHandler?: any; otherValues?: Map; variant: "standard" | "outlined"; + initiallyOpen: boolean; } DynamicSelect.defaultProps = { @@ -66,6 +67,7 @@ DynamicSelect.defaultProps = { bulkEditMode: false, otherValues: new Map(), variant: "outlined", + initiallyOpen: false, bulkEditSwitchChangeHandler: () => { }, @@ -73,12 +75,13 @@ DynamicSelect.defaultProps = { const qController = Client.getInstance(); -function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant}: Props) +function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen}: Props) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(initiallyOpen); const [options, setOptions] = useState([]); const [searchTerm, setSearchTerm] = useState(null); const [firstRender, setFirstRender] = useState(true); + const [otherValuesWhenResultsWereLoaded, setOtherValuesWhenResultsWereLoaded] = useState(JSON.stringify(Object.fromEntries((otherValues)))) const {inputBorderColor} = colors; //////////////////////////////////////////////////////////////////////////////////////////////// @@ -113,7 +116,14 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe { // console.log("First render, so not searching..."); setFirstRender(false); - return; + + /* + if(!initiallyOpen) + { + console.log("returning because not initially open?"); + return; + } + */ } // console.log("Use effect for searchTerm - searching!"); @@ -146,6 +156,24 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe }; }, [ searchTerm ]); + // todo - finish... call it in onOpen? + const reloadIfOtherValuesAreChanged = () => + { + if(JSON.stringify(Object.fromEntries(otherValues)) != otherValuesWhenResultsWereLoaded) + { + (async () => + { + setLoading(true); + setOptions([]); + console.log("Refreshing possible values..."); + const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, fieldName, searchTerm ?? "", null, otherValues); + setLoading(false); + setOptions([ ...results ]); + setOtherValuesWhenResultsWereLoaded(JSON.stringify(Object.fromEntries(otherValues))); + })(); + } + } + const inputChanged = (event: React.SyntheticEvent, value: string, reason: string) => { // console.log(`input changed. Reason: ${reason}, setting search term to ${value}`); diff --git a/src/qqq/components/query/QuickFilter.tsx b/src/qqq/components/query/QuickFilter.tsx index 4a08f1b..1580b6b 100644 --- a/src/qqq/components/query/QuickFilter.tsx +++ b/src/qqq/components/query/QuickFilter.tsx @@ -291,7 +291,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData let startIcon = {startIconName} if(criteriaIsValid) { - startIcon = {startIcon} + startIcon = {startIcon} } let buttonContent = {tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label} diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 7044b32..b79d5b3 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -2153,10 +2153,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element - Fields that are frequently used for filter conditions can be added here for quick access.

    - Use the add_circle_outline button to add a field.

    - To remove a field, click it and then use the highlight_off button. + + Fields that you frequently use for filter conditions can be added here for quick access.

    + Use the + add_circle_outline + button to add a Quick Filter field.

    + To remove a Quick Filter field, click the field name, and then use the + highlight_off + button.
    } placement="left"> Quick Filter:
    From 78f764c4cddb2d34d10076c55589b6b3457d85dd Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 23 Jan 2024 14:02:06 -0600 Subject: [PATCH 06/40] CE-798 - primary working version of tsx for basic vs. advanced query (quick-filters in basic mode) --- src/qqq/components/buttons/DefaultButtons.tsx | 2 +- src/qqq/components/misc/FieldAutoComplete.tsx | 18 +- .../query/BasicAndAdvancedQueryControls.tsx | 664 +++++++++++++++++ src/qqq/components/query/ExportMenuItem.tsx | 131 ++++ src/qqq/components/query/QuickFilter.tsx | 243 +++--- .../query/SelectionSubsetDialog.tsx | 74 ++ .../components/query/TableVariantDialog.tsx | 122 +++ src/qqq/pages/records/query/RecordQuery.tsx | 694 +++++------------- src/qqq/styles/qqq-override-styles.css | 6 + src/qqq/utils/qqq/FilterUtils.ts | 121 ++- src/qqq/utils/qqq/ValueUtils.tsx | 13 + 11 files changed, 1460 insertions(+), 628 deletions(-) create mode 100644 src/qqq/components/query/BasicAndAdvancedQueryControls.tsx create mode 100644 src/qqq/components/query/ExportMenuItem.tsx create mode 100644 src/qqq/components/query/SelectionSubsetDialog.tsx create mode 100644 src/qqq/components/query/TableVariantDialog.tsx diff --git a/src/qqq/components/buttons/DefaultButtons.tsx b/src/qqq/components/buttons/DefaultButtons.tsx index f3e10fe..c934f3f 100644 --- a/src/qqq/components/buttons/DefaultButtons.tsx +++ b/src/qqq/components/buttons/DefaultButtons.tsx @@ -37,7 +37,7 @@ interface QCreateNewButtonProps export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element { return ( - + add}> Create New diff --git a/src/qqq/components/misc/FieldAutoComplete.tsx b/src/qqq/components/misc/FieldAutoComplete.tsx index 3a10805..4f789ae 100644 --- a/src/qqq/components/misc/FieldAutoComplete.tsx +++ b/src/qqq/components/misc/FieldAutoComplete.tsx @@ -34,14 +34,16 @@ interface FieldAutoCompleteProps tableMetaData: QTableMetaData; handleFieldChange: (event: any, newValue: any, reason: string) => void; defaultValue?: {field: QFieldMetaData, table: QTableMetaData, fieldName: string}; - autoFocus?: boolean - hiddenFieldNames?: string[] + autoFocus?: boolean; + forceOpen?: boolean; + hiddenFieldNames?: string[]; } FieldAutoComplete.defaultProps = { defaultValue: null, autoFocus: false, + forceOpen: null, hiddenFieldNames: [] }; @@ -61,7 +63,7 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a } } -export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element +export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element { const fieldOptions: any[] = []; makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames); @@ -124,6 +126,15 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi return option.fieldName === value.fieldName; } + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // seems like, if we always add the open attribute, then if its false or null, then the autocomplete // + // doesn't open at all... so, only add the attribute at all, if forceOpen is true // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + const alsoOpen: {[key: string]: any} = {} + if(forceOpen) + { + alsoOpen["open"] = forceOpen; + } return ( ); diff --git a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx new file mode 100644 index 0000000..a84d9a9 --- /dev/null +++ b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx @@ -0,0 +1,664 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection"; +import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import {Badge, ToggleButton, ToggleButtonGroup, Typography} from "@mui/material"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import Icon from "@mui/material/Icon"; +import Menu from "@mui/material/Menu"; +import Tooltip from "@mui/material/Tooltip"; +import {GridFilterModel} from "@mui/x-data-grid-pro"; +import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro"; +import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react"; +import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; +import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; +import QuickFilter from "qqq/components/query/QuickFilter"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import TableUtils from "qqq/utils/qqq/TableUtils"; + +interface BasicAndAdvancedQueryControlsProps +{ + metaData: QInstance; + tableMetaData: QTableMetaData; + queryFilter: QQueryFilter; + gridApiRef: React.MutableRefObject + + setQueryFilter: (queryFilter: QQueryFilter) => void; + handleFilterChange: (filterModel: GridFilterModel, doSetQueryFilter?: boolean, isChangeFromDataGrid?: boolean) => void; + + ///////////////////////////////////////////////////////////////////////////////////////////// + // this prop is used as a way to recognize changes in the query filter internal structure, // + // since the queryFilter object (reference) doesn't get updated // + ///////////////////////////////////////////////////////////////////////////////////////////// + queryFilterJSON: string; + + mode: string; + setMode: (mode: string) => void; +} + +let debounceTimeout: string | number | NodeJS.Timeout; + +/******************************************************************************* + ** Component to provide the basic & advanced query-filter controls for the + ** RecordQuery screen. + ** + ** Done as a forwardRef, so RecordQuery can call some functions, e.g., when user + ** does things on that screen, that we need to know about in here. + *******************************************************************************/ +const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) => +{ + const {metaData, tableMetaData, queryFilter, gridApiRef, setQueryFilter, handleFilterChange, queryFilterJSON, mode, setMode} = props + + ///////////////////////////////////////////////////////// + // get the quick-filter-field-names from local storage // + ///////////////////////////////////////////////////////// + const QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT = "qqq.quickFilterFieldNames"; + const quickFilterFieldNamesLocalStorageKey = `${QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT}.${tableMetaData.name}`; + let defaultQuickFilterFieldNames: Set = new Set(); + if (localStorage.getItem(quickFilterFieldNamesLocalStorageKey)) + { + defaultQuickFilterFieldNames = new Set(JSON.parse(localStorage.getItem(quickFilterFieldNamesLocalStorageKey))); + } + + ///////////////////// + // state variables // + ///////////////////// + const [quickFilterFieldNames, setQuickFilterFieldNames] = useState(defaultQuickFilterFieldNames); + const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null) + const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0); + const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + ////////////////////////////////////////////////////////////////////////////////// + // make some functions available to our parent - so it can tell us to do things // + ////////////////////////////////////////////////////////////////////////////////// + useImperativeHandle(ref, () => + { + return { + ensureAllFilterCriteriaAreActiveQuickFilters(currentFilter: QQueryFilter, reason: string) + { + ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, currentFilter, reason); + }, + addField(fieldName: string) + { + addQuickFilterField({fieldName: fieldName}, "columnMenu"); + } + } + }); + + + /******************************************************************************* + ** for a given field, set its default operator for quick-filter dropdowns. + *******************************************************************************/ + function getDefaultOperatorForField(field: QFieldMetaData) + { + // todo - sometimes i want contains instead of equals on strings (client.name, for example...) + let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS; + if (field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE) + { + defaultOperator = QCriteriaOperator.GREATER_THAN; + } + else if (field?.type == QFieldType.BOOLEAN) + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // for booleans, if we set a default, since none of them have values, then they are ALWAYS selected, which isn't what we want. // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + defaultOperator = null; + } + return defaultOperator; + } + + + /******************************************************************************* + ** Callback passed into the QuickFilter component, to update the criteria + ** after user makes changes to it or to clear it out. + *******************************************************************************/ + const updateQuickCriteria = (newCriteria: QFilterCriteria, needDebounce = false, doClearCriteria = false) => + { + let found = false; + let foundIndex = null; + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + if(queryFilter.criteria[i].fieldName == newCriteria.fieldName) + { + queryFilter.criteria[i] = newCriteria; + found = true; + foundIndex = i; + break; + } + } + + if(doClearCriteria) + { + if(found) + { + queryFilter.criteria.splice(foundIndex, 1); + setQueryFilter(queryFilter); + const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); + handleFilterChange(gridFilterModel, false); + } + return; + } + + if(!found) + { + if(!queryFilter.criteria) + { + queryFilter.criteria = []; + } + queryFilter.criteria.push(newCriteria); + found = true; + } + + if(found) + { + clearTimeout(debounceTimeout) + debounceTimeout = setTimeout(() => + { + setQueryFilter(queryFilter); + const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); + handleFilterChange(gridFilterModel, false); + }, needDebounce ? 500 : 1); + + forceUpdate(); + } + }; + + + /******************************************************************************* + ** Get the QFilterCriteriaWithId object to pass in to the QuickFilter component + ** for a given field name. + *******************************************************************************/ + const getQuickCriteriaParam = (fieldName: string): QFilterCriteriaWithId | null | "tooComplex" => + { + const matches: QFilterCriteriaWithId[] = []; + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + if(queryFilter.criteria[i].fieldName == fieldName) + { + matches.push(queryFilter.criteria[i] as QFilterCriteriaWithId); + } + } + + if(matches.length == 0) + { + return (null); + } + else if(matches.length == 1) + { + return (matches[0]); + } + else + { + return "tooComplex"; + } + }; + + + /******************************************************************************* + ** set the quick-filter field names state variable and local-storage + *******************************************************************************/ + const storeQuickFilterFieldNames = () => + { + setQuickFilterFieldNames(new Set([...quickFilterFieldNames.values()])); + localStorage.setItem(quickFilterFieldNamesLocalStorageKey, JSON.stringify([...quickFilterFieldNames.values()])); + } + + + /******************************************************************************* + ** Event handler for QuickFilter component, to remove a quick filter field from + ** the screen. + *******************************************************************************/ + const handleRemoveQuickFilterField = (fieldName: string): void => + { + if(quickFilterFieldNames.has(fieldName)) + { + ////////////////////////////////////// + // remove this field from the query // + ////////////////////////////////////// + const criteria = new QFilterCriteria(fieldName, null, []); + updateQuickCriteria(criteria, false, true); + + quickFilterFieldNames.delete(fieldName); + storeQuickFilterFieldNames(); + } + }; + + + /******************************************************************************* + ** Event handler for button that opens the add-quick-filter menu + *******************************************************************************/ + const openAddQuickFilterMenu = (event: any) => + { + setAddQuickFilterMenu(event.currentTarget); + setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1); + } + + + /******************************************************************************* + ** Handle closing the add-quick-filter menu + *******************************************************************************/ + const closeAddQuickFilterMenu = () => + { + setAddQuickFilterMenu(null); + } + + + /******************************************************************************* + ** Add a quick-filter field to the screen, from either the user selecting one, + ** or from a new query being activated, etc. + *******************************************************************************/ + const addQuickFilterField = (newValue: any, reason: "blur" | "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | "columnMenu" | string) => + { + console.log(`Adding quick filter field as: ${JSON.stringify(newValue)}`); + if (reason == "blur") + { + ////////////////////////////////////////////////////////////////// + // this keeps a click out of the menu from selecting the option // + ////////////////////////////////////////////////////////////////// + return; + } + + const fieldName = newValue ? newValue.fieldName : null; + if (fieldName) + { + if (!quickFilterFieldNames.has(fieldName)) + { + ///////////////////////////////// + // add the field if we need to // + ///////////////////////////////// + quickFilterFieldNames.add(fieldName); + storeQuickFilterFieldNames(); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // only do this when user has added the field (e.g., not when adding it because of a selected view or filter-in-url) // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected") + { + setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5); + } + } + else if(reason == "columnMenu") + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if field was already on-screen, but user clicked an option from the columnMenu, then open the quick-filter field // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5); + } + + closeAddQuickFilterMenu(); + } + }; + + + /******************************************************************************* + ** event handler for the Filter Buidler button - e.g., opens the parent's grid's + ** filter panel + *******************************************************************************/ + const openFilterBuilder = (e: React.MouseEvent | React.MouseEvent) => + { + gridApiRef.current.showFilterPanel(); + }; + + + /******************************************************************************* + ** event handler for the clear-filters modal + *******************************************************************************/ + const handleClearFiltersAction = (event: React.KeyboardEvent, isYesButton: boolean = false) => + { + if (isYesButton || event.key == "Enter") + { + setShowClearFiltersWarning(false); + handleFilterChange({items: []} as GridFilterModel); + } + }; + + + /******************************************************************************* + ** format the current query as a string for showing on-screen as a preview. + *******************************************************************************/ + const queryToAdvancedString = () => + { + if(queryFilter == null || !queryFilter.criteria) + { + return (); + } + + let counter = 0; + + return ( + + {queryFilter.criteria.map((criteria, i) => + { + if(criteria && criteria.fieldName && criteria.operator) + { + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName); + const valuesString = FilterUtils.getValuesString(field, criteria); + counter++; + + return ( + + {counter > 1 ? {queryFilter.booleanOperator}  : } + {field.label} {criteria.operator} {valuesString}  + + ); + } + else + { + return (); + } + })} + + ); + }; + + + /******************************************************************************* + ** event handler for toggling between modes - basic & advanced. + *******************************************************************************/ + const modeToggleClicked = (newValue: string | null) => + { + if (newValue) + { + if(newValue == "basic") + { + //////////////////////////////////////////////////////////////////////////////// + // we're always allowed to go to advanced - // + // but if we're trying to go to basic, make sure the filter isn't too complex // + //////////////////////////////////////////////////////////////////////////////// + const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); + if (!canFilterWorkAsBasic) + { + console.log("Query cannot work as basic - so - not allowing toggle to basic.") + return; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // when going to basic, make sure all fields in the current query are active as quick-filters // + //////////////////////////////////////////////////////////////////////////////////////////////// + if (queryFilter && queryFilter.criteria) + { + ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "modeToggleClicked"); + } + } + + ////////////////////////////////////////////////////////////////////////////////////// + // note - this is a callback to the parent - as it is responsible for this state... // + ////////////////////////////////////////////////////////////////////////////////////// + setMode(newValue); + } + }; + + + /******************************************************************************* + ** make sure that all fields in the current query are on-screen as quick-filters + ** (that is, if the query can be basic) + *******************************************************************************/ + const ensureAllFilterCriteriaAreActiveQuickFilters = (tableMetaData: QTableMetaData, queryFilter: QQueryFilter, reason: "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | string) => + { + if(!tableMetaData || !queryFilter) + { + return; + } + + const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); + if (!canFilterWorkAsBasic) + { + console.log("query is too complex for basic - so - switching to advanced"); + modeToggleClicked("advanced"); + forceUpdate(); + return; + } + + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const criteria = queryFilter.criteria[i]; + if (criteria && criteria.fieldName) + { + addQuickFilterField(criteria, reason); + } + } + } + + + ////////////////////////////////////////////////////////////////////////////// + // if there aren't any quick-filters turned on, get defaults from the table // + // only run this block upon a first-render // + ////////////////////////////////////////////////////////////////////////////// + const [firstRender, setFirstRender] = useState(true); + if(firstRender) + { + setFirstRender(false); + + if (defaultQuickFilterFieldNames == null || defaultQuickFilterFieldNames.size == 0) + { + defaultQuickFilterFieldNames = new Set(); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // check if there's materialDashboard tableMetaData, and if it has defaultQuickFilterFieldNames // + ////////////////////////////////////////////////////////////////////////////////////////////////// + const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard"); + if (mdbMetaData) + { + if (mdbMetaData?.defaultQuickFilterFieldNames?.length) + { + for (let i = 0; i < mdbMetaData.defaultQuickFilterFieldNames.length; i++) + { + defaultQuickFilterFieldNames.add(mdbMetaData.defaultQuickFilterFieldNames[i]); + } + } + } + + ///////////////////////////////////////////// + // if still none, then look for T1 section // + ///////////////////////////////////////////// + if (defaultQuickFilterFieldNames.size == 0) + { + if (tableMetaData.sections) + { + const t1Sections = tableMetaData.sections.filter((s: QTableSection) => s.tier == "T1"); + if (t1Sections.length) + { + for (let i = 0; i < t1Sections.length; i++) + { + if (t1Sections[i].fieldNames) + { + for (let j = 0; j < t1Sections[i].fieldNames.length; j++) + { + defaultQuickFilterFieldNames.add(t1Sections[i].fieldNames[j]); + } + } + } + } + } + } + + setQuickFilterFieldNames(defaultQuickFilterFieldNames); + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // this is being used as a version of like forcing that we get re-rendered if the query filter changes... // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + const [lastIndex, setLastIndex] = useState(queryFilterJSON); + if(queryFilterJSON != lastIndex) + { + ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "defaultFilterLoaded"); + setLastIndex(queryFilterJSON); + } + + /////////////////////////////////////////////////// + // set some status flags based on current filter // + /////////////////////////////////////////////////// + const hasValidFilters = queryFilter && queryFilter.criteria && queryFilter.criteria.length > 0; // todo - should be better (e.g., see if operator & values are set) + const {canFilterWorkAsBasic, reasonsWhyItCannot} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); + let reasonWhyBasicIsDisabled = null; + if(reasonsWhyItCannot && reasonsWhyItCannot.length > 0) + { + reasonWhyBasicIsDisabled = <> + Your current Filter cannot be managed using BASIC mode because: +
      + {reasonsWhyItCannot.map((reason, i) =>
    • {reason}
    • )} +
    + + } + + return ( + + + { + mode == "basic" && + + { + tableMetaData && + [...quickFilterFieldNames.values()].map((fieldName) => + { + const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = getDefaultOperatorForField(field); + + return ( + field && + ); + }) + } + { + tableMetaData && + <> + + + + + + addQuickFilterField(newValue, reason)} + autoFocus={true} + forceOpen={Boolean(addQuickFilterMenu)} + hiddenFieldNames={[...quickFilterFieldNames.values()]} + /> + + + + } + + } + { + metaData && tableMetaData && mode == "advanced" && + <> + + + +
    + { + hasValidFilters && ( + <> + + setShowClearFiltersWarning(true)}>clear + + setShowClearFiltersWarning(true)} onKeyPress={(e) => handleClearFiltersAction(e)}> + Confirm + + Are you sure you want to remove all conditions from the current filter? + + + setShowClearFiltersWarning(true)} /> + handleClearFiltersAction(null, true)} /> + + + + ) + } +
    + + Current Filter: + { + + {queryToAdvancedString()} + + } + + + } +
    + + { + metaData && tableMetaData && + + Mode: + + modeToggleClicked(newValue)} + size="small" + sx={{pl: 0.5}} + > + Basic + Advanced + + + + } + +
    + ); +}); + +export default BasicAndAdvancedQueryControls; \ No newline at end of file diff --git a/src/qqq/components/query/ExportMenuItem.tsx b/src/qqq/components/query/ExportMenuItem.tsx new file mode 100644 index 0000000..f1a1004 --- /dev/null +++ b/src/qqq/components/query/ExportMenuItem.tsx @@ -0,0 +1,131 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import MenuItem from "@mui/material/MenuItem"; +import {GridColDef, GridExportMenuItemProps} from "@mui/x-data-grid-pro"; +import React from "react"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +interface QExportMenuItemProps extends GridExportMenuItemProps<{}> +{ + tableMetaData: QTableMetaData; + totalRecords: number + columnsModel: GridColDef[]; + columnVisibilityModel: { [index: string]: boolean }; + queryFilter: QQueryFilter; + format: string; +} + +/******************************************************************************* + ** Component to serve as an item in the Export menu + *******************************************************************************/ +export default function ExportMenuItem(props: QExportMenuItemProps) +{ + const {format, tableMetaData, totalRecords, columnsModel, columnVisibilityModel, queryFilter, hideMenu} = props; + + return ( + + { + /////////////////////////////////////////////////////////////////////////////// + // build the list of visible fields. note, not doing them in-order (in case // + // the user did drag & drop), because column order model isn't right yet // + // so just doing them to match columns (which were pKey, then sorted) // + /////////////////////////////////////////////////////////////////////////////// + const visibleFields: string[] = []; + columnsModel.forEach((gridColumn) => + { + const fieldName = gridColumn.field; + if (columnVisibilityModel[fieldName] !== false) + { + visibleFields.push(fieldName); + } + }); + + ////////////////////////////////////// + // construct the url for the export // + ////////////////////////////////////// + const dateString = ValueUtils.formatDateTimeForFileName(new Date()); + const filename = `${tableMetaData.label} Export ${dateString}.${format}`; + const url = `/data/${tableMetaData.name}/export/${filename}`; + + const encodedFilterJSON = encodeURIComponent(JSON.stringify(queryFilter)); + + ////////////////////////////////////////////////////////////////////////////////////// + // open a window (tab) with a little page that says the file is being generated. // + // then have that page load the url for the export. // + // If there's an error, it'll appear in that window. else, the file will download. // + ////////////////////////////////////////////////////////////////////////////////////// + const exportWindow = window.open("", "_blank"); + exportWindow.document.write(` + + + ${filename} + + + + Generating file ${filename}${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}... +
    + + +
    + + `); + + /* + // todo - probably better - generate the report in an iframe... + // only open question is, giving user immediate feedback, and knowing when the stream has started and/or stopped + // maybe a busy-loop that would check iframe's url (e.g., after posting should change, maybe?) + const iframe = document.getElementById("exportIFrame"); + const form = iframe.querySelector("form"); + form.action = url; + form.target = "exportIFrame"; + (iframe.querySelector("#authorizationInput") as HTMLInputElement).value = qController.getAuthorizationHeaderValue(); + form.submit(); + */ + + /////////////////////////////////////////// + // Hide the export menu after the export // + /////////////////////////////////////////// + hideMenu?.(); + }} + > + Export + {` ${format.toUpperCase()}`} +
    + ); +} + diff --git a/src/qqq/components/query/QuickFilter.tsx b/src/qqq/components/query/QuickFilter.tsx index 1580b6b..4170aa6 100644 --- a/src/qqq/components/query/QuickFilter.tsx +++ b/src/qqq/components/query/QuickFilter.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 {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; @@ -35,9 +36,10 @@ import React, {SyntheticEvent, useState} from "react"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; import {getDefaultCriteriaValue, getOperatorOptions, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; -type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex"; +export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex"; interface QuickFilterProps { @@ -47,27 +49,64 @@ interface QuickFilterProps criteriaParam: CriteriaParamType; updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean, doRemoveCriteria: boolean) => void; defaultOperator?: QCriteriaOperator; - toggleQuickFilterField?: (fieldName: string) => void; + handleRemoveQuickFilterField?: (fieldName: string) => void; } +QuickFilter.defaultProps = + { + defaultOperator: QCriteriaOperator.EQUALS, + handleRemoveQuickFilterField: null + }; + +let seedId = new Date().getTime() % 173237; + +/******************************************************************************* + ** Test if a CriteriaParamType represents an actual query criteria - or, if it's + ** null or the "tooComplex" placeholder. + *******************************************************************************/ const criteriaParamIsCriteria = (param: CriteriaParamType): boolean => { return (param != null && param != "tooComplex"); }; -QuickFilter.defaultProps = +/******************************************************************************* + ** Test of an OperatorOption equals a query Criteria - that is - that the + ** operators within them are equal - AND - if the OperatorOption has implicit + ** values (e.g., the booleans), then those options equal the criteria's options. + *******************************************************************************/ +const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteria: QFilterCriteriaWithId): boolean => +{ + if(operatorOption.value == criteria.operator) { - defaultOperator: QCriteriaOperator.EQUALS, - toggleQuickFilterField: null - }; + if(operatorOption.implicitValues) + { + if(JSON.stringify(operatorOption.implicitValues) == JSON.stringify(criteria.values)) + { + return (true); + } + else + { + return (false); + } + } -let seedId = new Date().getTime() % 173237; + return (true); + } + return (false); +} + + +/******************************************************************************* + ** Get the object to use as the selected OperatorOption (e.g., value for that + ** autocomplete), given an array of options, the query's active criteria in this + ** field, and the default operator to use for this field + *******************************************************************************/ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption => { if(criteria) { - const filteredOptions = operatorOptions.filter(o => o.value == criteria.operator); + const filteredOptions = operatorOptions.filter(o => doesOperatorOptionEqualCriteria(o, criteria)); if(filteredOptions.length > 0) { return (filteredOptions[0]); @@ -83,10 +122,14 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q return (null); } -export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, toggleQuickFilterField}: QuickFilterProps): JSX.Element +/******************************************************************************* + ** Component to render a QuickFilter - that is - a button, with a Menu under it, + ** with Operator and Value controls. + *******************************************************************************/ +export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField}: QuickFilterProps): JSX.Element { const operatorOptions = fieldMetaData ? getOperatorOptions(tableMetaData, fullFieldName) : []; - const tableForField = tableMetaData; // todo!! const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName); + const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName); const [isOpen, setIsOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); @@ -97,17 +140,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator)); const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label); - const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator); - if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue)) - { - setOperatorSelectedValue(maybeNewOperatorSelectedValue) - setOperatorInputValue(maybeNewOperatorSelectedValue.label) - } + const [startIconName, setStartIconName] = useState("filter_alt"); - if(!fieldMetaData) - { - return (null); - } + const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue); ////////////////////////////////////////////////////////////////////////////////////////////////////////////// // handle a change to the criteria from outside this component (e.g., the prop isn't the same as the state) // @@ -117,16 +152,20 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData const newCriteria = criteriaParam as QFilterCriteriaWithId; setCriteria(newCriteria); const operatorOption = operatorOptions.filter(o => o.value == newCriteria.operator)[0]; + console.log(`B: setOperatorSelectedValue [${JSON.stringify(operatorOption)}]`); setOperatorSelectedValue(operatorOption); setOperatorInputValue(operatorOption.label); } + /******************************************************************************* + ** Test if we need to construct a new criteria object + *******************************************************************************/ const criteriaNeedsReset = (): boolean => { if(criteria != null && criteriaParam == null) { const defaultOperatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0]; - if(criteria.operator !== defaultOperatorOption.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue())) + if(criteria.operator !== defaultOperatorOption?.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue())) { return (true); } @@ -135,44 +174,50 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData return (false); } + /******************************************************************************* + ** Construct a new criteria object - resetting the values tied to the oprator + ** autocomplete at the same time. + *******************************************************************************/ const makeNewCriteria = (): QFilterCriteria => { const operatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0]; - const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption.value, getDefaultCriteriaValue()); + const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption?.value, getDefaultCriteriaValue()); criteria.id = id; + console.log(`C: setOperatorSelectedValue [${JSON.stringify(operatorOption)}]`); setOperatorSelectedValue(operatorOption); - setOperatorInputValue(operatorOption.label); + setOperatorInputValue(operatorOption?.label); setCriteria(criteria); return(criteria); } - if (criteria == null || criteriaNeedsReset()) - { - makeNewCriteria(); - } - - const toggleOpen = (event: any) => + /******************************************************************************* + ** event handler to open the menu in response to the button being clicked. + *******************************************************************************/ + const handleOpenMenu = (event: any) => { setIsOpen(!isOpen); setAnchorEl(event.currentTarget); }; + /******************************************************************************* + ** handler for the Menu when being closed + *******************************************************************************/ const closeMenu = () => { setIsOpen(false); setAnchorEl(null); }; - ///////////////////////////////////////////// - // event handler for operator Autocomplete // - // todo - too dupe? - ///////////////////////////////////////////// + /******************************************************************************* + ** event handler for operator Autocomplete having its value changed + *******************************************************************************/ const handleOperatorChange = (event: any, newValue: any, reason: string) => { criteria.operator = newValue ? newValue.value : null; if (newValue) { + console.log(`D: setOperatorSelectedValue [${JSON.stringify(newValue)}]`); setOperatorSelectedValue(newValue); setOperatorInputValue(newValue.label); @@ -183,6 +228,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData } else { + console.log("E: setOperatorSelectedValue [null]"); setOperatorSelectedValue(null); setOperatorInputValue(""); } @@ -190,15 +236,18 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData updateCriteria(criteria, false, false); }; + /******************************************************************************* + ** implementation of isOptionEqualToValue for Autocomplete - compares both the + ** value (e.g., what operator it is) and the implicitValues within the option + *******************************************************************************/ function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption) { return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues)); } - ////////////////////////////////////////////////// - // event handler for value field (of all types) // - // todo - too dupe! - ////////////////////////////////////////////////// + /******************************************************************************* + ** event handler for the value field (of all types), when it changes + *******************************************************************************/ const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) => { // @ts-ignore @@ -221,48 +270,17 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData updateCriteria(criteria, true, false); }; + /******************************************************************************* + ** a noop event handler, e.g., for a too-complex + *******************************************************************************/ const noop = () => { }; - const getValuesString = (): string => - { - let valuesString = ""; - if (criteria.values && criteria.values.length) - { - let labels = [] as string[]; - - let maxLoops = criteria.values.length; - if (maxLoops > 5) - { - maxLoops = 3; - } - - for (let i = 0; i < maxLoops; i++) - { - if (criteria.values[i] && criteria.values[i].label) - { - labels.push(criteria.values[i].label); - } - else - { - labels.push(criteria.values[i]); - } - } - - if (maxLoops < criteria.values.length) - { - labels.push(" and " + (criteria.values.length - maxLoops) + " other values."); - } - - valuesString = (labels.join(", ")); - } - return valuesString; - } - - const [startIconName, setStartIconName] = useState("filter_alt"); - const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue); - + /******************************************************************************* + ** event handler that responds to 'x' button that removes the criteria from the + ** quick-filter, resetting it to a new filter. + *******************************************************************************/ const resetCriteria = (e: React.MouseEvent) => { if(criteriaIsValid) @@ -274,6 +292,10 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData } } + /******************************************************************************* + ** event handler for mouse-over on the filter icon - that changes to an 'x' + ** if there's a valid criteria in the quick-filter + *******************************************************************************/ const startIconMouseOver = () => { if(criteriaIsValid) @@ -281,11 +303,56 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData setStartIconName("clear"); } } + + /******************************************************************************* + ** event handler for mouse-out on the filter icon - always resets it. + *******************************************************************************/ const startIconMouseOut = () => { setStartIconName("filter_alt"); } + /******************************************************************************* + ** event handler for clicking the (x) icon that turns off this quick filter field. + ** hands off control to the function that was passed in (e.g., from RecordQuery). + *******************************************************************************/ + const handleTurningOffQuickFilterField = () => + { + closeMenu() + handleRemoveQuickFilterField(criteria?.fieldName); + } + + //////////////////////////////////////////////////////////////////////////////////// + // if no field was input (e.g., record-query is still loading), return null early // + //////////////////////////////////////////////////////////////////////////////////// + if(!fieldMetaData) + { + return (null); + } + + ////////////////////////////////////////////////////////////////////////////////////////// + // if there should be a selected value in the operator autocomplete, and it's different // + // from the last selected one, then set the state vars that control that autocomplete // + ////////////////////////////////////////////////////////////////////////////////////////// + const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator); + if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue)) + { + console.log(`A: setOperatorSelectedValue [${JSON.stringify(maybeNewOperatorSelectedValue)}]`); + setOperatorSelectedValue(maybeNewOperatorSelectedValue) + setOperatorInputValue(maybeNewOperatorSelectedValue?.label) + } + + ///////////////////////////////////////////////////////////////////////////////////// + // if there wasn't a criteria, or we need to reset it (make a new one), then do so // + ///////////////////////////////////////////////////////////////////////////////////// + if (criteria == null || criteriaNeedsReset()) + { + makeNewCriteria(); + } + + ///////////////////////// + // build up the button // + ///////////////////////// const tooComplex = criteriaParam == "tooComplex"; const tooltipEnterDelay = 500; let startIcon = {startIconName} @@ -298,22 +365,29 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData if (criteriaIsValid) { buttonContent = ( - + {buttonContent} ); } let button = fieldMetaData && ; + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if the criteria on this field is the "tooComplex" sentinel, then wrap the button in a tooltip stating such, and return early. // + // note this was part of original design on this widget, but later deprecated... // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if (tooComplex) { - // wrap button in span, so disabled button doesn't cause disabled tooltip + //////////////////////////////////////////////////////////////////////////// + // wrap button in span, so disabled button doesn't cause disabled tooltip // + //////////////////////////////////////////////////////////////////////////// return ( {button} @@ -321,12 +395,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData ); } - const doToggle = () => - { - closeMenu() - toggleQuickFilterField(criteria?.fieldName); - } - + ////////////////////////////// + // return the button & menu // + ////////////////////////////// const widthAndMaxWidth = 250 return ( <> @@ -334,9 +405,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData { isOpen && { - toggleQuickFilterField && + handleRemoveQuickFilterField && - highlight_off + highlight_off } diff --git a/src/qqq/components/query/SelectionSubsetDialog.tsx b/src/qqq/components/query/SelectionSubsetDialog.tsx new file mode 100644 index 0000000..d601f1c --- /dev/null +++ b/src/qqq/components/query/SelectionSubsetDialog.tsx @@ -0,0 +1,74 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import TextField from "@mui/material/TextField"; +import React, {useState} from "react"; +import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; + + +/******************************************************************************* + ** Component that is the dialog for the user to enter the selection-subset + *******************************************************************************/ +export default function SelectionSubsetDialog(props: { isOpen: boolean; initialValue: number; closeHandler: (value?: number) => void }) +{ + const [value, setValue] = useState(props.initialValue); + + const handleChange = (newValue: string) => + { + setValue(parseInt(newValue)); + }; + + const keyPressed = (e: React.KeyboardEvent) => + { + if (e.key == "Enter" && value) + { + props.closeHandler(value); + } + }; + + return ( + props.closeHandler()} onKeyPress={(e) => keyPressed(e)}> + Subset of the Query Result + + How many records do you want to select? + handleChange(e.target.value)} + value={value} + sx={{width: "100%"}} + onFocus={event => event.target.select()} + /> + + + props.closeHandler()} /> + props.closeHandler(value)} /> + + + ); +} + diff --git a/src/qqq/components/query/TableVariantDialog.tsx b/src/qqq/components/query/TableVariantDialog.tsx new file mode 100644 index 0000000..b40efa5 --- /dev/null +++ b/src/qqq/components/query/TableVariantDialog.tsx @@ -0,0 +1,122 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant"; +import Autocomplete from "@mui/material/Autocomplete"; +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import TextField from "@mui/material/TextField"; +import React, {useEffect, useState} from "react"; +import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery"; +import Client from "qqq/utils/qqq/Client"; + +const qController = Client.getInstance(); + +/******************************************************************************* + ** Component that is the dialog for the user to select a variant on tables with variant backends // + *******************************************************************************/ +export default function TableVariantDialog(props: { isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void }) +{ + const [value, setValue] = useState(null); + const [dropDownOpen, setDropDownOpen] = useState(false); + const [variants, setVariants] = useState(null); + + const handleVariantChange = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) => + { + const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${props.table.name}`; + if (value != null) + { + localStorage.setItem(tableVariantLocalStorageKey, JSON.stringify(value)); + } + else + { + localStorage.removeItem(tableVariantLocalStorageKey); + } + props.closeHandler(value); + }; + + const keyPressed = (e: React.KeyboardEvent) => + { + if (e.key == "Enter" && value) + { + props.closeHandler(value); + } + }; + + useEffect(() => + { + console.log("queryVariants"); + try + { + (async () => + { + const variants = await qController.tableVariants(props.table.name); + console.log(JSON.stringify(variants)); + setVariants(variants); + })(); + } + catch (e) + { + console.log(e); + } + }, []); + + + return variants && ( + keyPressed(e)}> + {props.table.variantTableLabel} + + Select the {props.table.variantTableLabel} to be used on this table: + + { + setDropDownOpen(true); + }} + onClose={() => + { + setDropDownOpen(false); + }} + // @ts-ignore + onChange={handleVariantChange} + isOptionEqualToValue={(option, value) => option.id === value.id} + options={variants} + renderInput={(params) => } + getOptionLabel={(option) => + { + if (typeof option == "object") + { + return (option as QTableVariant).name; + } + return option; + }} + /> + + + ); +} + diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index b79d5b3..4a29d69 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -22,7 +22,6 @@ import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability"; import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; -import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; @@ -30,19 +29,11 @@ import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTa import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete"; import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; -import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; -import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {Alert, Collapse, TablePagination, Typography} from "@mui/material"; -import Autocomplete from "@mui/material/Autocomplete"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogContentText from "@mui/material/DialogContentText"; -import DialogTitle from "@mui/material/DialogTitle"; import Divider from "@mui/material/Divider"; import Icon from "@mui/material/Icon"; import IconButton from "@mui/material/IconButton"; @@ -51,22 +42,23 @@ import ListItemIcon from "@mui/material/ListItemIcon"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Modal from "@mui/material/Modal"; -import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector, useGridApiRef, GridPreferencePanelsValue, GridColumnResizeParams} from "@mui/x-data-grid-pro"; +import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector, useGridApiRef, GridPreferencePanelsValue, GridColumnResizeParams, ColumnHeaderFilterIconButtonProps} from "@mui/x-data-grid-pro"; import {GridRowModel} from "@mui/x-data-grid/models/gridRows"; import FormData from "form-data"; import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; import {useLocation, useNavigate, useSearchParams} from "react-router-dom"; import QContext from "QContext"; -import {QActionsMenuButton, QCancelButton, QCreateNewButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import {QActionsMenuButton, QCancelButton, QCreateNewButton} from "qqq/components/buttons/DefaultButtons"; import MenuButton from "qqq/components/buttons/MenuButton"; -import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import {GotoRecordButton} from "qqq/components/misc/GotoRecordDialog"; import SavedFilters from "qqq/components/misc/SavedFilters"; +import BasicAndAdvancedQueryControls from "qqq/components/query/BasicAndAdvancedQueryControls"; import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; -import {CustomFilterPanel, QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; -import QuickFilter from "qqq/components/query/QuickFilter"; +import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; +import ExportMenuItem from "qqq/components/query/ExportMenuItem"; +import SelectionSubsetDialog from "qqq/components/query/SelectionSubsetDialog"; +import TableVariantDialog from "qqq/components/query/TableVariantDialog"; import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import BaseLayout from "qqq/layouts/BaseLayout"; import ProcessRun from "qqq/pages/processes/ProcessRun"; @@ -89,6 +81,7 @@ const COLUMN_WIDTHS_LOCAL_STORAGE_KEY_ROOT = "qqq.columnWidths"; const SEEN_JOIN_TABLES_LOCAL_STORAGE_KEY_ROOT = "qqq.seenJoinTables"; const DENSITY_LOCAL_STORAGE_KEY_ROOT = "qqq.density"; const QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT = "qqq.quickFilterFieldNames"; +const MODE_LOCAL_STORAGE_KEY_ROOT = "qqq.queryScreenMode"; export const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant"; @@ -104,7 +97,6 @@ RecordQuery.defaultProps = { }; const qController = Client.getInstance(); -let debounceTimeout: string | number | NodeJS.Timeout; function RecordQuery({table, launchProcess}: Props): JSX.Element { @@ -151,7 +143,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const columnVisibilityLocalStorageKey = `${COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; const filterLocalStorageKey = `${FILTER_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const quickFilterFieldNamesLocalStorageKey = `${QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; + const modeLocalStorageKey = `${MODE_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; let defaultSort = [] as GridSortItem[]; let defaultVisibility = {} as { [index: string]: boolean }; let didDefaultVisibilityComeFromLocalStorage = false; @@ -162,7 +154,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element let defaultColumnWidths = {} as {[fieldName: string]: number}; let seenJoinTables: {[tableName: string]: boolean} = {}; let defaultTableVariant: QTableVariant = null; - let defaultQuickFilterFieldNames: Set = new Set(); + let defaultMode = "basic"; //////////////////////////////////////////////////////////////////////////////////// // set the to be not per table (do as above if we want per table) at a later port // @@ -206,13 +198,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { defaultTableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey)); } - if (localStorage.getItem(quickFilterFieldNamesLocalStorageKey)) + if (localStorage.getItem(modeLocalStorageKey)) { - defaultQuickFilterFieldNames = new Set(JSON.parse(localStorage.getItem(quickFilterFieldNamesLocalStorageKey))); + defaultMode = localStorage.getItem(modeLocalStorageKey); } const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel); const [lastFetchedQFilterJSON, setLastFetchedQFilterJSON] = useState(""); + const [lastFetchedVariant, setLastFetchedVariant] = useState(null); const [columnSortModel, setColumnSortModel] = useState(defaultSort); const [queryFilter, setQueryFilter] = useState(new QQueryFilter()); const [tableVariant, setTableVariant] = useState(defaultTableVariant); @@ -254,8 +247,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [gridMouseDownX, setGridMouseDownX] = useState(0); const [gridMouseDownY, setGridMouseDownY] = useState(0); const [gridPreferencesWindow, setGridPreferencesWindow] = useState(undefined); - const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false); - const [hasValidFilters, setHasValidFilters] = useState(false); const [currentSavedFilter, setCurrentSavedFilter] = useState(null as QRecord); const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData); @@ -266,9 +257,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [columnStatsFieldTableName, setColumnStatsFieldTableName] = useState(null as string) const [filterForColumnStats, setFilterForColumnStats] = useState(null as QQueryFilter); - const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null); - const [quickFilterFieldNames, setQuickFilterFieldNames] = useState(defaultQuickFilterFieldNames); - const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0); + const [mode, setMode] = useState(defaultMode); + const basicAndAdvancedQueryControlsRef = useRef(); const instance = useRef({timer: null}); @@ -321,7 +311,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element else if (! e.metaKey && e.key === "r") { e.preventDefault() - updateTable(); + updateTable("'r' keyboard event"); } else if (! e.metaKey && e.key === "c") { @@ -396,7 +386,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } else { - setCurrentSavedFilter(null); + doSetCurrentSavedFilter(null); } } @@ -417,7 +407,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { const result = processResult as QJobComplete; const qRecord = new QRecord(result.values.savedFilterList[0]); - setCurrentSavedFilter(qRecord); + doSetCurrentSavedFilter(qRecord); } })(); } @@ -473,7 +463,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { let filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, limit); filter = FilterUtils.convertFilterPossibleValuesToIds(filter); - setHasValidFilters(filter.criteria && filter.criteria.length > 0); return (filter); }; @@ -588,14 +577,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element {tableMetaData?.variantTableLabel}: {tableVariant?.name} - settings + settings ); } - const updateTable = () => + const updateTable = (reason?: string) => { + console.log(`In updateTable for ${reason}`); setLoading(true); setRows([]); (async () => @@ -658,14 +648,25 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setColumnSortModel(models.sort); setWarningAlert(models.warning); - setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage)); + const newQueryFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage); + setQueryFilter(newQueryFilter); + + //////////////////////////////////////////////////////////////////////////////////////// + // this ref may not be defined on the initial render, so, make this call in a timeout // + //////////////////////////////////////////////////////////////////////////////////////// + setTimeout(() => + { + // @ts-ignore + basicAndAdvancedQueryControlsRef?.current?.ensureAllFilterCriteriaAreActiveQuickFilters(newQueryFilter, "defaultFilterLoaded") + }); + return; } setTableMetaData(tableMetaData); setTableLabel(tableMetaData.label); - if(tableMetaData?.usesVariants && ! tableVariant) + if (tableMetaData?.usesVariants && !tableVariant) { promptForTableVariantSelection(); return; @@ -802,6 +803,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } setLastFetchedQFilterJSON(JSON.stringify(qFilter)); + setLastFetchedVariant(tableVariant); qController.query(tableName, qFilter, queryJoins, tableVariant).then((results) => { console.log(`Received results for query ${thisQueryId}`); @@ -1021,7 +1023,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()])) { console.log("calling update table for visible join table change"); - updateTable(); + updateTable("visible joins change"); setVisibleJoinTables(newVisibleJoinTables); } @@ -1117,111 +1119,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element })(); } - interface QExportMenuItemProps extends GridExportMenuItemProps<{}> - { - format: string; - } - - function ExportMenuItem(props: QExportMenuItemProps) - { - const {format, hideMenu} = props; - - return ( - - { - /////////////////////////////////////////////////////////////////////////////// - // build the list of visible fields. note, not doing them in-order (in case // - // the user did drag & drop), because column order model isn't right yet // - // so just doing them to match columns (which were pKey, then sorted) // - /////////////////////////////////////////////////////////////////////////////// - const visibleFields: string[] = []; - columnsModel.forEach((gridColumn) => - { - const fieldName = gridColumn.field; - if (columnVisibilityModel[fieldName] !== false) - { - visibleFields.push(fieldName); - } - }); - - /////////////////////// - // zero-pad function // - /////////////////////// - const zp = (value: number): string => (value < 10 ? `0${value}` : `${value}`); - - ////////////////////////////////////// - // construct the url for the export // - ////////////////////////////////////// - const dateString = ValueUtils.formatDateTimeForFileName(new Date()); - const filename = `${tableMetaData.label} Export ${dateString}.${format}`; - const url = `/data/${tableMetaData.name}/export/${filename}`; - - const encodedFilterJSON = encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel))); - - ////////////////////////////////////////////////////////////////////////////////////// - // open a window (tab) with a little page that says the file is being generated. // - // then have that page load the url for the export. // - // If there's an error, it'll appear in that window. else, the file will download. // - ////////////////////////////////////////////////////////////////////////////////////// - const exportWindow = window.open("", "_blank"); - exportWindow.document.write(` - - - ${filename} - - - - Generating file ${filename}${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}... -
    - - -
    - - `); - - /* - // todo - probably better - generate the report in an iframe... - // only open question is, giving user immediate feedback, and knowing when the stream has started and/or stopped - // maybe a busy-loop that would check iframe's url (e.g., after posting should change, maybe?) - const iframe = document.getElementById("exportIFrame"); - const form = iframe.querySelector("form"); - form.action = url; - form.target = "exportIFrame"; - (iframe.querySelector("#authorizationInput") as HTMLInputElement).value = qController.getAuthorizationHeaderValue(); - form.submit(); - */ - - /////////////////////////////////////////// - // Hide the export menu after the export // - /////////////////////////////////////////// - hideMenu?.(); - }} - > - Export - {` ${format.toUpperCase()}`} -
    - ); - } - function getNoOfSelectedRecords() { if (selectFullFilterState === "filter") { - if(isJoinMany(tableMetaData, getVisibleJoinTables())) + if (isJoinMany(tableMetaData, getVisibleJoinTables())) { return (distinctRecords); } @@ -1300,8 +1202,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element newPath.pop(); navigate(newPath.join("/")); - console.log("calling update table for close modal"); - updateTable(); + updateTable("close modal process"); }; const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete", processLabelPart: "Load" | "Edit" | "Delete") => @@ -1361,7 +1262,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element more than one record associated with each {tableMetaData?.label}. let distinctPart = isJoinMany(tableMetaData, getVisibleJoinTables()) ? ( -  ({safeToLocaleString(distinctRecords)} distinct +  ({ValueUtils.safeToLocaleString(distinctRecords)} distinct info_outlined ) @@ -1431,12 +1332,36 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } + function doSetCurrentSavedFilter(savedFilter: QRecord) + { + setCurrentSavedFilter(savedFilter); + + if(savedFilter) + { + (async () => + { + let localTableMetaData = tableMetaData; + if(!localTableMetaData) + { + localTableMetaData = await qController.loadTableMetaData(tableName); + } + + const models = await FilterUtils.determineFilterAndSortModels(qController, localTableMetaData, savedFilter.values.get("filterJson"), null, null, null); + const newQueryFilter = FilterUtils.buildQFilterFromGridFilter(localTableMetaData, models.filter, models.sort, rowsPerPage); + // todo?? ensureAllFilterCriteriaAreActiveQuickFilters(localTableMetaData, newQueryFilter, "savedFilterSelected") + + const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(localTableMetaData, newQueryFilter); + handleFilterChange(gridFilterModel, true); + })() + } + } + async function handleSavedFilterChange(selectedSavedFilterId: number) { if (selectedSavedFilterId != null) { const qRecord = await fetchSavedFilter(selectedSavedFilterId); - setCurrentSavedFilter(qRecord); // this fixed initial load not showing filter name + doSetCurrentSavedFilter(qRecord); // this fixed initial load not showing filter name const models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, qRecord.values.get("filterJson"), null, null, null); handleFilterChange(models.filter); @@ -1533,7 +1458,28 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return ( - + + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // in advanced mode, use the default GridFilterMenuItem, which punches into the advanced/filter-builder UI // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + mode == "advanced" && + } + + { + /////////////////////////////////////////////////////////////////////////////////// + // for basic mode, use our own menu item to turn on this field as a quick-filter // + /////////////////////////////////////////////////////////////////////////////////// + mode == "basic" && + { + hideMenu(e); + // @ts-ignore !? + basicAndAdvancedQueryControlsRef.current.addField(currentColumn.field); + }}> + Filter (BASIC) TODO edit text + + } + @@ -1569,15 +1515,36 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); }); - - const safeToLocaleString = (n: Number): string => - { - if(n != null && n != undefined) + const CustomColumnHeaderFilterIconButton = forwardRef( + function ColumnHeaderFilterIconButton(props: ColumnHeaderFilterIconButtonProps, ref) { - return (n.toLocaleString()); - } - return (""); - } + if(mode == "basic") + { + let showFilter = false; + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const criteria = queryFilter.criteria[i]; + if(criteria.fieldName == props.field && criteria.operator) + { + // todo - test values too right? + showFilter = true; + } + } + + if(showFilter) + { + return ( + { + // @ts-ignore !? + basicAndAdvancedQueryControlsRef.current.addField(props.field); + + event.stopPropagation(); + }}>filter_alt); + } + } + + return (<>); + }); function CustomToolbar() { @@ -1614,9 +1581,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const joinIsMany = isJoinMany(tableMetaData, visibleJoinTables); const selectionMenuOptions: string[] = []; - selectionMenuOptions.push(`This page (${safeToLocaleString(distinctRecordsOnPageCount)} ${joinIsMany ? "distinct " : ""}record${distinctRecordsOnPageCount == 1 ? "" : "s"})`); - selectionMenuOptions.push(`Full query result (${joinIsMany ? safeToLocaleString(distinctRecords) + ` distinct record${distinctRecords == 1 ? "" : "s"}` : safeToLocaleString(totalRecords) + ` record${totalRecords == 1 ? "" : "s"}`})`); - selectionMenuOptions.push(`Subset of the query result ${selectionSubsetSize ? `(${safeToLocaleString(selectionSubsetSize)} ${joinIsMany ? "distinct " : ""}record${selectionSubsetSize == 1 ? "" : "s"})` : "..."}`); + selectionMenuOptions.push(`This page (${ValueUtils.safeToLocaleString(distinctRecordsOnPageCount)} ${joinIsMany ? "distinct " : ""}record${distinctRecordsOnPageCount == 1 ? "" : "s"})`); + selectionMenuOptions.push(`Full query result (${joinIsMany ? ValueUtils.safeToLocaleString(distinctRecords) + ` distinct record${distinctRecords == 1 ? "" : "s"}` : ValueUtils.safeToLocaleString(totalRecords) + ` record${totalRecords == 1 ? "" : "s"}`})`); + selectionMenuOptions.push(`Subset of the query result ${selectionSubsetSize ? `(${ValueUtils.safeToLocaleString(selectionSubsetSize)} ${joinIsMany ? "distinct " : ""}record${selectionSubsetSize == 1 ? "" : "s"})` : "..."}`); selectionMenuOptions.push("Clear selection"); function programmaticallySelectSomeOrAllRows(max?: number) @@ -1631,11 +1598,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element rows.forEach((value: GridRowModel, index: number) => { const primaryKeyValue = latestQueryResults[index].values.get(tableMetaData.primaryKeyField); - if(max) + if (max) { - if(selectedPrimaryKeys.size < max) + if (selectedPrimaryKeys.size < max) { - if(!selectedPrimaryKeys.has(primaryKeyValue)) + if (!selectedPrimaryKeys.has(primaryKeyValue)) { rowSelectionModel.push(value.__rowIndex); selectedPrimaryKeys.add(primaryKeyValue as string); @@ -1676,58 +1643,37 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; - const doClearFilter = (event: React.KeyboardEvent, isYesButton: boolean = false) => - { - if (isYesButton|| event.key == "Enter") + const exportMenuItemRestProps = { - setShowClearFiltersWarning(false); - handleFilterChange({items: []} as GridFilterModel); + tableMetaData: tableMetaData, + totalRecords: totalRecords, + columnsModel: columnsModel, + columnVisibilityModel: columnVisibilityModel, + queryFilter: queryFilter } - } return (
    -
    {/* @ts-ignore */}
    - {/* @ts-ignore */} - - { - hasValidFilters && ( -
    - - setShowClearFiltersWarning(true)}>clear - - setShowClearFiltersWarning(false)} onKeyPress={(e) => doClearFilter(e)}> - Confirm - - Are you sure you want to remove all conditions from the current filter? - - - setShowClearFiltersWarning(false)} /> - doClearFilter(null, true)}/> - - -
    - ) - } {/* @ts-ignore */} {/* @ts-ignore */} - - - + + +
    - + { setSelectionSubsetSizePromptOpen(false); @@ -1778,14 +1724,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { selectFullFilterState === "filterSubset" && (
    - The setSelectionSubsetSizePromptOpen(true)} style={{cursor: "pointer"}}>first {safeToLocaleString(selectionSubsetSize)} {joinIsMany ? "distinct" : ""} record{selectionSubsetSize == 1 ? "" : "s"} matching this query {selectionSubsetSize == 1 ? "is" : "are"} selected. + The setSelectionSubsetSizePromptOpen(true)} style={{cursor: "pointer"}}>first {ValueUtils.safeToLocaleString(selectionSubsetSize)} {joinIsMany ? "distinct" : ""} record{selectionSubsetSize == 1 ? "" : "s"} matching this query {selectionSubsetSize == 1 ? "is" : "are"} selected.
    ) } { (selectFullFilterState === "n/a" && selectedIds.length > 0) && (
    - {safeToLocaleString(selectedIds.length)} {joinIsMany ? "distinct" : ""} {selectedIds.length == 1 ? "record is" : "records are"} selected. + {ValueUtils.safeToLocaleString(selectedIds.length)} {joinIsMany ? "distinct" : ""} {selectedIds.length == 1 ? "record is" : "records are"} selected.
    ) } @@ -1875,34 +1821,27 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // to avoid both this useEffect and the one below from both doing an "initial query", // // only run this one if at least 1 query has already been ran // //////////////////////////////////////////////////////////////////////////////////////// - updateTable(); + updateTable("useEffect(pageNumber,rowsPerPage,columnSortModel,currentSavedFilter)"); } }, [pageNumber, rowsPerPage, columnSortModel, currentSavedFilter]); /////////////////////////////////////////////////////////////////////////////////////////////////////////////// // for state changes that DO change the filter, call to update the table - and DO clear out the totalRecords // /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - useEffect(() => - { - setTotalRecords(null); - setDistinctRecords(null); - updateTable(); - }, [columnsModel, tableState, tableVariant]); - useEffect(() => { const currentQFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage); currentQFilter.skip = pageNumber * rowsPerPage; const currentQFilterJSON = JSON.stringify(currentQFilter); + const currentVariantJSON = JSON.stringify(tableVariant); - if(currentQFilterJSON !== lastFetchedQFilterJSON) + if(currentQFilterJSON !== lastFetchedQFilterJSON || currentVariantJSON !== lastFetchedVariant) { setTotalRecords(null); setDistinctRecords(null); - updateTable(); + updateTable("useEffect(filterModel)"); } - - }, [filterModel]); + }, [filterModel, columnsModel, tableState, tableVariant]); useEffect(() => { @@ -1963,141 +1902,33 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } - const updateQuickCriteria = (newCriteria: QFilterCriteria, needDebounce = false, doClearCriteria = false) => + const doSetMode = (newValue: string) => { - let found = false; - let foundIndex = null; - for (let i = 0; i < queryFilter?.criteria?.length; i++) - { - if(queryFilter.criteria[i].fieldName == newCriteria.fieldName) - { - queryFilter.criteria[i] = newCriteria; - found = true; - foundIndex = i; - break; - } - } - - if(doClearCriteria) - { - if(found) - { - queryFilter.criteria.splice(foundIndex, 1); - setQueryFilter(queryFilter); - const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); - handleFilterChange(gridFilterModel, false); - } - return; - } - - if(!found) - { - if(!queryFilter.criteria) - { - queryFilter.criteria = []; - } - queryFilter.criteria.push(newCriteria); - found = true; - } - - if(found) - { - clearTimeout(debounceTimeout) - debounceTimeout = setTimeout(() => - { - setQueryFilter(queryFilter); - const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); - handleFilterChange(gridFilterModel, false); - }, needDebounce ? 500 : 1); - - forceUpdate(); - } - }; - - - const getQuickCriteriaParam = (fieldName: string): QFilterCriteriaWithId | null | "tooComplex" => - { - const matches: QFilterCriteriaWithId[] = []; - for (let i = 0; i < queryFilter?.criteria?.length; i++) - { - if(queryFilter.criteria[i].fieldName == fieldName) - { - matches.push(queryFilter.criteria[i] as QFilterCriteriaWithId); - } - } - - if(matches.length == 0) - { - return (null); - } - else if(matches.length == 1) - { - return (matches[0]); - } - else - { - return "tooComplex"; - } + setMode(newValue); + localStorage.setItem(modeLocalStorageKey, newValue); } - const toggleQuickFilterField = (fieldName: string): void => + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // for basic mode, set a custom ColumnHeaderFilterIconButton - w/ action to activate basic-mode quick-filter // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + let restOfDataGridProCustomComponents: any = {} + if(mode == "basic") { - if(quickFilterFieldNames.has(fieldName)) - { - quickFilterFieldNames.delete(fieldName); - } - else - { - quickFilterFieldNames.add(fieldName); - } - setQuickFilterFieldNames(new Set([...quickFilterFieldNames.values()])) - localStorage.setItem(quickFilterFieldNamesLocalStorageKey, JSON.stringify([...quickFilterFieldNames.values()])); - - // damnit, not auto-updating in the filter panel... have to click twice most of the time w/o this hacky hack. - setTimeout(() => forceUpdate(), 10); - } - - const openAddQuickFilterMenu = (event: any) => - { - setAddQuickFilterMenu(event.currentTarget); - setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1); - } - - const closeAddQuickFilterMenu = () => - { - setAddQuickFilterMenu(null); - } - - function addQuickFilterField(event: any, newValue: any, reason: string) - { - if(reason == "blur") - { - ////////////////////////////////////////////////////////////////// - // this keeps a click out of the menu from selecting the option // - ////////////////////////////////////////////////////////////////// - return; - } - - const fieldName = newValue ? newValue.fieldName : null - if(fieldName) - { - toggleQuickFilterField(fieldName); - closeAddQuickFilterMenu(); - } + restOfDataGridProCustomComponents.ColumnHeaderFilterIconButton = CustomColumnHeaderFilterIconButton; } return (
    {/* - // see above code that would use this + // see code in ExportMenuItem that would use this */} - + {alertContent ? ( - - - Fields that you frequently use for filter conditions can be added here for quick access.

    - Use the - add_circle_outline - button to add a Quick Filter field.

    - To remove a Quick Filter field, click the field name, and then use the - highlight_off - button. -
    } placement="left"> - Quick Filter: - - { - metaData && tableMetaData && - <> - - openAddQuickFilterMenu(e)} size="small" disableRipple>add_circle_outline - - - - - - - - } - { - tableMetaData && - [...quickFilterFieldNames.values()].map((fieldName) => - { - // todo - join fields... - // todo - sometimes i want contains (client.name, for example...) - - const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName); - let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS - if(field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE) - { - defaultOperator = QCriteriaOperator.GREATER_THAN; - } - - return ( - field && - ) - }) - } -
    + { + metaData && tableMetaData && + + } @@ -2232,7 +2008,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element LoadingOverlay: Loading, ColumnMenu: CustomColumnMenu, ColumnsPanel: CustomColumnsPanel, - FilterPanel: CustomFilterPanel + FilterPanel: CustomFilterPanel, + ... restOfDataGridProCustomComponents }} componentsProps={{ columnsPanel: @@ -2250,9 +2027,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element metaData: metaData, queryFilter: queryFilter, updateFilter: updateFilterFromFilterPanel, - quickFilterFieldNames: quickFilterFieldNames, - showQuickFilterPin: true, - toggleQuickFilterField: toggleQuickFilterField, } }} localeText={{ @@ -2296,7 +2070,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element getRowId={(row) => row.__rowIndex} selectionModel={rowSelectionModel} hideFooterSelectedRowCount={true} - sx={{border: 0, height: "calc(100vh - 250px)"}} + sx={{border: 0, height: tableMetaData?.usesVariants ? "calc(100vh - 300px)" : "calc(100vh - 270px)"}} /> @@ -2342,136 +2116,4 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } -//////////////////////////////////////////////////////////////////////////////////////////////////////// -// mini-component that is the dialog for the user to select a variant on tables with variant backends // -//////////////////////////////////////////////////////////////////////////////////////////////////////// -function TableVariantDialog(props: {isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void}) -{ - const [value, setValue] = useState(null) - const [dropDownOpen, setDropDownOpen] = useState(false) - const [variants, setVariants] = useState(null); - - const handleVariantChange = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) => - { - const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${props.table.name}`; - if(value != null) - { - localStorage.setItem(tableVariantLocalStorageKey, JSON.stringify(value)); - } - else - { - localStorage.removeItem(tableVariantLocalStorageKey); - } - props.closeHandler(value); - }; - - const keyPressed = (e: React.KeyboardEvent) => - { - if(e.key == "Enter" && value) - { - props.closeHandler(value); - } - } - - useEffect(() => - { - console.log("queryVariants") - try - { - (async () => - { - const variants = await qController.tableVariants(props.table.name); - console.log(JSON.stringify(variants)); - setVariants(variants); - })(); - } - catch (e) - { - console.log(e); - } - }, []); - - - return variants && ( - keyPressed(e)}> - {props.table.variantTableLabel} - - Select the {props.table.variantTableLabel} to be used on this table: - - { - setDropDownOpen(true); - }} - onClose={() => - { - setDropDownOpen(false); - }} - // @ts-ignore - onChange={handleVariantChange} - isOptionEqualToValue={(option, value) => option.id === value.id} - options={variants} - renderInput={(params) => } - getOptionLabel={(option) => - { - if(typeof option == "object") - { - return (option as QTableVariant).name; - } - return option; - }} - /> - - - ) -} - -////////////////////////////////////////////////////////////////////////////////// -// mini-component that is the dialog for the user to enter the selection-subset // -////////////////////////////////////////////////////////////////////////////////// -function SelectionSubsetDialog(props: {isOpen: boolean; initialValue: number; closeHandler: (value?: number) => void}) -{ - const [value, setValue] = useState(props.initialValue) - - const handleChange = (newValue: string) => - { - setValue(parseInt(newValue)) - } - - const keyPressed = (e: React.KeyboardEvent) => - { - if(e.key == "Enter" && value) - { - props.closeHandler(value); - } - } - - return ( - props.closeHandler()} onKeyPress={(e) => keyPressed(e)}> - Subset of the Query Result - - How many records do you want to select? - handleChange(e.target.value)} - value={value} - sx={{width: "100%"}} - onFocus={event => event.target.select()} - /> - - - props.closeHandler()} /> - props.closeHandler(value)} /> - - - ) -} - - - export default RecordQuery; diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index b9aba6b..b764304 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -424,6 +424,12 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } top: -60px !important; } +.MuiDataGrid-panel:has(.customFilterPanel) +{ + /* overwrite what the grid tries to do here, where it changes based on density... we always want the same. */ + transform: translate(274px, 305px) !important; +} + /* within the data-grid, the filter-panel's container has a max-height. mirror that, and set an overflow-y */ .MuiDataGrid-panel .customFilterPanel { diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 6fbb489..2feb61d 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -358,9 +358,9 @@ class FilterUtils //////////////////////////////////////////////////////////////////////////////////////////////// if (fieldType === QFieldType.DATE_TIME) { - for(let i = 0; i { - if(item.value && item.value.length) + if (item.value && item.value.length) { for (let i = 0; i < item.value.length; i++) { - const expression = this.gridCriteriaValueToExpression(item.value[i]) - if(expression) + const expression = this.gridCriteriaValueToExpression(item.value[i]); + if (expression) { item.value[i] = expression; } @@ -525,8 +525,8 @@ class FilterUtils } else { - const expression = this.gridCriteriaValueToExpression(item.value) - if(expression) + const expression = this.gridCriteriaValueToExpression(item.value); + if (expression) { item.value = expression; } @@ -641,7 +641,7 @@ class FilterUtils let incomplete = false; if (item.operatorValue === "between" || item.operatorValue === "notBetween") { - if(!item.value || !item.value.length || item.value.length < 2 || this.isUnset(item.value[0]) || this.isUnset(item.value[1])) + if (!item.value || !item.value.length || item.value.length < 2 || this.isUnset(item.value[0]) || this.isUnset(item.value[1])) { incomplete = true; } @@ -747,6 +747,103 @@ class FilterUtils return (filter); } + + /******************************************************************************* + ** + *******************************************************************************/ + public static canFilterWorkAsBasic(tableMetaData: QTableMetaData, filter: QQueryFilter): { canFilterWorkAsBasic: boolean; reasonsWhyItCannot?: string[] } + { + const reasonsWhyItCannot: string[] = []; + + if(filter == null) + { + return ({canFilterWorkAsBasic: true}); + } + + if(filter.booleanOperator == "OR") + { + reasonsWhyItCannot.push("Filter uses the 'OR' operator.") + } + + if(filter.criteria) + { + const usedFields: {[name: string]: boolean} = {}; + const warnedFields: {[name: string]: boolean} = {}; + for (let i = 0; i < filter.criteria.length; i++) + { + const criteriaName = filter.criteria[i].fieldName; + if(!criteriaName) + { + continue; + } + + if(usedFields[criteriaName]) + { + if(!warnedFields[criteriaName]) + { + const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, criteriaName); + let fieldLabel = field.label; + if(tableForField.name != tableMetaData.name) + { + let fieldLabel = `${tableForField.label}: ${field.label}`; + } + reasonsWhyItCannot.push(`Filter contains more than 1 condition for the field: ${fieldLabel}`); + warnedFields[criteriaName] = true; + } + } + usedFields[criteriaName] = true; + } + } + + if(reasonsWhyItCannot.length == 0) + { + return ({canFilterWorkAsBasic: true}); + } + else + { + return ({canFilterWorkAsBasic: false, reasonsWhyItCannot: reasonsWhyItCannot}); + } + } + + /******************************************************************************* + ** get the values associated with a criteria as a string, e.g., for showing + ** in a tooltip. + *******************************************************************************/ + public static getValuesString(fieldMetaData: QFieldMetaData, criteria: QFilterCriteria, maxValuesToShow: number = 3): string + { + let valuesString = ""; + if (criteria.values && criteria.values.length && fieldMetaData.type !== QFieldType.BOOLEAN) + { + let labels = [] as string[]; + + let maxLoops = criteria.values.length; + if (maxLoops > (maxValuesToShow + 2)) + { + maxLoops = maxValuesToShow; + } + + for (let i = 0; i < maxLoops; i++) + { + if (criteria.values[i] && criteria.values[i].label) + { + labels.push(criteria.values[i].label); + } + else + { + labels.push(criteria.values[i]); + } + } + + if (maxLoops < criteria.values.length) + { + labels.push(" and " + (criteria.values.length - maxLoops) + " other values."); + } + + valuesString = (labels.join(", ")); + } + return valuesString; + } + } export default FilterUtils; diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 909ec04..e786336 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -462,6 +462,19 @@ class ValueUtils return (String(param).replaceAll(/"/g, "\"\"")); } + + /******************************************************************************* + ** + *******************************************************************************/ + public static safeToLocaleString(n: Number): string + { + if (n != null && n != undefined) + { + return (n.toLocaleString()); + } + return (""); + } + } //////////////////////////////////////////////////////////////////////////////////////////////// From 7fbd3ce85334b2005d45178715037f5df0808f5c Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 23 Jan 2024 14:03:44 -0600 Subject: [PATCH 07/40] CE-798 - Add defaultQuickFilterFieldNames as table meta-data, along with instance validation; add junit (for the validation logic) --- .../MaterialDashboardTableMetaData.java | 79 +++++++- .../materialdashboard/junit/BaseTest.java | 75 ++++++++ .../materialdashboard/junit/TestUtils.java | 101 ++++++++++ .../MaterialDashboardTableMetaDataTest.java | 178 ++++++++++++++++++ 4 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/BaseTest.java create mode 100644 src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/TestUtils.java create mode 100644 src/test/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaDataTest.java diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java index 6ce1abd..d40cdb0 100644 --- a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java @@ -22,17 +22,23 @@ package com.kingsrook.qqq.frontend.materialdashboard.model.metadata; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; /******************************************************************************* - ** + ** table-level meta-data for this module (handled as QSupplementalTableMetaData) *******************************************************************************/ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData { private List> gotoFieldNames; - + private List defaultQuickFilterFieldNames; /******************************************************************************* @@ -86,4 +92,73 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData return (this); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void validate(QInstance qInstance, QTableMetaData tableMetaData, QInstanceValidator qInstanceValidator) + { + super.validate(qInstance, tableMetaData, qInstanceValidator); + + String prefix = "MaterialDashboardTableMetaData supplementalTableMetaData for table [" + tableMetaData.getName() + "] "; + + for(List gotoFieldNameSubList : CollectionUtils.nonNullList(gotoFieldNames)) + { + qInstanceValidator.assertCondition(!gotoFieldNameSubList.isEmpty(), prefix + "has an empty gotoFieldNames list"); + validateListOfFieldNames(tableMetaData, gotoFieldNameSubList, qInstanceValidator, prefix + "gotoFieldNames: "); + } + validateListOfFieldNames(tableMetaData, defaultQuickFilterFieldNames, qInstanceValidator, prefix + "defaultQuickFilterFieldNames: "); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void validateListOfFieldNames(QTableMetaData tableMetaData, List fieldNames, QInstanceValidator qInstanceValidator, String prefix) + { + Set usedNames = new HashSet<>(); + for(String fieldName : CollectionUtils.nonNullList(fieldNames)) + { + if(qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldName), prefix + " unrecognized field name: " + fieldName)) + { + qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + " has a duplicated field name: " + fieldName); + usedNames.add(fieldName); + } + } + } + + + + /******************************************************************************* + ** Getter for defaultQuickFilterFieldNames + *******************************************************************************/ + public List getDefaultQuickFilterFieldNames() + { + return (this.defaultQuickFilterFieldNames); + } + + + + /******************************************************************************* + ** Setter for defaultQuickFilterFieldNames + *******************************************************************************/ + public void setDefaultQuickFilterFieldNames(List defaultQuickFilterFieldNames) + { + this.defaultQuickFilterFieldNames = defaultQuickFilterFieldNames; + } + + + + /******************************************************************************* + ** Fluent setter for defaultQuickFilterFieldNames + *******************************************************************************/ + public MaterialDashboardTableMetaData withDefaultQuickFilterFieldNames(List defaultQuickFilterFieldNames) + { + this.defaultQuickFilterFieldNames = defaultQuickFilterFieldNames; + return (this); + } + } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/BaseTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/BaseTest.java new file mode 100644 index 0000000..0b0b0bd --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/BaseTest.java @@ -0,0 +1,75 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.junit; + + +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class BaseTest +{ + private static final QLogger LOG = QLogger.getLogger(BaseTest.class); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void baseBeforeEach() + { + QContext.init(TestUtils.defineInstance(), new QSession()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void baseAfterEach() + { + QContext.clear(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + protected static void reInitInstanceInContext(QInstance qInstance) + { + if(qInstance.equals(QContext.getQInstance())) + { + LOG.warn("Unexpected condition - the same qInstance that is already in the QContext was passed into reInit. You probably want a new QInstance object instance."); + } + QContext.init(qInstance, new QSession()); + } +} diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/TestUtils.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/TestUtils.java new file mode 100644 index 0000000..4f4d485 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/TestUtils.java @@ -0,0 +1,101 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.junit; + + +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class TestUtils +{ + public static final String DEFAULT_BACKEND_NAME = "memoryBackend"; + public static final String TABLE_NAME_PERSON = "person"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static QInstance defineInstance() + { + QInstance qInstance = new QInstance(); + qInstance.addBackend(defineBackend()); + qInstance.addTable(defineTablePerson()); + qInstance.setAuthentication(defineAuthentication()); + return (qInstance); + } + + + + /******************************************************************************* + ** Define the authentication used in standard tests - using 'mock' type. + ** + *******************************************************************************/ + public static QAuthenticationMetaData defineAuthentication() + { + return new QAuthenticationMetaData() + .withName("mock") + .withType(QAuthenticationType.MOCK); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static QBackendMetaData defineBackend() + { + return (new QBackendMetaData() + .withName(DEFAULT_BACKEND_NAME) + .withBackendType("memory")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QTableMetaData defineTablePerson() + { + return new QTableMetaData() + .withName(TABLE_NAME_PERSON) + .withLabel("Person") + .withBackendName(DEFAULT_BACKEND_NAME) + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false)) + .withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)) + .withField(new QFieldMetaData("firstName", QFieldType.STRING)) + .withField(new QFieldMetaData("lastName", QFieldType.STRING)) + .withField(new QFieldMetaData("birthDate", QFieldType.DATE)) + .withField(new QFieldMetaData("email", QFieldType.STRING)); + } +} diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaDataTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaDataTest.java new file mode 100644 index 0000000..63c7521 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaDataTest.java @@ -0,0 +1,178 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.model.metadata; + + +import java.util.List; +import java.util.function.Consumer; +import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.frontend.materialdashboard.junit.BaseTest; +import com.kingsrook.qqq.frontend.materialdashboard.junit.TestUtils; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + + +/******************************************************************************* + ** Unit test for MaterialDashboardTableMetaData + *******************************************************************************/ +class MaterialDashboardTableMetaDataTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testValidateGoToFieldNames() + { + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of()))), + "empty gotoFieldNames list"); + + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of("foo")))), + "unrecognized field name: foo"); + + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of("foo"), List.of("bar", "baz")))), + "unrecognized field name: foo", + "unrecognized field name: bar", + "unrecognized field name: baz"); + + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of("firstName", "firstName")))), + "duplicated field name: firstName"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testValidateQuickFilterFieldNames() + { + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withDefaultQuickFilterFieldNames(List.of("foo"))), + "unrecognized field name: foo"); + + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withDefaultQuickFilterFieldNames(List.of("firstName", "lastName", "firstName"))), + "duplicated field name: firstName"); + + } + + ////////////////////////////////////////////////////////////////////////// + // todo - methods below here were copied from QInstanceValidatorTest... // + // how to share those... // + ////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Run a little setup code on a qInstance; then validate it, and assert that it + ** failed validation with reasons that match the supplied vararg-reasons (but allow + ** more reasons - e.g., helpful when one thing we're testing causes other errors). + *******************************************************************************/ + private void assertValidationFailureReasonsAllowingExtraReasons(Consumer setup, String... reasons) + { + assertValidationFailureReasons(setup, true, reasons); + } + + + + /******************************************************************************* + ** Run a little setup code on a qInstance; then validate it, and assert that it + ** failed validation with reasons that match the supplied vararg-reasons (and + ** require that exact # of reasons). + *******************************************************************************/ + private void assertValidationFailureReasons(Consumer setup, String... reasons) + { + assertValidationFailureReasons(setup, false, reasons); + } + + + + /******************************************************************************* + ** Implementation for the overloads of this name. + *******************************************************************************/ + private void assertValidationFailureReasons(Consumer setup, boolean allowExtraReasons, String... reasons) + { + try + { + QInstance qInstance = TestUtils.defineInstance(); + setup.accept(qInstance); + new QInstanceValidator().validate(qInstance); + fail("Should have thrown validationException"); + } + catch(QInstanceValidationException e) + { + if(!allowExtraReasons) + { + int noOfReasons = e.getReasons() == null ? 0 : e.getReasons().size(); + assertEquals(reasons.length, noOfReasons, "Expected number of validation failure reasons.\nExpected reasons: " + String.join(",", reasons) + + "\nActual reasons: " + (noOfReasons > 0 ? String.join("\n", e.getReasons()) : "--")); + } + + for(String reason : reasons) + { + assertReason(reason, e); + } + } + } + + + + /******************************************************************************* + ** Assert that an instance is valid! + *******************************************************************************/ + private void assertValidationSuccess(Consumer setup) + { + try + { + QInstance qInstance = TestUtils.defineInstance(); + setup.accept(qInstance); + new QInstanceValidator().validate(qInstance); + } + catch(QInstanceValidationException e) + { + fail("Expected no validation errors, but received: " + e.getMessage()); + } + } + + + + /******************************************************************************* + ** utility method for asserting that a specific reason string is found within + ** the list of reasons in the QInstanceValidationException. + ** + *******************************************************************************/ + private void assertReason(String reason, QInstanceValidationException e) + { + assertNotNull(e.getReasons(), "Expected there to be a reason for the failure (but there was not)"); + assertThat(e.getReasons()) + .withFailMessage("Expected any of:\n%s\nTo match: [%s]", e.getReasons(), reason) + .anyMatch(s -> s.contains(reason)); + } + + ///////////////////////////////////////////////////////////////// + // todo - end of methods copied from QInstanceValidatorTest... // + ///////////////////////////////////////////////////////////////// +} \ No newline at end of file From 546f544373ddc2931549bb766f5c6e0b4281cc9c Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 23 Jan 2024 14:04:33 -0600 Subject: [PATCH 08/40] CE-798 - Update package for selenium tests in here --- .../selenium}/lib/QBaseSeleniumTest.java | 25 ++++++++++++++-- .../lib/QQQMaterialDashboardSelectors.java | 4 +-- .../selenium}/lib/QSeleniumLib.java | 4 +-- .../lib/javalin/CapturedContext.java | 23 ++++++++++++++- .../lib/javalin/CapturingHandler.java | 23 ++++++++++++++- .../lib/javalin/QSeleniumJavalin.java | 25 ++++++++++++++-- .../lib/javalin/RouteFromFileHandler.java | 23 ++++++++++++++- .../lib/javalin/RouteFromStringHandler.java | 23 ++++++++++++++- .../selenium}/tests/AppPageNavTest.java | 10 +++---- .../tests/AssociatedRecordScriptTest.java | 8 ++--- .../selenium}/tests/AuditTest.java | 10 +++---- .../selenium}/tests/BulkEditTest.java | 8 ++--- ...ClickLinkOnRecordThenEditShortcutTest.java | 8 ++--- .../tests/DashboardTableWidgetExportTest.java | 8 ++--- .../tests/QueryScreenFilterInUrlTest.java | 10 +++---- .../selenium}/tests/QueryScreenTest.java | 29 ++++++++++--------- .../selenium}/tests/SavedFiltersTest.java | 12 ++++---- .../selenium}/tests/ScriptTableTest.java | 8 ++--- 18 files changed, 195 insertions(+), 66 deletions(-) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/lib/QBaseSeleniumTest.java (85%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/lib/QQQMaterialDashboardSelectors.java (92%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/lib/QSeleniumLib.java (99%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/lib/javalin/CapturedContext.java (70%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/lib/javalin/CapturingHandler.java (59%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/lib/javalin/QSeleniumJavalin.java (90%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/lib/javalin/RouteFromFileHandler.java (65%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/lib/javalin/RouteFromStringHandler.java (60%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/tests/AppPageNavTest.java (88%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/tests/AssociatedRecordScriptTest.java (89%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/tests/AuditTest.java (94%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/tests/BulkEditTest.java (94%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/tests/ClickLinkOnRecordThenEditShortcutTest.java (89%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/tests/DashboardTableWidgetExportTest.java (94%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/tests/QueryScreenFilterInUrlTest.java (95%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/tests/QueryScreenTest.java (88%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/tests/SavedFiltersTest.java (94%) rename src/test/java/com/kingsrook/qqq/{materialdashboard => frontend/materialdashboard/selenium}/tests/ScriptTableTest.java (89%) diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QBaseSeleniumTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QBaseSeleniumTest.java similarity index 85% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/QBaseSeleniumTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QBaseSeleniumTest.java index bb88fc1..5ffd961 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QBaseSeleniumTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QBaseSeleniumTest.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib; import java.io.File; @@ -6,7 +27,7 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import io.github.bonigarcia.wdm.WebDriverManager; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QQQMaterialDashboardSelectors.java similarity index 92% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QQQMaterialDashboardSelectors.java index 91254ab..654f278 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QQQMaterialDashboardSelectors.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2023. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,7 +19,7 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.lib; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib; /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java similarity index 99% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java index ebd239f..8b3248a 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2023. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,7 +19,7 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.lib; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib; import java.io.File; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturedContext.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturedContext.java similarity index 70% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturedContext.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturedContext.java index 4a8836d..dd091ea 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturedContext.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturedContext.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib.javalin; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin; import io.javalin.http.Context; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturingHandler.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturingHandler.java similarity index 59% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturingHandler.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturingHandler.java index ea0f555..4cff650 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturingHandler.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturingHandler.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib.javalin; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin; import io.javalin.http.Context; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/QSeleniumJavalin.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/QSeleniumJavalin.java similarity index 90% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/QSeleniumJavalin.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/QSeleniumJavalin.java index 455b34b..864b2db 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/QSeleniumJavalin.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/QSeleniumJavalin.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib.javalin; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin; import java.util.ArrayList; @@ -6,7 +27,7 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import com.kingsrook.qqq.materialdashboard.lib.QSeleniumLib; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib; import io.javalin.Javalin; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromFileHandler.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromFileHandler.java similarity index 65% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromFileHandler.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromFileHandler.java index 3861623..c3e1325 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromFileHandler.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromFileHandler.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib.javalin; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin; import java.nio.charset.StandardCharsets; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromStringHandler.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromStringHandler.java similarity index 60% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromStringHandler.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromStringHandler.java index 9de15a8..36db7e4 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromStringHandler.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromStringHandler.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib.javalin; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin; import io.javalin.http.Context; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AppPageNavTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AppPageNavTest.java similarity index 88% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/AppPageNavTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AppPageNavTest.java index 1a3c6c3..8daf188 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AppPageNavTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AppPageNavTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,12 +19,12 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.QQQMaterialDashboardSelectors; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AssociatedRecordScriptTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java similarity index 89% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/AssociatedRecordScriptTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java index 737d32a..58f333c 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AssociatedRecordScriptTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,11 +19,11 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AuditTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AuditTest.java similarity index 94% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/AuditTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AuditTest.java index 907d34b..b7443a0 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AuditTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AuditTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,13 +19,13 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; import java.util.List; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import org.openqa.selenium.WebElement; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java similarity index 94% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java index cc47c27..2a247dd 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,11 +19,11 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/ClickLinkOnRecordThenEditShortcutTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ClickLinkOnRecordThenEditShortcutTest.java similarity index 89% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/ClickLinkOnRecordThenEditShortcutTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ClickLinkOnRecordThenEditShortcutTest.java index 741b6ef..35ed7dd 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/ClickLinkOnRecordThenEditShortcutTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ClickLinkOnRecordThenEditShortcutTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,11 +19,11 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/DashboardTableWidgetExportTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java similarity index 94% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/DashboardTableWidgetExportTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java index 4959bd8..b090ce8 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/DashboardTableWidgetExportTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,14 +19,14 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenFilterInUrlTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlTest.java similarity index 95% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenFilterInUrlTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlTest.java index 77e8b0e..76115d7 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenFilterInUrlTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,7 +19,7 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; import java.net.URLEncoder; @@ -31,9 +31,9 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset; import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.ThisOrLastPeriod; import com.kingsrook.qqq.backend.core.utils.JsonUtils; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.QQQMaterialDashboardSelectors; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import org.openqa.selenium.WebElement; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java similarity index 88% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java index 2bc5fe0..1f1a0d0 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,14 +19,14 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.QQQMaterialDashboardSelectors; -import com.kingsrook.qqq.materialdashboard.lib.QSeleniumLib; -import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; import org.openqa.selenium.Keys; @@ -69,13 +69,14 @@ public class QueryScreenTest extends QBaseSeleniumTest ///////////////////////////////////////////////////////////////////// // open the filter window, enter a value, wait for query to re-run // ///////////////////////////////////////////////////////////////////// - WebElement filterInput = qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_FILTER_INPUT); - qSeleniumLib.waitForElementToHaveFocus(filterInput); - filterInput.sendKeys("id"); - filterInput.sendKeys("\t"); - driver.switchTo().activeElement().sendKeys("\t"); qSeleniumJavalin.beginCapture(); - driver.switchTo().activeElement().sendKeys("1"); + addQueryFilterInput(qSeleniumLib, 0, "Id", "equals", "1", null); + // WebElement filterInput = qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_FILTER_INPUT); + // qSeleniumLib.waitForElementToHaveFocus(filterInput); + // filterInput.sendKeys("id"); + // filterInput.sendKeys("\t"); + // driver.switchTo().activeElement().sendKeys("\t"); + // driver.switchTo().activeElement().sendKeys("1" + "\t"); /////////////////////////////////////////////////////////////////// // assert that query & count both have the expected filter value // @@ -189,4 +190,6 @@ public class QueryScreenTest extends QBaseSeleniumTest qSeleniumLib.waitForMillis(100); } + // todo - table requires variant - prompt for it, choose it, see query; change variant, change on-screen, re-query + } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedFiltersTest.java similarity index 94% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedFiltersTest.java index e3fc8ad..4af9f9d 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedFiltersTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,17 +19,17 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; -import static com.kingsrook.qqq.materialdashboard.tests.QueryScreenTest.addQueryFilterInput; +import static com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.QueryScreenTest.addQueryFilterInput; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/ScriptTableTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ScriptTableTest.java similarity index 89% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/ScriptTableTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ScriptTableTest.java index c6d1e2b..e935d45 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/ScriptTableTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ScriptTableTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,11 +19,11 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; From 61776bedb3fa4d020c28237755ca5a21a83b8a3f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 23 Jan 2024 15:06:24 -0600 Subject: [PATCH 09/40] CE-798 update qqq-backend-core version for this story --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 13b545a..3b1d74f 100644 --- a/pom.xml +++ b/pom.xml @@ -66,7 +66,7 @@ com.kingsrook.qqq qqq-backend-core - 0.17.0-SNAPSHOT + feature-CE-798-quick-filters-20240123.205854-1 org.slf4j From f6b271363961069a49880371d1c66892ec9ef029 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 23 Jan 2024 20:32:32 -0600 Subject: [PATCH 10/40] CE-798 - attempt at passing all query tests, and a smidge of new new tests. --- .../selenium/lib/QSeleniumLib.java | 20 +++ .../selenium/lib/QueryScreenLib.java | 169 ++++++++++++++++++ .../tests/AssociatedRecordScriptTest.java | 2 +- .../tests/DashboardTableWidgetExportTest.java | 2 +- ...eryScreenFilterInUrlAdvancedModeTest.java} | 83 ++++----- .../QueryScreenFilterInUrlBasicModeTest.java | 159 ++++++++++++++++ .../selenium/tests/QueryScreenTest.java | 90 ++-------- .../selenium/tests/SavedFiltersTest.java | 17 +- 8 files changed, 408 insertions(+), 134 deletions(-) create mode 100644 src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QueryScreenLib.java rename src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/{QueryScreenFilterInUrlTest.java => QueryScreenFilterInUrlAdvancedModeTest.java} (80%) create mode 100755 src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java index 8b3248a..37a9351 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java @@ -209,6 +209,26 @@ public class QSeleniumLib + /******************************************************************************* + ** + *******************************************************************************/ + public void clickBackdrop() + { + for(WebElement webElement : this.waitForSelectorAll(".MuiBackdrop-root", 0)) + { + try + { + webElement.click(); + } + catch(Exception e) + { + // ignore. + } + } + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QueryScreenLib.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QueryScreenLib.java new file mode 100644 index 0000000..7d6a9a5 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QueryScreenLib.java @@ -0,0 +1,169 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib; + + +import org.openqa.selenium.By; +import org.openqa.selenium.Keys; +import org.openqa.selenium.WebElement; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class QueryScreenLib +{ + private final QSeleniumLib qSeleniumLib; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public QueryScreenLib(QSeleniumLib qSeleniumLib) + { + this.qSeleniumLib = qSeleniumLib; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public WebElement assertFilterButtonBadge(int valueInBadge) + { + return qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", String.valueOf(valueInBadge)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public WebElement waitForQueryToHaveRan() + { + return qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void clickFilterButton() + { + qSeleniumLib.waitForSelectorContaining("BUTTON", "FILTER BUILDER").click(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public WebElement assertQuickFilterButtonBadge(String fieldName) + { + return qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName + " .MuiBadge-root"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void clickQuickFilterButton(String fieldName) + { + // qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); + qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName).click(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void gotoAdvancedMode() + { + qSeleniumLib.waitForSelectorContaining("BUTTON", "ADVANCED").click(); + qSeleniumLib.waitForSelectorContaining("BUTTON", "FILTER BUILDER"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void gotoBasicMode() + { + qSeleniumLib.waitForSelectorContaining("BUTTON", "BASIC").click(); + qSeleniumLib.waitForSelectorContaining("BUTTON", "ADD FILTER"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void addQueryFilterInput(QSeleniumLib qSeleniumLib, int index, String fieldLabel, String operator, String value, String booleanOperator) + { + if(index > 0) + { + qSeleniumLib.waitForSelectorContaining("BUTTON", "Add condition").click(); + } + + WebElement subFormForField = qSeleniumLib.waitForSelectorAll(".filterCriteriaRow", index + 1).get(index); + + if(index == 1) + { + WebElement booleanOperatorInput = subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); + booleanOperatorInput.click(); + qSeleniumLib.waitForMillis(100); + + subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); + qSeleniumLib.waitForSelectorContaining("li", booleanOperator).click(); + qSeleniumLib.waitForMillis(100); + } + + WebElement fieldInput = subFormForField.findElement(By.cssSelector(".fieldColumn INPUT")); + fieldInput.click(); + qSeleniumLib.waitForMillis(100); + fieldInput.clear(); + fieldInput.sendKeys(fieldLabel); + qSeleniumLib.waitForMillis(100); + fieldInput.sendKeys("\n"); + qSeleniumLib.waitForMillis(100); + + WebElement operatorInput = subFormForField.findElement(By.cssSelector(".operatorColumn INPUT")); + operatorInput.click(); + qSeleniumLib.waitForMillis(100); + operatorInput.sendKeys(Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, operator); + qSeleniumLib.waitForMillis(100); + operatorInput.sendKeys("\n"); + qSeleniumLib.waitForMillis(100); + + WebElement valueInput = subFormForField.findElement(By.cssSelector(".filterValuesColumn INPUT")); + valueInput.click(); + valueInput.sendKeys(value); + qSeleniumLib.waitForMillis(100); + } + +} diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java index 58f333c..fbbce56 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java @@ -59,7 +59,7 @@ public class AssociatedRecordScriptTest extends QBaseSeleniumTest qSeleniumLib.waitForSelectorContaining("LI", "Developer Mode").click(); assertTrue(qSeleniumLib.driver.getCurrentUrl().endsWith("/1/dev")); - qSeleniumLib.waitForever(); + // qSeleniumLib.waitForever(); } } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java index b090ce8..04cc731 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java @@ -104,7 +104,7 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest "3","Bart J." """, fileContents); - qSeleniumLib.waitForever(); + // qSeleniumLib.waitForever(); } } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlAdvancedModeTest.java similarity index 80% rename from src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlAdvancedModeTest.java index 76115d7..4f93909 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlAdvancedModeTest.java @@ -32,16 +32,15 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.Now import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.ThisOrLastPeriod; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; -import org.openqa.selenium.WebElement; /******************************************************************************* ** Test for the record query screen when a filter is given in the URL *******************************************************************************/ -public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest +public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest { /******************************************************************************* @@ -67,15 +66,23 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest @Test void testUrlWithFilter() { + QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); + + //////////////////////////////// + // put table in advanced mode // + //////////////////////////////// + qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); + queryScreenLib.gotoAdvancedMode(); + //////////////////////////////////////// // not-blank -- criteria w/ no values // //////////////////////////////////////// String filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK))); qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(1); - clickFilterButton(); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(1); + queryScreenLib.clickFilterButton(); qSeleniumLib.waitForSelector("input[value=\"is not empty\"]"); /////////////////////////////// @@ -84,9 +91,9 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656))); qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(1); - clickFilterButton(); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(1); + queryScreenLib.clickFilterButton(); qSeleniumLib.waitForSelector("input[value=\"is between\"]"); qSeleniumLib.waitForSelector("input[value=\"1701\"]"); qSeleniumLib.waitForSelector("input[value=\"74656\"]"); @@ -97,9 +104,9 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1))); qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(1); - clickFilterButton(); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(1); + queryScreenLib.clickFilterButton(); qSeleniumLib.waitForSelector("input[value=\"does not equal\"]"); qSeleniumLib.waitForSelector("input[value=\"St. Louis\"]"); @@ -109,9 +116,9 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.IN, 1, 2))); qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(1); - clickFilterButton(); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(1); + queryScreenLib.clickFilterButton(); qSeleniumLib.waitForSelector("input[value=\"is any of\"]"); qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "St. Louis"); qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "Chesterfield"); @@ -122,9 +129,9 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS)))); qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(1); - clickFilterButton(); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(1); + queryScreenLib.clickFilterButton(); qSeleniumLib.waitForSelector("input[value=\"is after\"]"); qSeleniumLib.waitForSelector("input[value=\"5 days ago\"]"); @@ -135,9 +142,9 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest .withCriteria(new QFilterCriteria("firstName", QCriteriaOperator.STARTS_WITH, "Dar")) .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, ThisOrLastPeriod.this_(ChronoUnit.YEARS)))); qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(2); - clickFilterButton(); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(2); + queryScreenLib.clickFilterButton(); qSeleniumLib.waitForSelector("input[value=\"is at or before\"]"); qSeleniumLib.waitForSelector("input[value=\"start of this year\"]"); qSeleniumLib.waitForSelector("input[value=\"starts with\"]"); @@ -147,39 +154,9 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest // remove one // //////////////// qSeleniumLib.waitForSelectorContaining(".MuiIcon-root", "close").click(); - assertFilterButtonBadge(1); + queryScreenLib.assertFilterButtonBadge(1); - qSeleniumLib.waitForever(); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private WebElement assertFilterButtonBadge(int valueInBadge) - { - return qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", String.valueOf(valueInBadge)); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private WebElement waitForQueryToHaveRan() - { - return qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private void clickFilterButton() - { - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); + // qSeleniumLib.waitForever(); } } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java new file mode 100755 index 0000000..fc76ad6 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java @@ -0,0 +1,159 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; + + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.temporal.ChronoUnit; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.ThisOrLastPeriod; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; +import org.junit.jupiter.api.Test; + + +/******************************************************************************* + ** Test for the record query screen when a filter is given in the URL + *******************************************************************************/ +public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin) + { + super.addJavalinRoutes(qSeleniumJavalin); + qSeleniumJavalin + .withRouteToFile("/data/person/count", "data/person/count.json") + .withRouteToFile("/data/person/query", "data/person/index.json") + .withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json") + .withRouteToFile("/data/person/variants", "data/person/variants.json") + .withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUrlWithFilter() + { + QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); + + //////////////////////////////////////// + // not-blank -- criteria w/ no values // + //////////////////////////////////////// + String filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonBadge("annualSalary"); + queryScreenLib.clickQuickFilterButton("annualSalary"); + qSeleniumLib.waitForSelector("input[value=\"is not empty\"]"); + + /////////////////////////////// + // between on a number field // + /////////////////////////////// + filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonBadge("annualSalary"); + queryScreenLib.clickQuickFilterButton("annualSalary"); + qSeleniumLib.waitForSelector("input[value=\"is between\"]"); + qSeleniumLib.waitForSelector("input[value=\"1701\"]"); + qSeleniumLib.waitForSelector("input[value=\"74656\"]"); + + ////////////////////////////////////////// + // not-equals on a possible-value field // + ////////////////////////////////////////// + filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonBadge("homeCityId"); + queryScreenLib.clickQuickFilterButton("homeCityId"); + qSeleniumLib.waitForSelector("input[value=\"does not equal\"]"); + qSeleniumLib.waitForSelector("input[value=\"St. Louis\"]"); + + ////////////////////////////////////// + // an IN for a possible-value field // + ////////////////////////////////////// + filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.IN, 1, 2))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonBadge("homeCityId"); + queryScreenLib.clickQuickFilterButton("homeCityId"); + qSeleniumLib.waitForSelector("input[value=\"is any of\"]"); + qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "St. Louis"); + qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "Chesterfield"); + + ///////////////////////////////////////// + // greater than a date-time expression // + ///////////////////////////////////////// + filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS)))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonBadge("createDate"); + queryScreenLib.clickQuickFilterButton("createDate"); + qSeleniumLib.waitForSelector("input[value=\"is after\"]"); + qSeleniumLib.waitForSelector("input[value=\"5 days ago\"]"); + + /////////////////////// + // multiple criteria // + /////////////////////// + filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("firstName", QCriteriaOperator.STARTS_WITH, "Dar")) + .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, ThisOrLastPeriod.this_(ChronoUnit.YEARS)))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonBadge("firstName"); + queryScreenLib.assertQuickFilterButtonBadge("createDate"); + queryScreenLib.clickQuickFilterButton("createDate"); + qSeleniumLib.waitForSelector("input[value=\"is at or before\"]"); + qSeleniumLib.waitForSelector("input[value=\"start of this year\"]"); + qSeleniumLib.clickBackdrop(); + queryScreenLib.clickQuickFilterButton("firstName"); + qSeleniumLib.waitForSelector("input[value=\"starts with\"]"); + qSeleniumLib.waitForSelector("input[value=\"Dar\"]"); + + //////////////// + // remove one // + //////////////// + // todo! qSeleniumLib.waitForSelectorContaining(".MuiIcon-root", "close").click(); + // todo! assertQuickFilterButtonBadge(1); + + // qSeleniumLib.waitForever(); + } + +} diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java index 1f1a0d0..ae6b231 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java @@ -24,13 +24,10 @@ package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors; -import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; -import org.openqa.selenium.By; -import org.openqa.selenium.Keys; -import org.openqa.selenium.WebElement; import static org.assertj.core.api.Assertions.assertThat; @@ -60,33 +57,28 @@ public class QueryScreenTest extends QBaseSeleniumTest ** *******************************************************************************/ @Test - void testBasicQueryAndClearFilters() + void testBuildQueryQueryAndClearFilters() { + QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); + qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); - qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL); - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.gotoAdvancedMode(); + queryScreenLib.clickFilterButton(); ///////////////////////////////////////////////////////////////////// // open the filter window, enter a value, wait for query to re-run // ///////////////////////////////////////////////////////////////////// qSeleniumJavalin.beginCapture(); - addQueryFilterInput(qSeleniumLib, 0, "Id", "equals", "1", null); - // WebElement filterInput = qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_FILTER_INPUT); - // qSeleniumLib.waitForElementToHaveFocus(filterInput); - // filterInput.sendKeys("id"); - // filterInput.sendKeys("\t"); - // driver.switchTo().activeElement().sendKeys("\t"); - // driver.switchTo().activeElement().sendKeys("1" + "\t"); + queryScreenLib.addQueryFilterInput(qSeleniumLib, 0, "Id", "equals", "1", null); /////////////////////////////////////////////////////////////////// // assert that query & count both have the expected filter value // /////////////////////////////////////////////////////////////////// String idEquals1FilterSubstring = """ {"fieldName":"id","operator":"EQUALS","values":["1"]}"""; - CapturedContext capturedCount = qSeleniumJavalin.waitForCapturedPath("/data/person/count"); - CapturedContext capturedQuery = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); - assertThat(capturedCount).extracting("body").asString().contains(idEquals1FilterSubstring); - assertThat(capturedQuery).extracting("body").asString().contains(idEquals1FilterSubstring); + qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/count", idEquals1FilterSubstring); + qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", idEquals1FilterSubstring); qSeleniumJavalin.endCapture(); /////////////////////////////////////// @@ -106,8 +98,8 @@ public class QueryScreenTest extends QBaseSeleniumTest //////////////////////////////////////////////////////////////////// // assert that query & count both no longer have the filter value // //////////////////////////////////////////////////////////////////// - capturedCount = qSeleniumJavalin.waitForCapturedPath("/data/person/count"); - capturedQuery = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); + CapturedContext capturedCount = qSeleniumJavalin.waitForCapturedPath("/data/person/count"); + CapturedContext capturedQuery = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); assertThat(capturedCount).extracting("body").asString().doesNotContain(idEquals1FilterSubstring); assertThat(capturedQuery).extracting("body").asString().doesNotContain(idEquals1FilterSubstring); qSeleniumJavalin.endCapture(); @@ -121,13 +113,16 @@ public class QueryScreenTest extends QBaseSeleniumTest @Test void testMultiCriteriaQueryWithOr() { + QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); + qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); - qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL); - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.gotoAdvancedMode(); + queryScreenLib.clickFilterButton(); qSeleniumJavalin.beginCapture(); - addQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or"); - addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); + queryScreenLib.addQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or"); + queryScreenLib.addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); String expectedFilterContents0 = """ {"fieldName":"firstName","operator":"CONTAINS","values":["Dar"]}"""; @@ -143,53 +138,6 @@ public class QueryScreenTest extends QBaseSeleniumTest } - - /******************************************************************************* - ** - *******************************************************************************/ - static void addQueryFilterInput(QSeleniumLib qSeleniumLib, int index, String fieldLabel, String operator, String value, String booleanOperator) - { - if(index > 0) - { - qSeleniumLib.waitForSelectorContaining("BUTTON", "Add condition").click(); - } - - WebElement subFormForField = qSeleniumLib.waitForSelectorAll(".filterCriteriaRow", index + 1).get(index); - - if(index == 1) - { - WebElement booleanOperatorInput = subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); - booleanOperatorInput.click(); - qSeleniumLib.waitForMillis(100); - - subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); - qSeleniumLib.waitForSelectorContaining("li", booleanOperator).click(); - qSeleniumLib.waitForMillis(100); - } - - WebElement fieldInput = subFormForField.findElement(By.cssSelector(".fieldColumn INPUT")); - fieldInput.click(); - qSeleniumLib.waitForMillis(100); - fieldInput.clear(); - fieldInput.sendKeys(fieldLabel); - qSeleniumLib.waitForMillis(100); - fieldInput.sendKeys("\n"); - qSeleniumLib.waitForMillis(100); - - WebElement operatorInput = subFormForField.findElement(By.cssSelector(".operatorColumn INPUT")); - operatorInput.click(); - qSeleniumLib.waitForMillis(100); - operatorInput.sendKeys(Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, operator); - qSeleniumLib.waitForMillis(100); - operatorInput.sendKeys("\n"); - qSeleniumLib.waitForMillis(100); - - WebElement valueInput = subFormForField.findElement(By.cssSelector(".filterValuesColumn INPUT")); - valueInput.click(); - valueInput.sendKeys(value); - qSeleniumLib.waitForMillis(100); - } - // todo - table requires variant - prompt for it, choose it, see query; change variant, change on-screen, re-query } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedFiltersTest.java index 4af9f9d..ff03273 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedFiltersTest.java @@ -22,15 +22,10 @@ package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; -import org.openqa.selenium.By; -import static com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.QueryScreenTest.addQueryFilterInput; -import static org.junit.jupiter.api.Assertions.assertTrue; /******************************************************************************* @@ -69,7 +64,11 @@ public class SavedFiltersTest extends QBaseSeleniumTest @Test void testNavigatingBackAndForth() { + QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); + qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); + queryScreenLib.gotoAdvancedMode(); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Saved Filters").click(); qSeleniumLib.waitForSelectorContaining("LI", "Some People"); @@ -108,8 +107,9 @@ public class SavedFiltersTest extends QBaseSeleniumTest ////////////////////// // modify the query // ////////////////////// - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); - addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); + /* todo - right now - this is changed - but - working through it with Views story... revisit before merge! + queryScreenLib.clickFilterButton(); + queryScreenLib.addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); qSeleniumLib.waitForSelectorContaining("H3", "Person").click(); qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People") .findElement(By.cssSelector("CIRCLE")); @@ -171,6 +171,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); assertTrue(capturedContext.getBody().matches("(?s).*id.*LESS_THAN.*10.*")); qSeleniumJavalin.endCapture(); + */ } } From c5c756d84f81156d7081f74b8e6620d72e4920a5 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 19:34:00 -0600 Subject: [PATCH 11/40] CE-793 - Replace/rename savedFilter as savedView --- src/App.tsx | 2 +- src/qqq/components/buttons/DefaultButtons.tsx | 6 ++-- src/qqq/components/horseshoe/Breadcrumbs.tsx | 2 +- .../selenium/lib/QBaseSeleniumTest.java | 2 +- ...ueryScreenFilterInUrlAdvancedModeTest.java | 2 +- .../QueryScreenFilterInUrlBasicModeTest.java | 2 +- .../selenium/tests/QueryScreenTest.java | 2 +- ...edFiltersTest.java => SavedViewsTest.java} | 34 +++++++++---------- .../init-id=2.json | 6 ++-- .../init.json | 10 +++--- 10 files changed, 34 insertions(+), 34 deletions(-) rename src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/{SavedFiltersTest.java => SavedViewsTest.java} (85%) rename src/test/resources/fixtures/processes/{querySavedFilter => querySavedView}/init-id=2.json (62%) rename src/test/resources/fixtures/processes/{querySavedFilter => querySavedView}/init.json (63%) diff --git a/src/App.tsx b/src/App.tsx index 48578b1..e22a461 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -355,7 +355,7 @@ export default function App() routeList.push({ name: `${app.label}`, key: app.name, - route: `${path}/savedFilter/:id`, + route: `${path}/savedView/:id`, component: , }); diff --git a/src/qqq/components/buttons/DefaultButtons.tsx b/src/qqq/components/buttons/DefaultButtons.tsx index c934f3f..982ab82 100644 --- a/src/qqq/components/buttons/DefaultButtons.tsx +++ b/src/qqq/components/buttons/DefaultButtons.tsx @@ -123,7 +123,7 @@ export function QActionsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonP ); } -export function QSavedFiltersMenuButton({isOpen, onClickHandler}: QActionsMenuButtonProps): JSX.Element +export function QSavedViewsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonProps): JSX.Element { return ( @@ -132,9 +132,9 @@ export function QSavedFiltersMenuButton({isOpen, onClickHandler}: QActionsMenuBu color="dark" onClick={onClickHandler} fullWidth - startIcon={filter_alt} + startIcon={visibility} > - saved filters  + Saved Views  keyboard_arrow_down diff --git a/src/qqq/components/horseshoe/Breadcrumbs.tsx b/src/qqq/components/horseshoe/Breadcrumbs.tsx index b31bb76..c319b98 100644 --- a/src/qqq/components/horseshoe/Breadcrumbs.tsx +++ b/src/qqq/components/horseshoe/Breadcrumbs.tsx @@ -92,7 +92,7 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element let accumulatedPath = ""; for (let i = 0; i < routes.length; i++) { - if(routes[i] === "savedFilter") + if(routes[i] === "savedView") { continue; } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QBaseSeleniumTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QBaseSeleniumTest.java index 5ffd961..57ed9c1 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QBaseSeleniumTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QBaseSeleniumTest.java @@ -177,7 +177,7 @@ public class QBaseSeleniumTest .withRouteToFile("/metaData/table/city", "metaData/table/person.json") .withRouteToFile("/metaData/table/script", "metaData/table/script.json") .withRouteToFile("/metaData/table/scriptRevision", "metaData/table/scriptRevision.json") - .withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json"); + .withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json"); } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlAdvancedModeTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlAdvancedModeTest.java index 4f93909..f2bdfe4 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlAdvancedModeTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlAdvancedModeTest.java @@ -55,7 +55,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest .withRouteToFile("/data/person/query", "data/person/index.json") .withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json") .withRouteToFile("/data/person/variants", "data/person/variants.json") - .withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json"); + .withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json"); } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java index fc76ad6..83d18e7 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java @@ -55,7 +55,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest .withRouteToFile("/data/person/query", "data/person/index.json") .withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json") .withRouteToFile("/data/person/variants", "data/person/variants.json") - .withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json"); + .withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json"); } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java index ae6b231..25520d1 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java @@ -48,7 +48,7 @@ public class QueryScreenTest extends QBaseSeleniumTest .withRouteToFile("/data/person/count", "data/person/count.json") .withRouteToFile("/data/person/query", "data/person/index.json") .withRouteToFile("/data/person/variants", "data/person/variants.json") - .withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json"); + .withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json"); } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedViewsTest.java similarity index 85% rename from src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedFiltersTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedViewsTest.java index ff03273..f58e9be 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedViewsTest.java @@ -29,9 +29,9 @@ import org.junit.jupiter.api.Test; /******************************************************************************* - ** Test for Saved Filters functionality on the Query screen. + ** Test for Saved View functionality on the Query screen. *******************************************************************************/ -public class SavedFiltersTest extends QBaseSeleniumTest +public class SavedViewsTest extends QBaseSeleniumTest { /******************************************************************************* @@ -69,7 +69,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); queryScreenLib.gotoAdvancedMode(); - qSeleniumLib.waitForSelectorContaining("BUTTON", "Saved Filters").click(); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Saved Views").click(); qSeleniumLib.waitForSelectorContaining("LI", "Some People"); //////////////////////////////////////// @@ -78,15 +78,15 @@ public class SavedFiltersTest extends QBaseSeleniumTest qSeleniumJavalin.stop(); qSeleniumJavalin.clearRoutes(); addStandardRoutesForThisTest(qSeleniumJavalin); - qSeleniumJavalin.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init-id=2.json"); + qSeleniumJavalin.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init-id=2.json"); qSeleniumJavalin.restart(); - /////////////////////////////////////////////////////// - // go to a specific filter - assert that it's loaded // - /////////////////////////////////////////////////////// + ///////////////////////////////////////////////////// + // go to a specific view - assert that it's loaded // + ///////////////////////////////////////////////////// qSeleniumLib.waitForSelectorContaining("LI", "Some People").click(); - qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedFilter/2")); - qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People"); + qSeleniumLib.waitForCondition("Current URL should have view id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2")); + qSeleniumLib.waitForSelectorContaining("DIV", "Current View: Some People"); ////////////////////////////// // click into a view screen // @@ -97,11 +97,11 @@ public class SavedFiltersTest extends QBaseSeleniumTest ///////////////////////////////////////////////////// // take breadcrumb back to table query // - // assert the previously selected filter is loaded // + // assert the previously selected View is loaded // ///////////////////////////////////////////////////// qSeleniumLib.waitForSelectorContaining("A", "Person").click(); - qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedFilter/2")); - qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People"); + qSeleniumLib.waitForCondition("Current URL should have View id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2")); + qSeleniumLib.waitForSelectorContaining("DIV", "Current View: Some People"); qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1"); ////////////////////// @@ -127,8 +127,8 @@ public class SavedFiltersTest extends QBaseSeleniumTest /////////////////////////////////////////////////////////////////////////////// qSeleniumJavalin.beginCapture(); qSeleniumLib.waitForSelectorContaining("A", "Person").click(); - qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedFilter/2")); - qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People") + qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2")); + qSeleniumLib.waitForSelectorContaining("DIV", "Current View: Some People") .findElement(By.cssSelector("CIRCLE")); qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "2"); CapturedContext capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); @@ -136,7 +136,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest qSeleniumJavalin.endCapture(); //////////////////////////////////////////////////// - // navigate to the table with a filter in the URL // + // navigate to the table with a View in the URL // //////////////////////////////////////////////////// String filter = """ { @@ -152,7 +152,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest """.replace('\n', ' ').replaceAll(" ", ""); qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8), "Person"); qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1"); - qSeleniumLib.waitForSelectorContainingToNotExist("DIV", "Current Filter"); + qSeleniumLib.waitForSelectorContainingToNotExist("DIV", "Current View"); ////////////////////////////// // click into a view screen // @@ -166,7 +166,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest ///////////////////////////////////////////////////////////////////////////////// qSeleniumJavalin.beginCapture(); qSeleniumLib.waitForSelectorContaining("A", "Person").click(); - qSeleniumLib.waitForCondition("Current URL should not have filter id", () -> !driver.getCurrentUrl().endsWith("/person/savedFilter/2")); + qSeleniumLib.waitForCondition("Current URL should not have filter id", () -> !driver.getCurrentUrl().endsWith("/person/savedView/2")); qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1"); capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); assertTrue(capturedContext.getBody().matches("(?s).*id.*LESS_THAN.*10.*")); diff --git a/src/test/resources/fixtures/processes/querySavedFilter/init-id=2.json b/src/test/resources/fixtures/processes/querySavedView/init-id=2.json similarity index 62% rename from src/test/resources/fixtures/processes/querySavedFilter/init-id=2.json rename to src/test/resources/fixtures/processes/querySavedView/init-id=2.json index fe19313..b0b601f 100644 --- a/src/test/resources/fixtures/processes/querySavedFilter/init-id=2.json +++ b/src/test/resources/fixtures/processes/querySavedView/init-id=2.json @@ -1,16 +1,16 @@ { "values": { "_qStepTimeoutMillis": "60000", - "savedFilterList": [ + "savedViewList": [ { - "tableName": "savedFilter", + "tableName": "savedView", "values": { "label": "Some People", "id": 2, "createDate": "2023-02-20T18:40:58Z", "modifyDate": "2023-02-20T18:40:58Z", "tableName": "person", - "filterJson": "{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}", + "filterJson": "{\"filter\":{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}}", "userId": "darin.kelkhoff@kingsrook.com" } } diff --git a/src/test/resources/fixtures/processes/querySavedFilter/init.json b/src/test/resources/fixtures/processes/querySavedView/init.json similarity index 63% rename from src/test/resources/fixtures/processes/querySavedFilter/init.json rename to src/test/resources/fixtures/processes/querySavedView/init.json index 23b8f4a..7f03dd5 100644 --- a/src/test/resources/fixtures/processes/querySavedFilter/init.json +++ b/src/test/resources/fixtures/processes/querySavedView/init.json @@ -1,28 +1,28 @@ { "values": { "_qStepTimeoutMillis": "60000", - "savedFilterList": [ + "savedViewList": [ { - "tableName": "savedFilter", + "tableName": "savedView", "values": { "label": "All People", "id": 1, "createDate": "2023-02-20T18:39:11Z", "modifyDate": "2023-02-20T18:39:11Z", "tableName": "person", - "filterJson": "{\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}", + "filterJson": "{\"filter\":{\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}}", "userId": "darin.kelkhoff@kingsrook.com" } }, { - "tableName": "savedFilter", + "tableName": "savedView", "values": { "label": "Some People", "id": 2, "createDate": "2023-02-20T18:40:58Z", "modifyDate": "2023-02-20T18:40:58Z", "tableName": "person", - "filterJson": "{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}", + "filterJson": "{\"filter\":{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}}", "userId": "darin.kelkhoff@kingsrook.com" } } From 5d479ad04a9bdf6a6fde32aab84656ecc52b61b9 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 19:54:49 -0600 Subject: [PATCH 12/40] CE-793 - Add callback for going to slow-loading; call setters in constructor --- src/qqq/models/LoadingState.ts | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/qqq/models/LoadingState.ts b/src/qqq/models/LoadingState.ts index 39d7d27..992d349 100644 --- a/src/qqq/models/LoadingState.ts +++ b/src/qqq/models/LoadingState.ts @@ -41,17 +41,30 @@ ** {myLoadingState.isNotLoading() && myData && ... ** - In your template, before your "slow loading" view, check for `myLoadingState.isLoadingSlow()`, e.g. ** {myLoadingState.isLoadingSlow() && } + ** + ** In addition, you can also supply a callback to run "upon slow" (e.g., when + ** moving into the slow state). *******************************************************************************/ export class LoadingState { private state: "notLoading" | "loading" | "slow" private slowTimeout: any; - private forceUpdate: () => void + private forceUpdate: () => void; + private uponSlowCallback: () => void; constructor(forceUpdate: () => void, initialState: "notLoading" | "loading" | "slow" = "notLoading") { this.forceUpdate = forceUpdate; this.state = initialState; + + if(initialState == "loading") + { + this.setLoading(); + } + else if(initialState == "notLoading") + { + this.setNotLoading(); + } } public setLoading() @@ -60,6 +73,12 @@ export class LoadingState this.slowTimeout = setTimeout(() => { this.state = "slow"; + + if(this.uponSlowCallback) + { + this.uponSlowCallback(); + } + this.forceUpdate(); }, 1000); } @@ -85,4 +104,14 @@ export class LoadingState return (this.state == "notLoading"); } + public getState(): string + { + return (this.state); + } + + public setUponSlowCallback(value: any) + { + this.uponSlowCallback = value; + } + } \ No newline at end of file From db2cdc36032de8a74aadbe2bdb37f2c8b5a6dd46 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 19:55:23 -0600 Subject: [PATCH 13/40] CE-793 - Fix grid pinned column height; new style for toggle buttons --- src/qqq/styles/qqq-override-styles.css | 39 ++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index b764304..0642ab8 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -427,7 +427,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } .MuiDataGrid-panel:has(.customFilterPanel) { /* overwrite what the grid tries to do here, where it changes based on density... we always want the same. */ - transform: translate(274px, 305px) !important; + /* transform: translate(274px, 305px) !important; */ + transform: translate(274px, 276px) !important; } /* within the data-grid, the filter-panel's container has a max-height. mirror that, and set an overflow-y */ @@ -614,4 +615,38 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } .dataGridHeaderTooltip { top: -1.25rem; -} \ No newline at end of file +} + +/* when grid contents weren't filling the height of the screen, the gray panel for pinned columns + was stretching to most of the grid height, but it wasn't the full height and so looked a little + broken. just turing off this min height changes to not try to stretch at all, and is not broken. */ +.MuiDataGrid-pinnedColumns +{ + min-height: unset !important; +} + +/* new style for toggle buttons */ +.MuiToggleButtonGroup-root +{ + padding: 0.25rem; + border: 1px solid #BDBDBD; + border-radius: 0.5rem !important; +} +.MuiToggleButtonGroup-root .MuiButtonBase-root +{ + text-transform: none; + font-size: 0.75rem; + color: black; + font-weight: 600; + border-radius: 0.375rem !important; /* overriding left/right edge overrides for first/last */ + border: none; + flex: 1 1 0px; +} +.MuiToggleButtonGroup-root .MuiButtonBase-root.Mui-selected +{ + background: rgba(117, 117, 117, 0.20); +} +.MuiToggleButtonGroup-root .MuiButtonBase-root.Mui-disabled +{ + border: none; +} From d901404d2535c3d63fe6e578a242d939e1fcb11b Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 19:56:09 -0600 Subject: [PATCH 14/40] CE-793 - Redo SavedFilters component as SavedViews --- src/qqq/components/misc/SavedFilters.tsx | 511 ------------- src/qqq/components/misc/SavedViews.tsx | 918 +++++++++++++++++++++++ 2 files changed, 918 insertions(+), 511 deletions(-) delete mode 100644 src/qqq/components/misc/SavedFilters.tsx create mode 100644 src/qqq/components/misc/SavedViews.tsx diff --git a/src/qqq/components/misc/SavedFilters.tsx b/src/qqq/components/misc/SavedFilters.tsx deleted file mode 100644 index eead528..0000000 --- a/src/qqq/components/misc/SavedFilters.tsx +++ /dev/null @@ -1,511 +0,0 @@ -/* - * 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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; -import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; -import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; -import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete"; -import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; -import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; -import {FiberManualRecord} from "@mui/icons-material"; -import {Alert} from "@mui/material"; -import Box from "@mui/material/Box"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import Divider from "@mui/material/Divider"; -import Icon from "@mui/material/Icon"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import TextField from "@mui/material/TextField"; -import Tooltip from "@mui/material/Tooltip"; -import Typography from "@mui/material/Typography"; -import {GridFilterModel, GridSortItem} from "@mui/x-data-grid-pro"; -import FormData from "form-data"; -import React, {useEffect, useRef, useState} from "react"; -import {useLocation, useNavigate} from "react-router-dom"; -import {QCancelButton, QDeleteButton, QSaveButton, QSavedFiltersMenuButton} from "qqq/components/buttons/DefaultButtons"; -import FilterUtils from "qqq/utils/qqq/FilterUtils"; - -interface Props -{ - qController: QController; - metaData: QInstance; - tableMetaData: QTableMetaData; - currentSavedFilter: QRecord; - filterModel?: GridFilterModel; - columnSortModel?: GridSortItem[]; - filterOnChangeCallback?: (selectedSavedFilterId: number) => void; -} - -function SavedFilters({qController, metaData, tableMetaData, currentSavedFilter, filterModel, columnSortModel, filterOnChangeCallback}: Props): JSX.Element -{ - const navigate = useNavigate(); - - const [savedFilters, setSavedFilters] = useState([] as QRecord[]); - const [savedFiltersMenu, setSavedFiltersMenu] = useState(null); - const [savedFiltersHaveLoaded, setSavedFiltersHaveLoaded] = useState(false); - const [filterIsModified, setFilterIsModified] = useState(false); - - const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false); - const [isSaveFilterAs, setIsSaveFilterAs] = useState(false); - const [isRenameFilter, setIsRenameFilter] = useState(false); - const [isDeleteFilter, setIsDeleteFilter] = useState(false); - const [savedFilterNameInputValue, setSavedFilterNameInputValue] = useState(null as string); - const [popupAlertContent, setPopupAlertContent] = useState(""); - - const anchorRef = useRef(null); - const location = useLocation(); - const [saveOptionsOpen, setSaveOptionsOpen] = useState(false); - - const SAVE_OPTION = "Save..."; - const DUPLICATE_OPTION = "Duplicate..."; - const RENAME_OPTION = "Rename..."; - const DELETE_OPTION = "Delete..."; - const CLEAR_OPTION = "Clear Current Filter"; - const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION]; - - const openSavedFiltersMenu = (event: any) => setSavedFiltersMenu(event.currentTarget); - const closeSavedFiltersMenu = () => setSavedFiltersMenu(null); - - ////////////////////////////////////////////////////////////////////////// - // load filters on first run, then monitor location or metadata changes // - ////////////////////////////////////////////////////////////////////////// - useEffect(() => - { - loadSavedFilters() - .then(() => - { - if (currentSavedFilter != null) - { - let qFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel); - setFilterIsModified(JSON.stringify(qFilter) !== currentSavedFilter.values.get("filterJson")); - } - - setSavedFiltersHaveLoaded(true); - }); - }, [location , tableMetaData, currentSavedFilter, filterModel, columnSortModel]) - - - - /******************************************************************************* - ** make request to load all saved filters from backend - *******************************************************************************/ - async function loadSavedFilters() - { - if (! tableMetaData) - { - return; - } - - const formData = new FormData(); - formData.append("tableName", tableMetaData.name); - - let savedFilters = await makeSavedFilterRequest("querySavedFilter", formData); - setSavedFilters(savedFilters); - } - - - - /******************************************************************************* - ** fired when a saved record is clicked from the dropdown - *******************************************************************************/ - const handleSavedFilterRecordOnClick = async (record: QRecord) => - { - setSaveFilterPopupOpen(false); - closeSavedFiltersMenu(); - filterOnChangeCallback(record.values.get("id")); - navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedFilter/${record.values.get("id")}`); - }; - - - - /******************************************************************************* - ** fired when a save option is selected from the save... button/dropdown combo - *******************************************************************************/ - const handleDropdownOptionClick = (optionName: string) => - { - setSaveOptionsOpen(false); - setPopupAlertContent(null); - closeSavedFiltersMenu(); - setSaveFilterPopupOpen(true); - setIsSaveFilterAs(false); - setIsRenameFilter(false); - setIsDeleteFilter(false) - - switch(optionName) - { - case SAVE_OPTION: - break; - case DUPLICATE_OPTION: - setIsSaveFilterAs(true); - break; - case CLEAR_OPTION: - setSaveFilterPopupOpen(false) - filterOnChangeCallback(null); - navigate(metaData.getTablePathByName(tableMetaData.name)); - break; - case RENAME_OPTION: - if(currentSavedFilter != null) - { - setSavedFilterNameInputValue(currentSavedFilter.values.get("label")); - } - setIsRenameFilter(true); - break; - case DELETE_OPTION: - setIsDeleteFilter(true) - break; - } - } - - - - /******************************************************************************* - ** fired when save or delete button saved on confirmation dialogs - *******************************************************************************/ - async function handleFilterDialogButtonOnClick() - { - try - { - const formData = new FormData(); - if (isDeleteFilter) - { - formData.append("id", currentSavedFilter.values.get("id")); - await makeSavedFilterRequest("deleteSavedFilter", formData); - await(async() => - { - handleDropdownOptionClick(CLEAR_OPTION); - })(); - } - else - { - formData.append("tableName", tableMetaData.name); - formData.append("filterJson", JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel)))); - - if (isSaveFilterAs || isRenameFilter || currentSavedFilter == null) - { - formData.append("label", savedFilterNameInputValue); - if(currentSavedFilter != null && isRenameFilter) - { - formData.append("id", currentSavedFilter.values.get("id")); - } - } - else - { - formData.append("id", currentSavedFilter.values.get("id")); - formData.append("label", currentSavedFilter?.values.get("label")); - } - const recordList = await makeSavedFilterRequest("storeSavedFilter", formData); - await(async() => - { - if (recordList && recordList.length > 0) - { - setSavedFiltersHaveLoaded(false); - loadSavedFilters(); - handleSavedFilterRecordOnClick(recordList[0]); - } - })(); - } - } - catch (e: any) - { - setPopupAlertContent(JSON.stringify(e.message)); - } - } - - - - /******************************************************************************* - ** hides/shows the save options - *******************************************************************************/ - const handleToggleSaveOptions = () => - { - setSaveOptionsOpen((prevOpen) => !prevOpen); - }; - - - - /******************************************************************************* - ** closes save options menu (on clickaway) - *******************************************************************************/ - const handleSaveOptionsMenuClose = (event: Event) => - { - if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) - { - return; - } - - setSaveOptionsOpen(false); - }; - - - - /******************************************************************************* - ** stores the current dialog input text to state - *******************************************************************************/ - const handleSaveFilterInputChange = (event: React.ChangeEvent) => - { - setSavedFilterNameInputValue(event.target.value); - }; - - - - /******************************************************************************* - ** closes current dialog - *******************************************************************************/ - const handleSaveFilterPopupClose = () => - { - setSaveFilterPopupOpen(false); - }; - - - - /******************************************************************************* - ** make a request to the backend for various savedFilter processes - *******************************************************************************/ - async function makeSavedFilterRequest(processName: string, formData: FormData): Promise - { - ///////////////////////// - // fetch saved filters // - ///////////////////////// - let savedFilters = [] as QRecord[] - try - { - ////////////////////////////////////////////////////////////////// - // we don't want this job to go async, so, pass a large timeout // - ////////////////////////////////////////////////////////////////// - formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000); - const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders()); - if (processResult instanceof QJobError) - { - const jobError = processResult as QJobError; - throw(jobError.error); - } - else - { - const result = processResult as QJobComplete; - if(result.values.savedFilterList) - { - for (let i = 0; i < result.values.savedFilterList.length; i++) - { - const qRecord = new QRecord(result.values.savedFilterList[i]); - savedFilters.push(qRecord); - } - } - } - } - catch (e) - { - throw(e); - } - - return (savedFilters); - } - - const hasStorePermission = metaData?.processes.has("storeSavedFilter"); - const hasDeletePermission = metaData?.processes.has("deleteSavedFilter"); - const hasQueryPermission = metaData?.processes.has("querySavedFilter"); - - const renderSavedFiltersMenu = tableMetaData && ( - - Filter Actions - { - hasStorePermission && - handleDropdownOptionClick(SAVE_OPTION)}> - save - Save... - - } - { - hasStorePermission && - handleDropdownOptionClick(RENAME_OPTION)}> - edit - Rename... - - } - { - hasStorePermission && - handleDropdownOptionClick(DUPLICATE_OPTION)}> - content_copy - Duplicate... - - } - { - hasDeletePermission && - handleDropdownOptionClick(DELETE_OPTION)}> - delete - Delete... - - } - { - handleDropdownOptionClick(CLEAR_OPTION)}> - clear - Clear Current Filter - - } - - Your Filters - { - savedFilters && savedFilters.length > 0 ? ( - savedFilters.map((record: QRecord, index: number) => - handleSavedFilterRecordOnClick(record)}> - {record.values.get("label")} - - ) - ): ( - - No filters have been saved for this table. - - ) - } - - ); - - return ( - hasQueryPermission && tableMetaData ? ( - - - {renderSavedFiltersMenu} - - - { - savedFiltersHaveLoaded && currentSavedFilter && ( - Current Filter:  - - {currentSavedFilter.values.get("label")} - { - filterIsModified && ( - - - - ) - } - - - ) - } - - - { - - { - if (e.key == "Enter") - { - handleFilterDialogButtonOnClick(); - } - }} - > - { - currentSavedFilter ? ( - isDeleteFilter ? ( - Delete Filter - ) : ( - isSaveFilterAs ? ( - Save Filter As - ):( - isRenameFilter ? ( - Rename Filter - ):( - Update Existing Filter - ) - ) - ) - ):( - Save New Filter - ) - } - - { - (! currentSavedFilter || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? ( - - { - isSaveFilterAs ? ( - Enter a name for this new saved filter. - ):( - Enter a new name for this saved filter. - ) - } - - { - event.target.select(); - }} - /> - - ):( - isDeleteFilter ? ( - Are you sure you want to delete the filter {`'${currentSavedFilter?.values.get("label")}'`}? - ):( - Are you sure you want to update the filter {`'${currentSavedFilter?.values.get("label")}'`} with the current filter criteria? - ) - ) - } - {popupAlertContent ? ( - - {popupAlertContent} - - ) : ("")} - - - - { - isDeleteFilter ? - - : - - } - - - } - - ) : null - ); -} - -export default SavedFilters; diff --git a/src/qqq/components/misc/SavedViews.tsx b/src/qqq/components/misc/SavedViews.tsx new file mode 100644 index 0000000..89c58cb --- /dev/null +++ b/src/qqq/components/misc/SavedViews.tsx @@ -0,0 +1,918 @@ +/* + * 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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete"; +import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; +import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import {FiberManualRecord} from "@mui/icons-material"; +import {Alert} from "@mui/material"; +import Box from "@mui/material/Box"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import Divider from "@mui/material/Divider"; +import Icon from "@mui/material/Icon"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import {TooltipProps} from "@mui/material/Tooltip/Tooltip"; +import Typography from "@mui/material/Typography"; +import FormData from "form-data"; +import React, {useEffect, useRef, useState} from "react"; +import {useLocation, useNavigate} from "react-router-dom"; +import {QCancelButton, QDeleteButton, QSaveButton, QSavedViewsMenuButton} from "qqq/components/buttons/DefaultButtons"; +import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; +import QQueryColumns from "qqq/models/query/QQueryColumns"; +import RecordQueryView from "qqq/models/query/RecordQueryView"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import TableUtils from "qqq/utils/qqq/TableUtils"; + +interface Props +{ + qController: QController; + metaData: QInstance; + tableMetaData: QTableMetaData; + currentSavedView: QRecord; + view?: RecordQueryView; + viewAsJson?: string; + viewOnChangeCallback?: (selectedSavedViewId: number) => void; + loadingSavedView: boolean +} + +function SavedViews({qController, metaData, tableMetaData, currentSavedView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element +{ + const navigate = useNavigate(); + + const [savedViews, setSavedViews] = useState([] as QRecord[]); + const [savedViewsMenu, setSavedViewsMenu] = useState(null); + const [savedViewsHaveLoaded, setSavedViewsHaveLoaded] = useState(false); + // const [viewIsModified, setViewIsModified] = useState(false); + + const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false); + const [isSaveFilterAs, setIsSaveFilterAs] = useState(false); + const [isRenameFilter, setIsRenameFilter] = useState(false); + const [isDeleteFilter, setIsDeleteFilter] = useState(false); + const [savedViewNameInputValue, setSavedViewNameInputValue] = useState(null as string); + const [popupAlertContent, setPopupAlertContent] = useState(""); + + const anchorRef = useRef(null); + const location = useLocation(); + const [saveOptionsOpen, setSaveOptionsOpen] = useState(false); + + const SAVE_OPTION = "Save..."; + const DUPLICATE_OPTION = "Duplicate..."; + const RENAME_OPTION = "Rename..."; + const DELETE_OPTION = "Delete..."; + const CLEAR_OPTION = "New View"; + const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION]; + + const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget); + const closeSavedViewsMenu = () => setSavedViewsMenu(null); + + ////////////////////////////////////////////////////////////////////////// + // load filters on first run, then monitor location or metadata changes // + ////////////////////////////////////////////////////////////////////////// + useEffect(() => + { + loadSavedViews() + .then(() => + { + setSavedViewsHaveLoaded(true); + /* + if (currentSavedView != null) + { + const isModified = JSON.stringify(view) !== currentSavedView.values.get("viewJson"); + console.log(`Is view modified? ${isModified}\n${JSON.stringify(view)}\n${currentSavedView.values.get("viewJson")}`); + setViewIsModified(isModified); + } + */ + }); + }, [location, tableMetaData, currentSavedView, view]) // todo#elimGrid does this monitoring work?? + + + /******************************************************************************* + ** + *******************************************************************************/ + const fieldNameToLabel = (fieldName: string): string => + { + try + { + const [fieldMetaData, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + if(fieldTable.name != tableMetaData.name) + { + return (tableMetaData.label + ": " + fieldMetaData.label); + } + + return (fieldMetaData.label); + } + catch(e) + { + return (fieldName); + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + const diffFilters = (savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => + { + try + { + //////////////////////////////////////////////////////////////////////////////// + // inner helper function for reporting on the number of criteria for a field. // + // e.g., will tell us "added criteria X" or "removed 2 criteria on Y" // + //////////////////////////////////////////////////////////////////////////////// + const diffCriteriaFunction = (base: QQueryFilter, compare: QQueryFilter, messagePrefix: string, isCheckForChanged = false) => + { + const baseCriteriaMap: { [name: string]: QFilterCriteria[] } = {}; + base?.criteria?.forEach((criteria) => + { + if(!baseCriteriaMap[criteria.fieldName]) + { + baseCriteriaMap[criteria.fieldName] = [] + } + baseCriteriaMap[criteria.fieldName].push(criteria) + }); + + const compareCriteriaMap: { [name: string]: QFilterCriteria[] } = {}; + compare?.criteria?.forEach((criteria) => + { + if(!compareCriteriaMap[criteria.fieldName]) + { + compareCriteriaMap[criteria.fieldName] = [] + } + compareCriteriaMap[criteria.fieldName].push(criteria) + }); + + for (let fieldName of Object.keys(compareCriteriaMap)) + { + const noBaseCriteria = baseCriteriaMap[fieldName]?.length ?? 0; + const noCompareCriteria = compareCriteriaMap[fieldName]?.length ?? 0; + + if(isCheckForChanged) + { + ///////////////////////////////////////////////////////////////////////////////////////////// + // first - if we're checking for changes to specific criteria (e.g., change id=5 to id<>5, // + // or change id=5 to id=6, or change id=5 to id<>7) // + // our "sweet spot" is if there's a single criteria on each side of the check // + ///////////////////////////////////////////////////////////////////////////////////////////// + if(noBaseCriteria == 1 && noCompareCriteria == 1) + { + const baseCriteria = baseCriteriaMap[fieldName][0] + const compareCriteria = compareCriteriaMap[fieldName][0] + const baseValuesJSON = JSON.stringify(baseCriteria.values ?? []) + const compareValuesJSON = JSON.stringify(compareCriteria.values ?? []) + if(baseCriteria.operator != compareCriteria.operator || baseValuesJSON != compareValuesJSON) + { + viewDiffs.push(`Changed a filter from ${FilterUtils.criteriaToHumanString(tableMetaData, baseCriteria)} to ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteria)}`) + } + } + else if(noBaseCriteria == noCompareCriteria) + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else - if the number of criteria on this field differs, that'll get caught in a non-isCheckForChanged call, so // + // todo, i guess - this is kinda weak - but if there's the same number of criteria on a field, then just ... do a shitty JSON compare between them... // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const baseJSON = JSON.stringify(baseCriteriaMap[fieldName]) + const compareJSON = JSON.stringify(compareCriteriaMap[fieldName]) + if(baseJSON != compareJSON) + { + viewDiffs.push(`${messagePrefix} 1 or more filters on ${fieldNameToLabel(fieldName)}`); + } + } + } + else + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else - we're not checking for changes to individual criteria - rather - we're just checking if criteria were added or removed. // + // we'll do that by starting to see if the nubmer of criteria is different. // + // and, only do it in only 1 direction, assuming we'll get called twice, with the base & compare sides flipped // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(noBaseCriteria < noCompareCriteria) + { + if (noBaseCriteria == 0 && noCompareCriteria == 1) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if the difference is 0 to 1 (1 to 0 when called in reverse), then we can report the full criteria that was added/removed // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + viewDiffs.push(`${messagePrefix} filter: ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteriaMap[fieldName][0])}`) + } + else + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else, say 0 to 2, or 2 to 1 - just report on how many were changed... // + // todo this isn't great, as you might have had, say, (A,B), and now you have (C) - but all we'll say is "removed 1"... // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const noDiffs = noCompareCriteria - noBaseCriteria; + viewDiffs.push(`${messagePrefix} ${noDiffs} filters on ${fieldNameToLabel(fieldName)}`) + } + } + } + } + }; + + diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Added"); + diffCriteriaFunction(activeView.queryFilter, savedView.queryFilter, "Removed"); + diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Changed", true); + + ////////////////////// + // boolean operator // + ////////////////////// + if (savedView.queryFilter.booleanOperator != activeView.queryFilter.booleanOperator) + { + viewDiffs.push("Changed filter from 'And' to 'Or'") + } + + /////////////// + // order-bys // + /////////////// + const savedOrderBys = savedView.queryFilter.orderBys; + const activeOrderBys = activeView.queryFilter.orderBys; + if (savedOrderBys.length != activeOrderBys.length) + { + viewDiffs.push("Changed sort") + } + else if (savedOrderBys.length > 0) + { + const toWord = ((b: boolean) => b ? "ascending" : "descending"); + if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName && savedOrderBys[0].isAscending != activeOrderBys[0].isAscending) + { + viewDiffs.push(`Changed sort from ${fieldNameToLabel(savedOrderBys[0].fieldName)} ${toWord(savedOrderBys[0].isAscending)} to ${fieldNameToLabel(activeOrderBys[0].fieldName)} ${toWord(activeOrderBys[0].isAscending)}`) + } + else if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName) + { + viewDiffs.push(`Changed sort field from ${fieldNameToLabel(savedOrderBys[0].fieldName)} to ${fieldNameToLabel(activeOrderBys[0].fieldName)}`) + } + else if (savedOrderBys[0].isAscending != activeOrderBys[0].isAscending) + { + viewDiffs.push(`Changed sort direction from ${toWord(savedOrderBys[0].isAscending)} to ${toWord(activeOrderBys[0].isAscending)}`) + } + } + } + catch(e) + { + console.log(`Error looking for differences in filters ${e}`); + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + const diffColumns = (savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => + { + try + { + //////////////////////////////////////////////////////////// + // nested function to help diff visible status of columns // + //////////////////////////////////////////////////////////// + const diffVisibilityFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => + { + const baseColumnsMap: { [name: string]: boolean } = {}; + base.columns.forEach((column) => + { + if (column.isVisible) + { + baseColumnsMap[column.name] = true; + } + }); + + const diffFields: string[] = []; + for (let i = 0; i < compare.columns.length; i++) + { + const column = compare.columns[i]; + if(column.isVisible) + { + if (!baseColumnsMap[column.name]) + { + diffFields.push(fieldNameToLabel(column.name)); + } + } + } + + if (diffFields.length > 0) + { + if (diffFields.length > 5) + { + viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); + } + else + { + viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + } + }; + + /////////////////////////////////////////////////////////// + // nested function to help diff pinned status of columns // + /////////////////////////////////////////////////////////// + const diffPinsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => + { + const baseColumnsMap: { [name: string]: string } = {}; + base.columns.forEach((column) => baseColumnsMap[column.name] = column.pinned); + + const diffFields: string[] = []; + for (let i = 0; i < compare.columns.length; i++) + { + const column = compare.columns[i]; + if (baseColumnsMap[column.name] != column.pinned) + { + diffFields.push(fieldNameToLabel(column.name)); + } + } + + if (diffFields.length > 0) + { + if (diffFields.length > 5) + { + viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); + } + else + { + viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + } + }; + + /////////////////////////////////////////////////// + // nested function to help diff width of columns // + /////////////////////////////////////////////////// + const diffWidthsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => + { + const baseColumnsMap: { [name: string]: number } = {}; + base.columns.forEach((column) => baseColumnsMap[column.name] = column.width); + + const diffFields: string[] = []; + for (let i = 0; i < compare.columns.length; i++) + { + const column = compare.columns[i]; + if (baseColumnsMap[column.name] != column.width) + { + diffFields.push(fieldNameToLabel(column.name)); + } + } + + if (diffFields.length > 0) + { + if (diffFields.length > 5) + { + viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); + } + else + { + viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + } + }; + + diffVisibilityFunction(savedView.queryColumns, activeView.queryColumns, "Turned on visibility for "); + diffVisibilityFunction(activeView.queryColumns, savedView.queryColumns, "Turned off visibility for "); + diffPinsFunction(savedView.queryColumns, activeView.queryColumns, "Changed pinned state for "); + + // console.log(`Saved: ${savedView.queryColumns.columns.map(c => c.name).join(",")}`); + // console.log(`Active: ${activeView.queryColumns.columns.map(c => c.name).join(",")}`); + if(savedView.queryColumns.columns.map(c => c.name).join(",") != activeView.queryColumns.columns.map(c => c.name).join(",")) + { + viewDiffs.push("Changed the order of 1 or more columns."); + } + + diffWidthsFunction(savedView.queryColumns, activeView.queryColumns, "Changed width for "); + } + catch (e) + { + console.log(`Error looking for differences in columns: ${e}`); + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + const diffQuickFilterFieldNames = (savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => + { + try + { + const diffFunction = (base: string[], compare: string[], messagePrefix: string) => + { + const baseFieldNameMap: { [name: string]: boolean } = {}; + base.forEach((name) => baseFieldNameMap[name] = true); + const diffFields: string[] = []; + for (let i = 0; i < compare.length; i++) + { + const name = compare[i]; + if (!baseFieldNameMap[name]) + { + diffFields.push(fieldNameToLabel(name)); + } + } + + if (diffFields.length > 0) + { + viewDiffs.push(`${messagePrefix} basic filter${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + } + + diffFunction(savedView.quickFilterFieldNames, activeView.quickFilterFieldNames, "Turned on"); + diffFunction(activeView.quickFilterFieldNames, savedView.quickFilterFieldNames, "Turned off"); + } + catch (e) + { + console.log(`Error looking for differences in quick filter field names: ${e}`); + } + } + + + let viewIsModified = false; + let viewDiffs:string[] = []; + + if(currentSavedView != null) + { + const savedView = JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView; + const activeView = view; + + diffFilters(savedView, activeView, viewDiffs); + diffColumns(savedView, activeView, viewDiffs); + diffQuickFilterFieldNames(savedView, activeView, viewDiffs); + + if(savedView.mode != activeView.mode) + { + viewDiffs.push(`Mode changed from ${savedView.mode} to ${activeView.mode}`) + } + + if(savedView.rowsPerPage != activeView.rowsPerPage) + { + viewDiffs.push(`Rows per page changed from ${savedView.rowsPerPage} to ${activeView.rowsPerPage}`) + } + + if(viewDiffs.length > 0) + { + viewIsModified = true; + } + } + + + /******************************************************************************* + ** make request to load all saved filters from backend + *******************************************************************************/ + async function loadSavedViews() + { + if (! tableMetaData) + { + return; + } + + const formData = new FormData(); + formData.append("tableName", tableMetaData.name); + + let savedViews = await makeSavedViewRequest("querySavedView", formData); + setSavedViews(savedViews); + } + + + + /******************************************************************************* + ** fired when a saved record is clicked from the dropdown + *******************************************************************************/ + const handleSavedViewRecordOnClick = async (record: QRecord) => + { + setSaveFilterPopupOpen(false); + closeSavedViewsMenu(); + viewOnChangeCallback(record.values.get("id")); + navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`); + }; + + + + /******************************************************************************* + ** fired when a save option is selected from the save... button/dropdown combo + *******************************************************************************/ + const handleDropdownOptionClick = (optionName: string) => + { + setSaveOptionsOpen(false); + setPopupAlertContent(null); + closeSavedViewsMenu(); + setSaveFilterPopupOpen(true); + setIsSaveFilterAs(false); + setIsRenameFilter(false); + setIsDeleteFilter(false) + + switch(optionName) + { + case SAVE_OPTION: + break; + case DUPLICATE_OPTION: + setIsSaveFilterAs(true); + break; + case CLEAR_OPTION: + setSaveFilterPopupOpen(false) + viewOnChangeCallback(null); + navigate(metaData.getTablePathByName(tableMetaData.name)); + break; + case RENAME_OPTION: + if(currentSavedView != null) + { + setSavedViewNameInputValue(currentSavedView.values.get("label")); + } + setIsRenameFilter(true); + break; + case DELETE_OPTION: + setIsDeleteFilter(true) + break; + } + } + + + + /******************************************************************************* + ** fired when save or delete button saved on confirmation dialogs + *******************************************************************************/ + async function handleFilterDialogButtonOnClick() + { + try + { + const formData = new FormData(); + if (isDeleteFilter) + { + formData.append("id", currentSavedView.values.get("id")); + await makeSavedViewRequest("deleteSavedView", formData); + await(async() => + { + handleDropdownOptionClick(CLEAR_OPTION); + })(); + } + else + { + formData.append("tableName", tableMetaData.name); + + //////////////////////////////////////////////////////////////////////////////////////////////////// + // clone view via json serialization/deserialization // + // then replace the queryFilter in it with a copy that has had its possible values changed to ids // + // then stringify that for the backend // + //////////////////////////////////////////////////////////////////////////////////////////////////// + const viewObject = JSON.parse(JSON.stringify(view)); + viewObject.queryFilter = JSON.parse(JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(viewObject.queryFilter))); + formData.append("viewJson", JSON.stringify(viewObject)); + + if (isSaveFilterAs || isRenameFilter || currentSavedView == null) + { + formData.append("label", savedViewNameInputValue); + if(currentSavedView != null && isRenameFilter) + { + formData.append("id", currentSavedView.values.get("id")); + } + } + else + { + formData.append("id", currentSavedView.values.get("id")); + formData.append("label", currentSavedView?.values.get("label")); + } + const recordList = await makeSavedViewRequest("storeSavedView", formData); + await(async() => + { + if (recordList && recordList.length > 0) + { + setSavedViewsHaveLoaded(false); + loadSavedViews(); + handleSavedViewRecordOnClick(recordList[0]); + } + })(); + } + } + catch (e: any) + { + setPopupAlertContent(JSON.stringify(e.message)); + } + } + + + + /******************************************************************************* + ** hides/shows the save options + *******************************************************************************/ + const handleToggleSaveOptions = () => + { + setSaveOptionsOpen((prevOpen) => !prevOpen); + }; + + + + /******************************************************************************* + ** closes save options menu (on clickaway) + *******************************************************************************/ + const handleSaveOptionsMenuClose = (event: Event) => + { + if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) + { + return; + } + + setSaveOptionsOpen(false); + }; + + + + /******************************************************************************* + ** stores the current dialog input text to state + *******************************************************************************/ + const handleSaveFilterInputChange = (event: React.ChangeEvent) => + { + setSavedViewNameInputValue(event.target.value); + }; + + + + /******************************************************************************* + ** closes current dialog + *******************************************************************************/ + const handleSaveFilterPopupClose = () => + { + setSaveFilterPopupOpen(false); + }; + + + + /******************************************************************************* + ** make a request to the backend for various savedView processes + *******************************************************************************/ + async function makeSavedViewRequest(processName: string, formData: FormData): Promise + { + ///////////////////////// + // fetch saved filters // + ///////////////////////// + let savedViews = [] as QRecord[] + try + { + ////////////////////////////////////////////////////////////////// + // we don't want this job to go async, so, pass a large timeout // + ////////////////////////////////////////////////////////////////// + formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000); + const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders()); + if (processResult instanceof QJobError) + { + const jobError = processResult as QJobError; + throw(jobError.error); + } + else + { + const result = processResult as QJobComplete; + if(result.values.savedViewList) + { + for (let i = 0; i < result.values.savedViewList.length; i++) + { + const qRecord = new QRecord(result.values.savedViewList[i]); + savedViews.push(qRecord); + } + } + } + } + catch (e) + { + throw(e); + } + + return (savedViews); + } + + const hasStorePermission = metaData?.processes.has("storeSavedView"); + const hasDeletePermission = metaData?.processes.has("deleteSavedView"); + const hasQueryPermission = metaData?.processes.has("querySavedView"); + + const tooltipMaxWidth = (maxWidth: string) => + { + return ({slotProps: { + tooltip: { + sx: { + maxWidth: maxWidth + } + } + }}) + } + + const menuTooltipAttribs = {...tooltipMaxWidth("250px"), placement: "left", enterDelay: 1000} as TooltipProps; + + const renderSavedViewsMenu = tableMetaData && ( + + Actions + { + hasStorePermission && + Save your current filters, columns and settings, for quick re-use at a later time.

    You will be prompted to enter a name if you choose this option.}> + handleDropdownOptionClick(SAVE_OPTION)}> + save + Save... + +
    + } + { + hasStorePermission && + + handleDropdownOptionClick(RENAME_OPTION)}> + edit + Rename... + + + } + { + hasStorePermission && + + handleDropdownOptionClick(DUPLICATE_OPTION)}> + content_copy + Duplicate... + + + } + { + hasDeletePermission && + + handleDropdownOptionClick(DELETE_OPTION)}> + delete + Delete... + + + } + { + + handleDropdownOptionClick(CLEAR_OPTION)}> + monitor + New View + + + } + + Your Saved Views + { + savedViews && savedViews.length > 0 ? ( + savedViews.map((record: QRecord, index: number) => + handleSavedViewRecordOnClick(record)}> + {record.values.get("label")} + + ) + ): ( + + No views have been saved for this table. + + ) + } +
    + ); + + return ( + hasQueryPermission && tableMetaData ? ( + + + {renderSavedViewsMenu} + + + { + savedViewsHaveLoaded && currentSavedView && ( + Current View:  + + { + loadingSavedView + ? "..." + : + <> + {currentSavedView.values.get("label")} + { + viewIsModified && ( + The current view has been modified: +
      + { + viewDiffs.map((s: string, i: number) =>
    • {s}
    • ) + } +
    Click "Save..." to save the changes.}> + +
    + ) + } + + } +
    +
    + ) + } +
    +
    + { + + { + if (e.key == "Enter") + { + handleFilterDialogButtonOnClick(); + } + }} + > + { + currentSavedView ? ( + isDeleteFilter ? ( + Delete View + ) : ( + isSaveFilterAs ? ( + Save View As + ):( + isRenameFilter ? ( + Rename View + ):( + Update Existing View + ) + ) + ) + ):( + Save New View + ) + } + + { + (! currentSavedView || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? ( + + { + isSaveFilterAs ? ( + Enter a name for this new saved view. + ):( + Enter a new name for this saved view. + ) + } + + { + event.target.select(); + }} + /> + + ):( + isDeleteFilter ? ( + Are you sure you want to delete the view {`'${currentSavedView?.values.get("label")}'`}? + ):( + Are you sure you want to update the view {`'${currentSavedView?.values.get("label")}'`}? + ) + ) + } + {popupAlertContent ? ( + + {popupAlertContent} + + ) : ("")} + + + + { + isDeleteFilter ? + + : + + } + + + } +
    + ) : null + ); +} + +export default SavedViews; From 4d5040e29d71efbaa97b3b3464b7b5d528545f5e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 19:56:44 -0600 Subject: [PATCH 15/40] CE-793 - Add method getFieldFullLabel --- src/qqq/utils/qqq/TableUtils.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/qqq/utils/qqq/TableUtils.ts b/src/qqq/utils/qqq/TableUtils.ts index dd0f6eb..b1b08f3 100644 --- a/src/qqq/utils/qqq/TableUtils.ts +++ b/src/qqq/utils/qqq/TableUtils.ts @@ -113,6 +113,31 @@ class TableUtils return (null); } + + /******************************************************************************* + ** for a field that might be from a join table, get its label - either the field's + ** label, if it's from "this" table - or the table's label: field's label, if it's + ** from a join table. + *******************************************************************************/ + public static getFieldFullLabel(tableMetaData: QTableMetaData, fieldName: string): string + { + try + { + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + if (fieldTable.name == tableMetaData.name) + { + return (field.label); + } + return `${fieldTable.label}: ${field.label}`; + } + catch (e) + { + console.log(`Error getting full field label for ${fieldName} in table ${tableMetaData?.name}: ${e}`); + return fieldName + } + } + + /******************************************************************************* ** *******************************************************************************/ From fa680c5a80865476d6983c6eada21b1780396d3a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 19:57:56 -0600 Subject: [PATCH 16/40] CE-793 - Initial add of models for savedViews rewrite of RecordQuery --- src/qqq/models/query/QQueryColumns.ts | 312 ++++++++++++++++++++++++ src/qqq/models/query/RecordQueryView.ts | 82 +++++++ 2 files changed, 394 insertions(+) create mode 100644 src/qqq/models/query/QQueryColumns.ts create mode 100644 src/qqq/models/query/RecordQueryView.ts diff --git a/src/qqq/models/query/QQueryColumns.ts b/src/qqq/models/query/QQueryColumns.ts new file mode 100644 index 0000000..8ba2a8d --- /dev/null +++ b/src/qqq/models/query/QQueryColumns.ts @@ -0,0 +1,312 @@ +/* + * 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {GridPinnedColumns} from "@mui/x-data-grid-pro"; +import DataGridUtils from "qqq/utils/DataGridUtils"; + +/******************************************************************************* + ** member object + *******************************************************************************/ +interface Column +{ + name: string; + isVisible: boolean; + width: number; + pinned?: "left" | "right"; +} + +/******************************************************************************* + ** Model for all info we'll store about columns on a query screen. + *******************************************************************************/ +export default class QQueryColumns +{ + columns: Column[] = []; + + /******************************************************************************* + ** factory function - build a QQueryColumns object from JSON (string or parsed object). + ** + ** input json is must look like if you JSON.stringify this class - that is: + ** {columns: [{name:"",isVisible:true,width:0,pinned:"left"},{}...]} + *******************************************************************************/ + public static buildFromJSON = (json: string | any): QQueryColumns => + { + const queryColumns = new QQueryColumns(); + + if (typeof json == "string") + { + json = JSON.parse(json); + } + + queryColumns.columns = json.columns; + + return (queryColumns); + }; + + + /******************************************************************************* + ** factory function - build a default QQueryColumns object for a table + ** + *******************************************************************************/ + public static buildDefaultForTable = (table: QTableMetaData): QQueryColumns => + { + const queryColumns = new QQueryColumns(); + + queryColumns.columns = []; + queryColumns.columns.push({name: "__check__", isVisible: true, width: 100, pinned: "left"}); + + const fields = this.getSortedFieldsFromTable(table); + fields.forEach((field) => + { + const column: Column = {name: field.name, isVisible: true, width: DataGridUtils.getColumnWidthForField(field, table)}; + queryColumns.columns.push(column); + + if (field.name == table.primaryKeyField) + { + column.pinned = "left"; + } + }); + + table.exposedJoins?.forEach((exposedJoin) => + { + const joinFields = this.getSortedFieldsFromTable(exposedJoin.joinTable); + joinFields.forEach((field) => + { + const column: Column = {name: `${exposedJoin.joinTable.name}.${field.name}`, isVisible: false, width: DataGridUtils.getColumnWidthForField(field, null)}; + queryColumns.columns.push(column); + }); + }); + + return (queryColumns); + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + private static getSortedFieldsFromTable(table: QTableMetaData) + { + const fields = [...table.fields.values()]; + fields.sort((a: QFieldMetaData, b: QFieldMetaData) => + { + return a.name.localeCompare(b.name); + }); + return fields; + } + + /******************************************************************************* + ** + *******************************************************************************/ + public updateVisibility = (visibilityModel: { [name: string]: boolean }): void => + { + for (let i = 0; i < this.columns.length; i++) + { + const name = this.columns[i].name; + this.columns[i].isVisible = visibilityModel[name]; + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public updateColumnOrder = (names: string[]): void => + { + const newColumns: Column[] = []; + const rest: Column[] = []; + + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + const index = names.indexOf(column.name); + if (index > -1) + { + newColumns[index] = column; + } + else + { + rest.push(column); + } + } + + this.columns = [...newColumns, ...rest]; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public updateColumnWidth = (name: string, width: number): void => + { + for (let i = 0; i < this.columns.length; i++) + { + if (this.columns[i].name == name) + { + this.columns[i].width = width; + } + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public setPinnedLeftColumns = (names: string[]): void => + { + const leftPins: Column[] = []; + const rest: Column[] = []; + + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + const pinIndex = names ? names.indexOf(column.name) : -1; + if (pinIndex > -1) + { + column.pinned = "left"; + leftPins[pinIndex] = column; + } + else + { + if (column.pinned == "left") + { + column.pinned = undefined; + } + rest.push(column); + } + } + + this.columns = [...leftPins, ...rest]; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public setPinnedRightColumns = (names: string[]): void => + { + const rightPins: Column[] = []; + const rest: Column[] = []; + + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + const pinIndex = names ? names.indexOf(column.name) : -1; + if (pinIndex > -1) + { + column.pinned = "right"; + rightPins[pinIndex] = column; + } + else + { + if (column.pinned == "right") + { + column.pinned = undefined; + } + rest.push(column); + } + } + + this.columns = [...rest, ...rightPins]; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public getColumnSortValues = (): { [name: string]: number } => + { + const sortValues: { [name: string]: number } = {}; + for (let i = 0; i < this.columns.length; i++) + { + sortValues[this.columns[i].name] = i; + } + return sortValues; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public getColumnWidths = (): { [name: string]: number } => + { + const widths: { [name: string]: number } = {}; + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + widths[column.name] = column.width; + } + return widths; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public toGridPinnedColumns = (): GridPinnedColumns => + { + const gridPinnedColumns: GridPinnedColumns = {left: [], right: []}; + + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + if (column.pinned == "left") + { + gridPinnedColumns.left.push(column.name); + } + else if (column.pinned == "right") + { + gridPinnedColumns.right.push(column.name); + } + } + + return gridPinnedColumns; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public toColumnVisibilityModel = (): { [index: string]: boolean } => + { + const columnVisibilityModel: { [index: string]: boolean } = {}; + + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + columnVisibilityModel[column.name] = column.isVisible; + } + + return columnVisibilityModel; + }; + +} + + +/******************************************************************************* + ** subclass of QQueryColumns - used as a marker, to indicate that the table + ** isn't yet loaded, so it just a placeholder. + *******************************************************************************/ +export class PreLoadQueryColumns extends QQueryColumns +{ +} + diff --git a/src/qqq/models/query/RecordQueryView.ts b/src/qqq/models/query/RecordQueryView.ts new file mode 100644 index 0000000..9784b29 --- /dev/null +++ b/src/qqq/models/query/RecordQueryView.ts @@ -0,0 +1,82 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import QQueryColumns, {PreLoadQueryColumns} from "qqq/models/query/QQueryColumns"; + + +/******************************************************************************* + ** Model to represent the full "view" that is active on the RecordQuery screen + ** (and accordingly, can be saved as a saved view). + *******************************************************************************/ +export default class RecordQueryView +{ + queryFilter: QQueryFilter; // contains orderBys + queryColumns: QQueryColumns; // contains on/off, sequence, widths, and pins + viewIdentity: string; // url vs. saved vs. ad-hoc, plus "noncey" stuff? not very used... + rowsPerPage: number; + quickFilterFieldNames: string[]; + mode: string; + // variant? + + /******************************************************************************* + ** + *******************************************************************************/ + constructor() + { + } + + + /******************************************************************************* + ** factory function - build a RecordQueryView object from JSON (string or parsed object). + ** + ** input json is must look like if you JSON.stringify this class - that is: + ** {queryFilter: {}, queryColumns: {}, etc...} + *******************************************************************************/ + public static buildFromJSON = (json: string | any): RecordQueryView => + { + const view = new RecordQueryView(); + + if (typeof json == "string") + { + json = JSON.parse(json); + } + + view.queryFilter = json.queryFilter as QQueryFilter; + + if(json.queryColumns) + { + view.queryColumns = QQueryColumns.buildFromJSON(json.queryColumns); + } + else + { + view.queryColumns = new PreLoadQueryColumns(); + } + + view.viewIdentity = json.viewIdentity; + view.rowsPerPage = json.rowsPerPage; + view.quickFilterFieldNames = json.quickFilterFieldNames; + view.mode = json.mode; + + return (view); + }; + +} \ No newline at end of file From 4fd72f9c7715efef2b637e922c594edf7bf03332 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 19:59:41 -0600 Subject: [PATCH 17/40] CE-793 - Initial UI/UX from designer applied --- src/qqq/components/query/QuickFilter.tsx | 66 ++++++++++++++++++++---- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/src/qqq/components/query/QuickFilter.tsx b/src/qqq/components/query/QuickFilter.tsx index 4170aa6..3807bf3 100644 --- a/src/qqq/components/query/QuickFilter.tsx +++ b/src/qqq/components/query/QuickFilter.tsx @@ -32,7 +32,8 @@ import Icon from "@mui/material/Icon"; import IconButton from "@mui/material/IconButton"; import Menu from "@mui/material/Menu"; import TextField from "@mui/material/TextField"; -import React, {SyntheticEvent, useState} from "react"; +import React, {SyntheticEvent, useContext, useState} from "react"; +import QContext from "QContext"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; import {getDefaultCriteriaValue, getOperatorOptions, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; @@ -60,6 +61,11 @@ QuickFilter.defaultProps = let seedId = new Date().getTime() % 173237; +export const quickFilterButtonStyles = { + fontSize: "0.75rem", color: "#757575", textTransform: "none", borderRadius: "2rem", border: "1px solid #757575", + minWidth: "3.5rem", minHeight: "auto", padding: "0.375rem 0.625rem", whiteSpace: "nowrap" +} + /******************************************************************************* ** Test if a CriteriaParamType represents an actual query criteria - or, if it's ** null or the "tooComplex" placeholder. @@ -144,6 +150,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue); + const {accentColor} = useContext(QContext); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// // handle a change to the criteria from outside this component (e.g., the prop isn't the same as the state) // ////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -314,12 +323,15 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData /******************************************************************************* ** event handler for clicking the (x) icon that turns off this quick filter field. - ** hands off control to the function that was passed in (e.g., from RecordQuery). + ** hands off control to the function that was passed in (e.g., from RecordQueryOrig). *******************************************************************************/ const handleTurningOffQuickFilterField = () => { closeMenu() - handleRemoveQuickFilterField(criteria?.fieldName); + if(handleRemoveQuickFilterField) + { + handleRemoveQuickFilterField(criteria?.fieldName); + } } //////////////////////////////////////////////////////////////////////////////////// @@ -361,9 +373,14 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData startIcon = {startIcon} } + let buttonAdditionalStyles: any = {}; let buttonContent = {tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label} if (criteriaIsValid) { + buttonAdditionalStyles.backgroundColor = accentColor + " !important"; + buttonAdditionalStyles.borderColor = accentColor + " !important"; + buttonAdditionalStyles.color = "white !important"; + buttonContent = ( {buttonContent} @@ -373,8 +390,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData let button = fieldMetaData && ; @@ -395,6 +411,39 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData ); } + /******************************************************************************* + ** event handler for 'x' button - either resets the criteria or turns off the field. + *******************************************************************************/ + const xClicked = (e: React.MouseEvent) => + { + e.stopPropagation(); + if(criteriaIsValid) + { + resetCriteria(e); + } + else + { + handleTurningOffQuickFilterField(); + } + } + + ///////////////////////////////////////////////////////////////////////////////////// + // only show the 'x' if it's to clear out a valid criteria on the field, // + // or if we were given a callback to remove the quick-filter field from the screen // + ///////////////////////////////////////////////////////////////////////////////////// + let xIcon = ; + if(criteriaIsValid || handleRemoveQuickFilterField) + { + xIcon = close + } + ////////////////////////////// // return the button & menu // ////////////////////////////// @@ -402,14 +451,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData return ( <> {button} + {xIcon} { isOpen && - { - handleRemoveQuickFilterField && - - highlight_off - - } Date: Mon, 29 Jan 2024 20:00:55 -0600 Subject: [PATCH 18/40] CE-793 - Always show table's default quick-filters; move some state up to parent (part of saved-views) --- .../query/BasicAndAdvancedQueryControls.tsx | 291 +++++++++--------- 1 file changed, 149 insertions(+), 142 deletions(-) diff --git a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx index a84d9a9..beef1dc 100644 --- a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx +++ b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx @@ -39,13 +39,13 @@ import DialogTitle from "@mui/material/DialogTitle"; import Icon from "@mui/material/Icon"; import Menu from "@mui/material/Menu"; import Tooltip from "@mui/material/Tooltip"; -import {GridFilterModel} from "@mui/x-data-grid-pro"; import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro"; import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react"; import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; -import QuickFilter from "qqq/components/query/QuickFilter"; +import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; +import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; @@ -53,11 +53,14 @@ interface BasicAndAdvancedQueryControlsProps { metaData: QInstance; tableMetaData: QTableMetaData; - queryFilter: QQueryFilter; - gridApiRef: React.MutableRefObject + quickFilterFieldNames: string[]; + setQuickFilterFieldNames: (names: string[]) => void; + + queryFilter: QQueryFilter; setQueryFilter: (queryFilter: QQueryFilter) => void; - handleFilterChange: (filterModel: GridFilterModel, doSetQueryFilter?: boolean, isChangeFromDataGrid?: boolean) => void; + + gridApiRef: React.MutableRefObject; ///////////////////////////////////////////////////////////////////////////////////////////// // this prop is used as a way to recognize changes in the query filter internal structure, // @@ -73,30 +76,20 @@ let debounceTimeout: string | number | NodeJS.Timeout; /******************************************************************************* ** Component to provide the basic & advanced query-filter controls for the - ** RecordQuery screen. + ** RecordQueryOrig screen. ** - ** Done as a forwardRef, so RecordQuery can call some functions, e.g., when user + ** Done as a forwardRef, so RecordQueryOrig can call some functions, e.g., when user ** does things on that screen, that we need to know about in here. *******************************************************************************/ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) => { - const {metaData, tableMetaData, queryFilter, gridApiRef, setQueryFilter, handleFilterChange, queryFilterJSON, mode, setMode} = props - - ///////////////////////////////////////////////////////// - // get the quick-filter-field-names from local storage // - ///////////////////////////////////////////////////////// - const QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT = "qqq.quickFilterFieldNames"; - const quickFilterFieldNamesLocalStorageKey = `${QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT}.${tableMetaData.name}`; - let defaultQuickFilterFieldNames: Set = new Set(); - if (localStorage.getItem(quickFilterFieldNamesLocalStorageKey)) - { - defaultQuickFilterFieldNames = new Set(JSON.parse(localStorage.getItem(quickFilterFieldNamesLocalStorageKey))); - } + const {metaData, tableMetaData, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props ///////////////////// // state variables // ///////////////////// - const [quickFilterFieldNames, setQuickFilterFieldNames] = useState(defaultQuickFilterFieldNames); + const [defaultQuickFilterFieldNames, setDefaultQuickFilterFieldNames] = useState(getDefaultQuickFilterFieldNames(tableMetaData)); + const [defaultQuickFilterFieldNameMap, setDefaultQuickFilterFieldNameMap] = useState(Object.fromEntries(defaultQuickFilterFieldNames.map(k => [k, true]))); const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null) const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0); const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false); @@ -115,6 +108,10 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo addField(fieldName: string) { addQuickFilterField({fieldName: fieldName}, "columnMenu"); + }, + getCurrentMode() + { + return (mode); } } }); @@ -167,8 +164,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo { queryFilter.criteria.splice(foundIndex, 1); setQueryFilter(queryFilter); - const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); - handleFilterChange(gridFilterModel, false); } return; } @@ -189,8 +184,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo debounceTimeout = setTimeout(() => { setQueryFilter(queryFilter); - const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); - handleFilterChange(gridFilterModel, false); }, needDebounce ? 500 : 1); forceUpdate(); @@ -228,23 +221,14 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo }; - /******************************************************************************* - ** set the quick-filter field names state variable and local-storage - *******************************************************************************/ - const storeQuickFilterFieldNames = () => - { - setQuickFilterFieldNames(new Set([...quickFilterFieldNames.values()])); - localStorage.setItem(quickFilterFieldNamesLocalStorageKey, JSON.stringify([...quickFilterFieldNames.values()])); - } - - /******************************************************************************* ** Event handler for QuickFilter component, to remove a quick filter field from ** the screen. *******************************************************************************/ const handleRemoveQuickFilterField = (fieldName: string): void => { - if(quickFilterFieldNames.has(fieldName)) + const index = quickFilterFieldNames.indexOf(fieldName) + if(index >= 0) { ////////////////////////////////////// // remove this field from the query // @@ -252,8 +236,8 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo const criteria = new QFilterCriteria(fieldName, null, []); updateQuickCriteria(criteria, false, true); - quickFilterFieldNames.delete(fieldName); - storeQuickFilterFieldNames(); + quickFilterFieldNames.splice(index, 1); + setQuickFilterFieldNames(quickFilterFieldNames); } }; @@ -281,7 +265,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo ** Add a quick-filter field to the screen, from either the user selecting one, ** or from a new query being activated, etc. *******************************************************************************/ - const addQuickFilterField = (newValue: any, reason: "blur" | "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | "columnMenu" | string) => + const addQuickFilterField = (newValue: any, reason: "blur" | "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | "columnMenu" | "activatedView" | string) => { console.log(`Adding quick filter field as: ${JSON.stringify(newValue)}`); if (reason == "blur") @@ -295,18 +279,18 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo const fieldName = newValue ? newValue.fieldName : null; if (fieldName) { - if (!quickFilterFieldNames.has(fieldName)) + if (quickFilterFieldNames.indexOf(fieldName) == -1) { ///////////////////////////////// // add the field if we need to // ///////////////////////////////// - quickFilterFieldNames.add(fieldName); - storeQuickFilterFieldNames(); + quickFilterFieldNames.push(fieldName); + setQuickFilterFieldNames(quickFilterFieldNames); /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // only do this when user has added the field (e.g., not when adding it because of a selected view or filter-in-url) // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if(reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected") + if(reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected" && reason != "activatedView") { setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5); } @@ -342,7 +326,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo if (isYesButton || event.key == "Enter") { setShowClearFiltersWarning(false); - handleFilterChange({items: []} as GridFilterModel); + setQueryFilter(new QQueryFilter()); } }; @@ -372,7 +356,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo return ( {counter > 1 ? {queryFilter.booleanOperator}  : } - {field.label} {criteria.operator} {valuesString}  + {FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)} ); } @@ -454,60 +438,21 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo } - ////////////////////////////////////////////////////////////////////////////// - // if there aren't any quick-filters turned on, get defaults from the table // - // only run this block upon a first-render // - ////////////////////////////////////////////////////////////////////////////// - const [firstRender, setFirstRender] = useState(true); - if(firstRender) + /******************************************************************************* + ** count how many valid criteria are in the query - for showing badge + *******************************************************************************/ + const countValidCriteria = (queryFilter: QQueryFilter): number => { - setFirstRender(false); - - if (defaultQuickFilterFieldNames == null || defaultQuickFilterFieldNames.size == 0) + let count = 0; + for (let i = 0; i < queryFilter?.criteria?.length; i++) { - defaultQuickFilterFieldNames = new Set(); - - ////////////////////////////////////////////////////////////////////////////////////////////////// - // check if there's materialDashboard tableMetaData, and if it has defaultQuickFilterFieldNames // - ////////////////////////////////////////////////////////////////////////////////////////////////// - const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard"); - if (mdbMetaData) + const {criteriaIsValid} = validateCriteria(queryFilter.criteria[i], null); + if(criteriaIsValid) { - if (mdbMetaData?.defaultQuickFilterFieldNames?.length) - { - for (let i = 0; i < mdbMetaData.defaultQuickFilterFieldNames.length; i++) - { - defaultQuickFilterFieldNames.add(mdbMetaData.defaultQuickFilterFieldNames[i]); - } - } + count++; } - - ///////////////////////////////////////////// - // if still none, then look for T1 section // - ///////////////////////////////////////////// - if (defaultQuickFilterFieldNames.size == 0) - { - if (tableMetaData.sections) - { - const t1Sections = tableMetaData.sections.filter((s: QTableSection) => s.tier == "T1"); - if (t1Sections.length) - { - for (let i = 0; i < t1Sections.length; i++) - { - if (t1Sections[i].fieldNames) - { - for (let j = 0; j < t1Sections[i].fieldNames.length; j++) - { - defaultQuickFilterFieldNames.add(t1Sections[i].fieldNames[j]); - } - } - } - } - } - } - - setQuickFilterFieldNames(defaultQuickFilterFieldNames); } + return count; } //////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -523,13 +468,13 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo /////////////////////////////////////////////////// // set some status flags based on current filter // /////////////////////////////////////////////////// - const hasValidFilters = queryFilter && queryFilter.criteria && queryFilter.criteria.length > 0; // todo - should be better (e.g., see if operator & values are set) + const hasValidFilters = queryFilter && countValidCriteria(queryFilter) > 0; const {canFilterWorkAsBasic, reasonsWhyItCannot} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); let reasonWhyBasicIsDisabled = null; if(reasonsWhyItCannot && reasonsWhyItCannot.length > 0) { reasonWhyBasicIsDisabled = <> - Your current Filter cannot be managed using BASIC mode because: + Your current Filter cannot be managed using Basic mode because:
      {reasonsWhyItCannot.map((reason, i) =>
    • {reason}
    • )}
    @@ -542,15 +487,14 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo { mode == "basic" && - { - tableMetaData && - [...quickFilterFieldNames.values()].map((fieldName) => + <> { - const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName); - let defaultOperator = getDefaultOperatorForField(field); + tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) => + { + const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = getDefaultOperatorForField(field); - return ( - field && - ); - }) - } - { - tableMetaData && - <> - - - - - - addQuickFilterField(newValue, reason)} - autoFocus={true} - forceOpen={Boolean(addQuickFilterMenu)} - hiddenFieldNames={[...quickFilterFieldNames.values()]} - /> - - - - } + handleRemoveQuickFilterField={null} />); + }) + } + + { + tableMetaData && quickFilterFieldNames?.map((fieldName) => + { + const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = getDefaultOperatorForField(field); + + return (defaultQuickFilterFieldNameMap[fieldName] ? null : ); + }) + } + { + tableMetaData && + <> + + + + + + addQuickFilterField(newValue, reason)} + autoFocus={true} + forceOpen={Boolean(addQuickFilterMenu)} + hiddenFieldNames={[...defaultQuickFilterFieldNames, ...quickFilterFieldNames]} + /> + + + + } + } { metaData && tableMetaData && mode == "advanced" && <> - @@ -641,14 +603,13 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo { metaData && tableMetaData && - Mode: modeToggleClicked(newValue)} size="small" - sx={{pl: 0.5}} + sx={{pl: 0.5, width: "10rem"}} > Basic Advanced @@ -661,4 +622,50 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo ); }); +export function getDefaultQuickFilterFieldNames(table: QTableMetaData): string[] +{ + const defaultQuickFilterFieldNames: string[] = []; + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // check if there's materialDashboard tableMetaData, and if it has defaultQuickFilterFieldNames // + ////////////////////////////////////////////////////////////////////////////////////////////////// + const mdbMetaData = table?.supplementalTableMetaData?.get("materialDashboard"); + if (mdbMetaData) + { + if (mdbMetaData?.defaultQuickFilterFieldNames?.length) + { + for (let i = 0; i < mdbMetaData.defaultQuickFilterFieldNames.length; i++) + { + defaultQuickFilterFieldNames.push(mdbMetaData.defaultQuickFilterFieldNames[i]); + } + } + } + + ///////////////////////////////////////////// + // if still none, then look for T1 section // + ///////////////////////////////////////////// + if (defaultQuickFilterFieldNames.length == 0) + { + if (table.sections) + { + const t1Sections = table.sections.filter((s: QTableSection) => s.tier == "T1"); + if (t1Sections.length) + { + for (let i = 0; i < t1Sections.length; i++) + { + if (t1Sections[i].fieldNames) + { + for (let j = 0; j < t1Sections[i].fieldNames.length; j++) + { + defaultQuickFilterFieldNames.push(t1Sections[i].fieldNames[j]); + } + } + } + } + } + } + + return (defaultQuickFilterFieldNames); +} + export default BasicAndAdvancedQueryControls; \ No newline at end of file From 4d5beea6076ed0e002852a6475af6af101e917d6 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 20:01:46 -0600 Subject: [PATCH 19/40] CE-793 - Add return type to validateCriteria; switch to work on criteria.operator, not operatorSelectedValue (enum) --- .../components/query/FilterCriteriaRow.tsx | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index bed7987..745ca78 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -24,7 +24,7 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; -import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete"; +import Autocomplete from "@mui/material/Autocomplete"; import Box from "@mui/material/Box"; import FormControl from "@mui/material/FormControl/FormControl"; import Icon from "@mui/material/Icon/Icon"; @@ -182,7 +182,7 @@ FilterCriteriaRow.defaultProps = { }; -export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue: OperatorOption) +export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string} { let criteriaIsValid = true; let criteriaStatusTooltip = "This condition is fully defined and is part of your filter."; @@ -211,37 +211,34 @@ export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValu } else { - if (operatorSelectedValue) + if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK) { - if (operatorSelectedValue.valueMode == ValueMode.NONE || operatorSelectedValue.implicitValues) + ////////////////////////////////// + // don't need to look at values // + ////////////////////////////////// + } + else if (criteria.operator == QCriteriaOperator.BETWEEN || criteria.operator == QCriteriaOperator.NOT_BETWEEN) + { + if (criteria.values.length < 2 || isNotSet(criteria.values[0]) || isNotSet(criteria.values[1])) { - ////////////////////////////////// - // don't need to look at values // - ////////////////////////////////// + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter two values to complete the definition of this condition."; } - else if (operatorSelectedValue.valueMode == ValueMode.DOUBLE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE_TIME) + } + else if (criteria.operator == QCriteriaOperator.IN || criteria.operator == QCriteriaOperator.NOT_IN) + { + if (criteria.values.length < 1 || isNotSet(criteria.values[0])) { - if (criteria.values.length < 2 || isNotSet(criteria.values[0]) || isNotSet(criteria.values[1])) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must enter two values to complete the definition of this condition."; - } + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition."; } - else if (operatorSelectedValue.valueMode == ValueMode.MULTI || operatorSelectedValue.valueMode == ValueMode.PVS_MULTI) + } + else + { + if (!criteria.values || isNotSet(criteria.values[0])) { - if (criteria.values.length < 1 || isNotSet(criteria.values[0])) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition."; - } - } - else - { - if (!criteria.values || isNotSet(criteria.values[0])) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must enter a value to complete the definition of this condition."; - } + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter a value to complete the definition of this condition."; } } } From b7f34dee21ffe1813d6f3b4dd4999ee4709e885f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 20:02:42 -0600 Subject: [PATCH 20/40] CE-793 - Rename as .tsx; remove functions that worked with GridFilter; add some functions for human-strings & JSON processing; --- src/qqq/utils/qqq/FilterUtils.ts | 849 ------------------------------ src/qqq/utils/qqq/FilterUtils.tsx | 485 +++++++++++++++++ 2 files changed, 485 insertions(+), 849 deletions(-) delete mode 100644 src/qqq/utils/qqq/FilterUtils.ts create mode 100644 src/qqq/utils/qqq/FilterUtils.tsx diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts deleted file mode 100644 index 2feb61d..0000000 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ /dev/null @@ -1,849 +0,0 @@ -/* - * 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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; -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 {NowExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowExpression"; -import {NowWithOffsetExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression"; -import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; -import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; -import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; -import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; -import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression"; -import {GridFilterItem, GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro"; -import TableUtils from "qqq/utils/qqq/TableUtils"; -import ValueUtils from "qqq/utils/qqq/ValueUtils"; - -const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId"; - -/******************************************************************************* - ** Utility class for working with QQQ Filters - ** - *******************************************************************************/ -class FilterUtils -{ - /******************************************************************************* - ** Convert a grid operator to a QQQ Criteria Operator. - *******************************************************************************/ - public static gridCriteriaOperatorToQQQ = (operator: string): QCriteriaOperator => - { - switch (operator) - { - 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 "=": - case "isTrue": - case "isFalse": - return QCriteriaOperator.EQUALS; - case "isNot": - case "!=": - return QCriteriaOperator.NOT_EQUALS_OR_IS_NULL; - case "after": - case ">": - return QCriteriaOperator.GREATER_THAN; - case "onOrAfter": - case ">=": - return QCriteriaOperator.GREATER_THAN_OR_EQUALS; - case "before": - case "<": - return QCriteriaOperator.LESS_THAN; - case "onOrBefore": - case "<=": - return QCriteriaOperator.LESS_THAN_OR_EQUALS; - case "isEmpty": - return QCriteriaOperator.IS_BLANK; - case "isNotEmpty": - return QCriteriaOperator.IS_NOT_BLANK; - case "isAnyOf": - return QCriteriaOperator.IN; - case "isNone": - return QCriteriaOperator.NOT_IN; - case "between": - return QCriteriaOperator.BETWEEN; - case "notBetween": - return QCriteriaOperator.NOT_BETWEEN; - default: - return QCriteriaOperator.EQUALS; - } - }; - - /******************************************************************************* - ** Convert a qqq criteria operator to one expected by the grid. - *******************************************************************************/ - 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: - case QFieldType.DECIMAL: - return ("="); - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - case QFieldType.STRING: - case QFieldType.TEXT: - case QFieldType.HTML: - case QFieldType.PASSWORD: - case QFieldType.BLOB: - return ("equals"); - case QFieldType.BOOLEAN: - if (criteriaValues && criteriaValues[0] === true) - { - return ("isTrue"); - } - else if (criteriaValues && criteriaValues[0] === false) - { - return ("isFalse"); - } - return ("is"); - default: - return ("is"); - } - case QCriteriaOperator.NOT_EQUALS: - case QCriteriaOperator.NOT_EQUALS_OR_IS_NULL: - - if (field.possibleValueSourceName) - { - return ("isNot"); - } - - switch (fieldType) - { - case QFieldType.INTEGER: - case QFieldType.DECIMAL: - return ("!="); - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - case QFieldType.BOOLEAN: - case QFieldType.STRING: - case QFieldType.TEXT: - case QFieldType.HTML: - case QFieldType.PASSWORD: - case QFieldType.BLOB: - default: - return ("isNot"); - } - case QCriteriaOperator.IN: - return ("isAnyOf"); - case QCriteriaOperator.NOT_IN: - return ("isNone"); - case QCriteriaOperator.STARTS_WITH: - return ("startsWith"); - case QCriteriaOperator.ENDS_WITH: - return ("endsWith"); - case QCriteriaOperator.CONTAINS: - return ("contains"); - case QCriteriaOperator.NOT_STARTS_WITH: - return ("notStartsWith"); - case QCriteriaOperator.NOT_ENDS_WITH: - return ("notEndsWith"); - case QCriteriaOperator.NOT_CONTAINS: - return ("notContains"); - case QCriteriaOperator.LESS_THAN: - switch (fieldType) - { - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - return ("before"); - default: - return ("<"); - } - case QCriteriaOperator.LESS_THAN_OR_EQUALS: - switch (fieldType) - { - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - return ("onOrBefore"); - default: - return ("<="); - } - case QCriteriaOperator.GREATER_THAN: - switch (fieldType) - { - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - return ("after"); - default: - return (">"); - } - case QCriteriaOperator.GREATER_THAN_OR_EQUALS: - switch (fieldType) - { - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - return ("onOrAfter"); - default: - return (">="); - } - case QCriteriaOperator.IS_BLANK: - return ("isEmpty"); - case QCriteriaOperator.IS_NOT_BLANK: - return ("isNotEmpty"); - case QCriteriaOperator.BETWEEN: - return ("between"); - case QCriteriaOperator.NOT_BETWEEN: - return ("notBetween"); - default: - console.warn(`Unhandled criteria operator: ${operator}`); - return ("="); - } - }; - - /******************************************************************************* - ** the values object needs handled differently based on cardinality of the operator. - ** that is - qqq always wants a list, but the grid provides it differently per-operator. - ** for single-values (the default), we must wrap it in an array. - ** for non-values (e.g., blank), set it to null. - ** for list-values, it's already in an array, so don't wrap it. - *******************************************************************************/ - public static gridCriteriaValueToQQQ = (operator: QCriteriaOperator, value: any, gridOperatorValue: string, fieldMetaData: QFieldMetaData): any[] => - { - if (gridOperatorValue === "isTrue") - { - return [true]; - } - else if (gridOperatorValue === "isFalse") - { - return [false]; - } - - if (operator === QCriteriaOperator.IS_BLANK || operator === QCriteriaOperator.IS_NOT_BLANK) - { - return (null); - } - else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN) - { - if ((value == null || value.length < 2) && (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 (FilterUtils.cleanseCriteriaValueForQQQ(value, fieldMetaData)); - } - - return (FilterUtils.cleanseCriteriaValueForQQQ([value], fieldMetaData)); - }; - - - /******************************************************************************* - ** 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). - ** - ** Or, if the values are date-times, convert them to UTC. - *******************************************************************************/ - private static cleanseCriteriaValueForQQQ = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] => - { - if (param === null || param === undefined) - { - return (param); - } - - if (FilterUtils.gridCriteriaValueToExpression(param)) - { - 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 // - // during build of new custom filter panel, this ended up causing us // - // problems (because we wanted the full PV object in the filter model for the frontend) // - // so, we can keep the PV as-is here, and see calls to convertFilterPossibleValuesToIds // - // to do what this used to do. // - ////////////////////////////////////////////////////////////////////////////////////////// - // rs.push(param[i].id); - rs.push(param[i]); - } - else - { - if (fieldMetaData?.type == QFieldType.DATE_TIME) - { - try - { - let toPush = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(param[i]); - rs.push(toPush); - } - catch (e) - { - console.log("Error converting date-time to UTC: ", e); - rs.push(param[i]); - } - } - 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; - } - else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN) - { - return (values); - } - - if (values && values.length > 0) - { - //////////////////////////////////////////////////////////////////////////////////////////////// - // make sure dates are formatted for the grid the way it expects - not the way we pass it in. // - //////////////////////////////////////////////////////////////////////////////////////////////// - if (fieldType === QFieldType.DATE_TIME) - { - for (let i = 0; i < values.length; i++) - { - if (!values[i].type) - { - values[i] = ValueUtils.formatDateTimeValueForForm(values[i]); - } - } - } - } - - return (values ? values[0] : ""); - }; - - - /******************************************************************************* - ** Get the default filter to use on the page - either from given filter string, query string param, or - ** local storage, or a default (empty). - *******************************************************************************/ - public static async determineFilterAndSortModels(qController: QController, tableMetaData: QTableMetaData, filterString: string, searchParams: URLSearchParams, filterLocalStorageKey: string, sortLocalStorageKey: string): Promise<{ filter: GridFilterModel, sort: GridSortItem[], warning: string }> - { - let defaultFilter = {items: []} as GridFilterModel; - let defaultSort = [] as GridSortItem[]; - let warningParts = [] as string[]; - - if (tableMetaData && tableMetaData.fields !== undefined) - { - if (filterString != null || (searchParams && searchParams.has("filter"))) - { - try - { - const filterJSON = (filterString !== null) ? JSON.parse(filterString) : JSON.parse(searchParams.get("filter")); - const qQueryFilter = filterJSON as QQueryFilter; - - ////////////////////////////////////////////////////////////////// - // translate from a qqq-style filter to one that the grid wants // - ////////////////////////////////////////////////////////////////// - let id = 1; - for (let i = 0; i < qQueryFilter?.criteria?.length; i++) - { - const criteria = qQueryFilter.criteria[i]; - let [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName); - if (field == null) - { - console.log("Couldn't find field for filter: " + criteria.fieldName); - warningParts.push("Your filter contained an unrecognized field name: " + criteria.fieldName); - continue; - } - - 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(fieldTable.name, null, field.name, "", values); - } - - //////////////////////////////////////////// - // log message if no values were returned // - //////////////////////////////////////////// - if (!values || values.length === 0) - { - console.warn("WARNING: No possible values were returned for [" + field.possibleValueSourceName + "] for values [" + criteria.values + "]."); - } - } - - ////////////////////////////////////////////////////////////////////////// - // replace objects that look like expressions with expression instances // - ////////////////////////////////////////////////////////////////////////// - if (values && values.length) - { - for (let i = 0; i < values.length; i++) - { - const expression = this.gridCriteriaValueToExpression(values[i]); - if (expression) - { - values[i] = expression; - } - } - } - - defaultFilter.items.push({ - columnField: criteria.fieldName, - operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, values), - value: FilterUtils.qqqCriteriaValuesToGrid(criteria.operator, values, field), - id: id++ - }); - } - - defaultFilter.linkOperator = GridLinkOperator.And; - if (qQueryFilter.booleanOperator === "OR") - { - defaultFilter.linkOperator = GridLinkOperator.Or; - } - - ///////////////////////////////////////////////////////////////// - // translate from qqq-style orderBy to one that the grid wants // - ///////////////////////////////////////////////////////////////// - if (qQueryFilter.orderBys && qQueryFilter.orderBys.length > 0) - { - for (let i = 0; i < qQueryFilter.orderBys.length; i++) - { - const orderBy = qQueryFilter.orderBys[i]; - defaultSort.push({ - field: orderBy.fieldName, - sort: orderBy.isAscending ? "asc" : "desc" - }); - } - } - - if (searchParams && searchParams.has("filter")) - { - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // if we're setting the filter based on a filter query-string param, then make sure we don't have a currentSavedFilter in local storage. // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - localStorage.removeItem(`${CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT}.${tableMetaData.name}`); - localStorage.setItem(filterLocalStorageKey, JSON.stringify(defaultFilter)); - localStorage.setItem(sortLocalStorageKey, JSON.stringify(defaultSort)); - } - - return ({filter: defaultFilter, sort: defaultSort, warning: warningParts.length > 0 ? "Warning: " + warningParts.join("; ") : ""}); - } - catch (e) - { - console.warn("Error parsing filter from query string", e); - } - } - - if (localStorage.getItem(filterLocalStorageKey)) - { - defaultFilter = JSON.parse(localStorage.getItem(filterLocalStorageKey)); - console.log(`Got default from LS: ${JSON.stringify(defaultFilter)}`); - } - - if (localStorage.getItem(sortLocalStorageKey)) - { - defaultSort = JSON.parse(localStorage.getItem(sortLocalStorageKey)); - console.log(`Got default from LS: ${JSON.stringify(defaultSort)}`); - } - } - - ///////////////////////////////////////////////////////////////////////////////// - // if any values in the items are objects, but should be expression instances, // - // then convert & replace them. // - ///////////////////////////////////////////////////////////////////////////////// - if (defaultFilter && defaultFilter.items && defaultFilter.items.length) - { - defaultFilter.items.forEach((item) => - { - if (item.value && item.value.length) - { - for (let i = 0; i < item.value.length; i++) - { - const expression = this.gridCriteriaValueToExpression(item.value[i]); - if (expression) - { - item.value[i] = expression; - } - } - } - else - { - const expression = this.gridCriteriaValueToExpression(item.value); - if (expression) - { - item.value = expression; - } - } - }); - } - - return ({filter: defaultFilter, sort: defaultSort, warning: warningParts.length > 0 ? "Warning: " + warningParts.join("; ") : ""}); - } - - - /******************************************************************************* - ** build a grid filter from a qqq filter - *******************************************************************************/ - public static buildGridFilterFromQFilter(tableMetaData: QTableMetaData, queryFilter: QQueryFilter): GridFilterModel - { - const gridItems: GridFilterItem[] = []; - - for (let i = 0; i < queryFilter.criteria.length; i++) - { - const criteria = queryFilter.criteria[i]; - const [field, fieldTable] = FilterUtils.getField(tableMetaData, criteria.fieldName); - if (field) - { - gridItems.push({columnField: criteria.fieldName, id: i, operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, criteria.values), value: FilterUtils.qqqCriteriaValuesToGrid(criteria.operator, criteria.values, field)}); - } - } - - const gridFilter: GridFilterModel = {items: gridItems, linkOperator: queryFilter.booleanOperator == "AND" ? GridLinkOperator.And : GridLinkOperator.Or}; - return (gridFilter); - } - - - /******************************************************************************* - ** - *******************************************************************************/ - public static getField(tableMetaData: QTableMetaData, fieldName: string): [QFieldMetaData, QTableMetaData] - { - if (fieldName == null) - { - return ([null, null]); - } - - if (fieldName.indexOf(".") > -1) - { - let parts = fieldName.split(".", 2); - if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length) - { - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) - { - const joinTable = tableMetaData.exposedJoins[i].joinTable; - if (joinTable.name == parts[0]) - { - return ([joinTable.fields.get(parts[1]), joinTable]); - } - } - } - - console.log(`Failed to find join field: ${fieldName}`); - return ([null, null]); - } - else - { - return ([tableMetaData.fields.get(fieldName), tableMetaData]); - } - } - - - /******************************************************************************* - ** build a qqq filter from a grid and column sort model - *******************************************************************************/ - public static buildQFilterFromGridFilter(tableMetaData: QTableMetaData, filterModel: GridFilterModel, columnSortModel: GridSortItem[], limit?: number, allowIncompleteCriteria = false): QQueryFilter - { - console.log("Building q filter with model:"); - console.log(filterModel); - - const qFilter = new QQueryFilter(); - if (columnSortModel) - { - columnSortModel.forEach((gridSortItem) => - { - qFilter.addOrderBy(new QFilterOrderBy(gridSortItem.field, gridSortItem.sort === "asc")); - }); - } - - if (limit) - { - console.log("Setting limit to: " + limit); - qFilter.limit = limit; - } - - if (filterModel) - { - let foundFilter = false; - filterModel.items.forEach((item) => - { - ///////////////////////////////////////////////////////////////////////// - // set the values for these operators that otherwise don't have values // - ///////////////////////////////////////////////////////////////////////// - if (item.operatorValue === "isTrue") - { - item.value = [true]; - } - else if (item.operatorValue === "isFalse") - { - item.value = [false]; - } - - //////////////////////////////////////////////////////////////////////////////// - // if no value set and not 'empty' or 'not empty' operators, skip this filter // - //////////////////////////////////////////////////////////////////////////////// - let incomplete = false; - if (item.operatorValue === "between" || item.operatorValue === "notBetween") - { - if (!item.value || !item.value.length || item.value.length < 2 || this.isUnset(item.value[0]) || this.isUnset(item.value[1])) - { - incomplete = true; - } - } - else if ((!item.value || item.value.length == 0 || (item.value.length == 1 && this.isUnset(item.value[0]))) && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty") - { - incomplete = true; - } - - if (incomplete && !allowIncompleteCriteria) - { - console.log(`Discarding incomplete filter criteria: ${JSON.stringify(item)}`); - return; - } - - const fieldMetadata = tableMetaData?.fields.get(item.columnField); - const operator = FilterUtils.gridCriteriaOperatorToQQQ(item.operatorValue); - const values = FilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue, fieldMetadata); - let criteria = new QFilterCriteria(item.columnField, operator, values); - qFilter.addCriteria(criteria); - foundFilter = true; - }); - - 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; - }; - - - /******************************************************************************* - ** - *******************************************************************************/ - private static isUnset(value: any) - { - return value === "" || value === undefined; - } - - /******************************************************************************* - ** - *******************************************************************************/ - private static gridCriteriaValueToExpression(value: any) - { - if (value && value.length) - { - value = value[0]; - } - - if (value && value.type) - { - if (value.type == "NowWithOffset") - { - return (new NowWithOffsetExpression(value)); - } - else if (value.type == "Now") - { - return (new NowExpression(value)); - } - else if (value.type == "ThisOrLastPeriod") - { - return (new ThisOrLastPeriodExpression(value)); - } - } - - return (null); - } - - - /******************************************************************************* - ** edit the input filter object, replacing any values which have {id,label} attributes - ** to instead just have the id part. - *******************************************************************************/ - public static convertFilterPossibleValuesToIds(inputFilter: QQueryFilter): QQueryFilter - { - const filter = Object.assign({}, inputFilter); - - if (filter.criteria) - { - for (let i = 0; i < filter.criteria.length; i++) - { - const criteria = filter.criteria[i]; - if (criteria.values) - { - for (let j = 0; j < criteria.values.length; j++) - { - let value = criteria.values[j]; - if (value && value.id && value.label) - { - criteria.values[j] = value.id; - } - } - } - } - } - - return (filter); - } - - - /******************************************************************************* - ** - *******************************************************************************/ - public static canFilterWorkAsBasic(tableMetaData: QTableMetaData, filter: QQueryFilter): { canFilterWorkAsBasic: boolean; reasonsWhyItCannot?: string[] } - { - const reasonsWhyItCannot: string[] = []; - - if(filter == null) - { - return ({canFilterWorkAsBasic: true}); - } - - if(filter.booleanOperator == "OR") - { - reasonsWhyItCannot.push("Filter uses the 'OR' operator.") - } - - if(filter.criteria) - { - const usedFields: {[name: string]: boolean} = {}; - const warnedFields: {[name: string]: boolean} = {}; - for (let i = 0; i < filter.criteria.length; i++) - { - const criteriaName = filter.criteria[i].fieldName; - if(!criteriaName) - { - continue; - } - - if(usedFields[criteriaName]) - { - if(!warnedFields[criteriaName]) - { - const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, criteriaName); - let fieldLabel = field.label; - if(tableForField.name != tableMetaData.name) - { - let fieldLabel = `${tableForField.label}: ${field.label}`; - } - reasonsWhyItCannot.push(`Filter contains more than 1 condition for the field: ${fieldLabel}`); - warnedFields[criteriaName] = true; - } - } - usedFields[criteriaName] = true; - } - } - - if(reasonsWhyItCannot.length == 0) - { - return ({canFilterWorkAsBasic: true}); - } - else - { - return ({canFilterWorkAsBasic: false, reasonsWhyItCannot: reasonsWhyItCannot}); - } - } - - /******************************************************************************* - ** get the values associated with a criteria as a string, e.g., for showing - ** in a tooltip. - *******************************************************************************/ - public static getValuesString(fieldMetaData: QFieldMetaData, criteria: QFilterCriteria, maxValuesToShow: number = 3): string - { - let valuesString = ""; - if (criteria.values && criteria.values.length && fieldMetaData.type !== QFieldType.BOOLEAN) - { - let labels = [] as string[]; - - let maxLoops = criteria.values.length; - if (maxLoops > (maxValuesToShow + 2)) - { - maxLoops = maxValuesToShow; - } - - for (let i = 0; i < maxLoops; i++) - { - if (criteria.values[i] && criteria.values[i].label) - { - labels.push(criteria.values[i].label); - } - else - { - labels.push(criteria.values[i]); - } - } - - if (maxLoops < criteria.values.length) - { - labels.push(" and " + (criteria.values.length - maxLoops) + " other values."); - } - - valuesString = (labels.join(", ")); - } - return valuesString; - } - -} - -export default FilterUtils; diff --git a/src/qqq/utils/qqq/FilterUtils.tsx b/src/qqq/utils/qqq/FilterUtils.tsx new file mode 100644 index 0000000..ae30d48 --- /dev/null +++ b/src/qqq/utils/qqq/FilterUtils.tsx @@ -0,0 +1,485 @@ +/* + * 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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; +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 {NowExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowExpression"; +import {NowWithOffsetExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression"; +import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression"; +import {GridSortModel} from "@mui/x-data-grid-pro"; +import TableUtils from "qqq/utils/qqq/TableUtils"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +/******************************************************************************* + ** Utility class for working with QQQ Filters + ** + *******************************************************************************/ +class FilterUtils +{ + + /******************************************************************************* + ** 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). + ** + ** Or, if the values are date-times, convert them to UTC. + *******************************************************************************/ + public static cleanseCriteriaValueForQQQ = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] => + { + if (param === null || param === undefined) + { + return (param); + } + + if (FilterUtils.gridCriteriaValueToExpression(param)) + { + 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 // + // during build of new custom filter panel, this ended up causing us // + // problems (because we wanted the full PV object in the filter model for the frontend) // + // so, we can keep the PV as-is here, and see calls to convertFilterPossibleValuesToIds // + // to do what this used to do. // + ////////////////////////////////////////////////////////////////////////////////////////// + // rs.push(param[i].id); + rs.push(param[i]); + } + else + { + if (fieldMetaData?.type == QFieldType.DATE_TIME) + { + try + { + let toPush = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(param[i]); + rs.push(toPush); + } + catch (e) + { + console.log("Error converting date-time to UTC: ", e); + rs.push(param[i]); + } + } + else + { + rs.push(param[i]); + } + } + } + return (rs); + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public static async cleanupValuesInFilerFromQueryString(qController: QController, tableMetaData: QTableMetaData, queryFilter: QQueryFilter) + { + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const criteria = queryFilter.criteria[i]; + let [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, 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(fieldTable.name, null, field.name, "", values); + } + + //////////////////////////////////////////// + // log message if no values were returned // + //////////////////////////////////////////// + if (!values || values.length === 0) + { + console.warn("WARNING: No possible values were returned for [" + field.possibleValueSourceName + "] for values [" + criteria.values + "]."); + } + } + + ////////////////////////////////////////////////////////////////////////// + // replace objects that look like expressions with expression instances // + ////////////////////////////////////////////////////////////////////////// + if (values && values.length) + { + for (let i = 0; i < values.length; i++) + { + const expression = this.gridCriteriaValueToExpression(values[i]); + if (expression) + { + values[i] = expression; + } + } + } + + criteria.values = values; + } + } + + + /******************************************************************************* + ** given a table, and a field name (which may be prefixed with an exposed-join + ** table name (from the table) - return the corresponding field-meta-data, and + ** the table that the field is from (e.g., may be a join table!) + *******************************************************************************/ + public static getField(tableMetaData: QTableMetaData, fieldName: string): [QFieldMetaData, QTableMetaData] + { + if (fieldName == null) + { + return ([null, null]); + } + + if (fieldName.indexOf(".") > -1) + { + let parts = fieldName.split(".", 2); + if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length) + { + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const joinTable = tableMetaData.exposedJoins[i].joinTable; + if (joinTable.name == parts[0]) + { + return ([joinTable.fields.get(parts[1]), joinTable]); + } + } + } + + console.log(`Failed to find join field: ${fieldName}`); + return ([null, null]); + } + else + { + return ([tableMetaData.fields.get(fieldName), tableMetaData]); + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + private static gridCriteriaValueToExpression(value: any) + { + if (value && value.length) + { + value = value[0]; + } + + if (value && value.type) + { + if (value.type == "NowWithOffset") + { + return (new NowWithOffsetExpression(value)); + } + else if (value.type == "Now") + { + return (new NowExpression(value)); + } + else if (value.type == "ThisOrLastPeriod") + { + return (new ThisOrLastPeriodExpression(value)); + } + } + + return (null); + } + + + /******************************************************************************* + ** edit the input filter object, replacing any values which have {id,label} attributes + ** to instead just have the id part. + *******************************************************************************/ + public static convertFilterPossibleValuesToIds(inputFilter: QQueryFilter): QQueryFilter + { + const filter = Object.assign({}, inputFilter); + + if (filter.criteria) + { + for (let i = 0; i < filter.criteria.length; i++) + { + const criteria = filter.criteria[i]; + if (criteria.values) + { + for (let j = 0; j < criteria.values.length; j++) + { + let value = criteria.values[j]; + if (value && value.id && value.label) + { + criteria.values[j] = value.id; + } + } + } + } + } + + return (filter); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static canFilterWorkAsBasic(tableMetaData: QTableMetaData, filter: QQueryFilter): { canFilterWorkAsBasic: boolean; reasonsWhyItCannot?: string[] } + { + const reasonsWhyItCannot: string[] = []; + + if(filter == null) + { + return ({canFilterWorkAsBasic: true}); + } + + if(filter.booleanOperator == "OR") + { + reasonsWhyItCannot.push("Filter uses the 'OR' operator.") + } + + if(filter.criteria) + { + const usedFields: {[name: string]: boolean} = {}; + const warnedFields: {[name: string]: boolean} = {}; + for (let i = 0; i < filter.criteria.length; i++) + { + const criteriaName = filter.criteria[i].fieldName; + if(!criteriaName) + { + continue; + } + + if(usedFields[criteriaName]) + { + if(!warnedFields[criteriaName]) + { + const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, criteriaName); + let fieldLabel = field.label; + if(tableForField.name != tableMetaData.name) + { + let fieldLabel = `${tableForField.label}: ${field.label}`; + } + reasonsWhyItCannot.push(`Filter contains more than 1 condition for the field: ${fieldLabel}`); + warnedFields[criteriaName] = true; + } + } + usedFields[criteriaName] = true; + } + } + + if(reasonsWhyItCannot.length == 0) + { + return ({canFilterWorkAsBasic: true}); + } + else + { + return ({canFilterWorkAsBasic: false, reasonsWhyItCannot: reasonsWhyItCannot}); + } + } + + /******************************************************************************* + ** get the values associated with a criteria as a string, e.g., for showing + ** in a tooltip. + *******************************************************************************/ + public static getValuesString(fieldMetaData: QFieldMetaData, criteria: QFilterCriteria, maxValuesToShow: number = 3): string + { + let valuesString = ""; + if (criteria.values && criteria.values.length && fieldMetaData.type !== QFieldType.BOOLEAN) + { + let labels = [] as string[]; + + let maxLoops = criteria.values.length; + if (maxLoops > (maxValuesToShow + 2)) + { + maxLoops = maxValuesToShow; + } + + for (let i = 0; i < maxLoops; i++) + { + if (criteria.values[i] && criteria.values[i].label) + { + labels.push(criteria.values[i].label); + } + else + { + labels.push(criteria.values[i]); + } + } + + if (maxLoops < criteria.values.length) + { + labels.push(" and " + (criteria.values.length - maxLoops) + " other values."); + } + + valuesString = (labels.join(", ")); + } + return valuesString; + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static buildQFilterFromJSONObject(object: any): QQueryFilter + { + const queryFilter = new QQueryFilter(); + + queryFilter.criteria = []; + for (let i = 0; i < object.criteria?.length; i++) + { + const criteriaObject = object.criteria[i]; + queryFilter.criteria.push(new QFilterCriteria(criteriaObject.fieldName, criteriaObject.operator, criteriaObject.values)); + } + + queryFilter.orderBys = []; + for (let i = 0; i < object.orderBys?.length; i++) + { + const orderByObject = object.orderBys[i]; + queryFilter.orderBys.push(new QFilterOrderBy(orderByObject.fieldName, orderByObject.isAscending)); + } + + queryFilter.booleanOperator = object.booleanOperator; + queryFilter.skip = object.skip; + queryFilter.limit = object.limit; + + return (queryFilter); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static getGridSortFromQueryFilter(queryFilter: QQueryFilter): GridSortModel + { + const gridSortModel: GridSortModel = []; + for (let i = 0; i < queryFilter?.orderBys?.length; i++) + { + const orderBy = queryFilter.orderBys[i]; + gridSortModel.push({field: orderBy.fieldName, sort: orderBy.isAscending ? "asc" : "desc"}) + } + return (gridSortModel); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static operatorToHumanString(criteria: QFilterCriteria): string + { + if(criteria == null || criteria.operator == null) + { + return (null); + } + + try + { + switch(criteria.operator) + { + case QCriteriaOperator.EQUALS: + return ("equals"); + case QCriteriaOperator.NOT_EQUALS: + case QCriteriaOperator.NOT_EQUALS_OR_IS_NULL: + return ("does not equal"); + case QCriteriaOperator.IN: + return ("is any of"); + case QCriteriaOperator.NOT_IN: + return ("is none of"); + case QCriteriaOperator.STARTS_WITH: + return ("starts with"); + case QCriteriaOperator.ENDS_WITH: + return ("ends with"); + case QCriteriaOperator.CONTAINS: + return ("contains"); + case QCriteriaOperator.NOT_STARTS_WITH: + return ("does not start with"); + case QCriteriaOperator.NOT_ENDS_WITH: + return ("does not end with"); + case QCriteriaOperator.NOT_CONTAINS: + return ("does not contain"); + case QCriteriaOperator.LESS_THAN: + return ("less than"); + case QCriteriaOperator.LESS_THAN_OR_EQUALS: + return ("less than or equals"); + case QCriteriaOperator.GREATER_THAN: + return ("greater than or equals"); + case QCriteriaOperator.GREATER_THAN_OR_EQUALS: + return ("greater than or equals"); + case QCriteriaOperator.IS_BLANK: + return ("is blank"); + case QCriteriaOperator.IS_NOT_BLANK: + return ("is not blank"); + case QCriteriaOperator.BETWEEN: + return ("is between"); + case QCriteriaOperator.NOT_BETWEEN: + return ("is not between"); + } + } + catch(e) + { + console.log(`Error getting operator human string for ${JSON.stringify(criteria)}: ${e}`); + return criteria?.operator + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static criteriaToHumanString(table: QTableMetaData, criteria: QFilterCriteria, styled = false): string | JSX.Element + { + if(criteria == null) + { + return (null); + } + + const [field, fieldTable] = TableUtils.getFieldAndTable(table, criteria.fieldName); + const fieldLabel = TableUtils.getFieldFullLabel(table, criteria.fieldName); + const valuesString = FilterUtils.getValuesString(field, criteria); + + if(styled) + { + return (<> + {fieldLabel} {FilterUtils.operatorToHumanString(criteria)} {valuesString}  + ); + } + else + { + return (`${fieldLabel} ${FilterUtils.operatorToHumanString(criteria)} ${valuesString}`); + } + } + +} + +export default FilterUtils; From bf802dd7cb9001e4c0993e75cc9f72c830fb7e7a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 19:36:48 -0600 Subject: [PATCH 21/40] CE-793 - Refactored components out of RecordQuery.tsx --- .../query/CustomPaginationComponent.tsx | 122 ++++++++++++++++ .../query/QueryScreenActionMenu.tsx | 134 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 src/qqq/components/query/CustomPaginationComponent.tsx create mode 100644 src/qqq/components/query/QueryScreenActionMenu.tsx diff --git a/src/qqq/components/query/CustomPaginationComponent.tsx b/src/qqq/components/query/CustomPaginationComponent.tsx new file mode 100644 index 0000000..c6eccfc --- /dev/null +++ b/src/qqq/components/query/CustomPaginationComponent.tsx @@ -0,0 +1,122 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {TablePagination} from "@mui/material"; +import Box from "@mui/material/Box"; +import Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; +import {GridRowsProp} from "@mui/x-data-grid-pro"; +import React from "react"; +import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +interface CustomPaginationProps +{ + tableMetaData: QTableMetaData; + rows: GridRowsProp[]; + totalRecords: number; + distinctRecords: number; + pageNumber: number; + rowsPerPage: number; + loading: boolean; + isJoinMany: boolean; + handlePageChange: (value: number) => void; + handleRowsPerPageChange: (value: number) => void; +} + +/******************************************************************************* + ** DataGrid custom component - for pagination! + *******************************************************************************/ +export default function CustomPaginationComponent({tableMetaData, rows, totalRecords, distinctRecords, pageNumber, rowsPerPage, loading, isJoinMany, handlePageChange, handleRowsPerPageChange}: CustomPaginationProps): JSX.Element +{ + // @ts-ignore + const defaultLabelDisplayedRows = ({from, to, count}) => + { + const tooltipHTML = <> + The number of rows shown on this screen may be greater than the number of {tableMetaData?.label} records + that match your query, because you have included fields from other tables which may have + more than one record associated with each {tableMetaData?.label}. + + let distinctPart = isJoinMany ? ( +  ({ValueUtils.safeToLocaleString(distinctRecords)} distinct + info_outlined + + ) + ) : <>; + + if (tableMetaData && !tableMetaData.capabilities.has(Capability.TABLE_COUNT)) + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // to avoid a non-countable table showing (this is what data-grid did) 91-100 even if there were only 95 records, // + // we'll do this... not quite good enough, but better than the original // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (rows.length > 0 && rows.length < to - from) + { + to = from + rows.length; + } + return (`Showing ${from.toLocaleString()} to ${to.toLocaleString()}`); + } + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // treat -1 as the sentinel that it's set as below -- remember, we did that so that 'to' would have a value in here when there's no count. // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (count !== null && count !== undefined && count !== -1) + { + if (count === 0) + { + return (loading ? "Counting..." : "No rows"); + } + + return + Showing {from.toLocaleString()} to {to.toLocaleString()} of + { + count == -1 ? + <>more than {to.toLocaleString()} + : <> {count.toLocaleString()}{distinctPart} + } + ; + } + else + { + return ("Counting..."); + } + }; + + + return ( + handlePageChange(value)} + onRowsPerPageChange={(event) => handleRowsPerPageChange(Number(event.target.value))} + labelDisplayedRows={defaultLabelDisplayedRows} + /> + ); + +} diff --git a/src/qqq/components/query/QueryScreenActionMenu.tsx b/src/qqq/components/query/QueryScreenActionMenu.tsx new file mode 100644 index 0000000..73147a4 --- /dev/null +++ b/src/qqq/components/query/QueryScreenActionMenu.tsx @@ -0,0 +1,134 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability"; +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 Divider from "@mui/material/Divider"; +import Icon from "@mui/material/Icon"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import React, {useState} from "react"; +import {useNavigate} from "react-router-dom"; +import {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons"; + +interface QueryScreenActionMenuProps +{ + metaData: QInstance; + tableMetaData: QTableMetaData; + tableProcesses: QProcessMetaData[]; + bulkLoadClicked: () => void; + bulkEditClicked: () => void; + bulkDeleteClicked: () => void; + processClicked: (process: QProcessMetaData) => void; +} + +QueryScreenActionMenu.defaultProps = { +}; + +export default function QueryScreenActionMenu({metaData, tableMetaData, tableProcesses, bulkLoadClicked, bulkEditClicked, bulkDeleteClicked, processClicked}: QueryScreenActionMenuProps): JSX.Element +{ + const [anchorElement, setAnchorElement] = useState(null) + + const navigate = useNavigate(); + + const openActionsMenu = (event: any) => + { + setAnchorElement(event.currentTarget); + } + + const closeActionsMenu = () => + { + setAnchorElement(null); + } + + const pushDividerIfNeeded = (menuItems: JSX.Element[]) => + { + if (menuItems.length > 0) + { + menuItems.push(); + } + }; + + const runSomething = (handler: () => void) => + { + closeActionsMenu(); + handler(); + } + + const menuItems: JSX.Element[] = []; + if (tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission) + { + menuItems.push( runSomething(bulkLoadClicked)}>library_addBulk Load); + } + if (tableMetaData.capabilities.has(Capability.TABLE_UPDATE) && tableMetaData.editPermission) + { + menuItems.push( runSomething(bulkEditClicked)}>editBulk Edit); + } + if (tableMetaData.capabilities.has(Capability.TABLE_DELETE) && tableMetaData.deletePermission) + { + menuItems.push( runSomething(bulkDeleteClicked)}>deleteBulk Delete); + } + + const runRecordScriptProcess = metaData?.processes.get("runRecordScript"); + if (runRecordScriptProcess) + { + const process = runRecordScriptProcess; + menuItems.push( runSomething(() => processClicked(process))}>{process.iconName ?? "arrow_forward"}{process.label}); + } + + menuItems.push( navigate(`${metaData.getTablePathByName(tableMetaData.name)}/dev`)}>codeDeveloper Mode); + + if (tableProcesses && tableProcesses.length) + { + pushDividerIfNeeded(menuItems); + } + + tableProcesses.sort((a, b) => a.label.localeCompare(b.label)); + tableProcesses.map((process) => + { + menuItems.push( runSomething(() => processClicked(process))}>{process.iconName ?? "arrow_forward"}{process.label}); + }); + + if (menuItems.length === 0) + { + menuItems.push(blockNo actions available); + } + + return ( + <> + + + {menuItems} + + + ) +} From e7995c98cc05a3add72c5a18bcbf54cab7481e1f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 20:05:30 -0600 Subject: [PATCH 22/40] CE-793 - Significant rewrite. Primarily, move from savedFilters to savedViews. But also then, move from storing each thing in the 'view' in local storage, to all be under one big view object; re-do the "initialization" of the page; remove DataGrid's filter model. method header comments; yeah. --- src/qqq/pages/records/query/RecordQuery.tsx | 1984 +++++++++++-------- 1 file changed, 1181 insertions(+), 803 deletions(-) diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 4a29d69..005f114 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -29,6 +29,9 @@ import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTa import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete"; import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; +import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {Alert, Collapse, TablePagination, Typography} from "@mui/material"; import Box from "@mui/material/Box"; @@ -38,29 +41,33 @@ import Divider from "@mui/material/Divider"; import Icon from "@mui/material/Icon"; import IconButton from "@mui/material/IconButton"; import LinearProgress from "@mui/material/LinearProgress"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Modal from "@mui/material/Modal"; import Tooltip from "@mui/material/Tooltip"; -import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector, useGridApiRef, GridPreferencePanelsValue, GridColumnResizeParams, ColumnHeaderFilterIconButtonProps} from "@mui/x-data-grid-pro"; +import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, gridFilterableColumnDefinitionsSelector, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} from "@mui/x-data-grid-pro"; import {GridRowModel} from "@mui/x-data-grid/models/gridRows"; import FormData from "form-data"; import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; import {useLocation, useNavigate, useSearchParams} from "react-router-dom"; import QContext from "QContext"; -import {QActionsMenuButton, QCancelButton, QCreateNewButton} from "qqq/components/buttons/DefaultButtons"; +import {QCancelButton, QCreateNewButton} from "qqq/components/buttons/DefaultButtons"; import MenuButton from "qqq/components/buttons/MenuButton"; import {GotoRecordButton} from "qqq/components/misc/GotoRecordDialog"; -import SavedFilters from "qqq/components/misc/SavedFilters"; -import BasicAndAdvancedQueryControls from "qqq/components/query/BasicAndAdvancedQueryControls"; +import SavedViews from "qqq/components/misc/SavedViews"; +import BasicAndAdvancedQueryControls, {getDefaultQuickFilterFieldNames} from "qqq/components/query/BasicAndAdvancedQueryControls"; import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; +import CustomPaginationComponent from "qqq/components/query/CustomPaginationComponent"; import ExportMenuItem from "qqq/components/query/ExportMenuItem"; +import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; +import QueryScreenActionMenu from "qqq/components/query/QueryScreenActionMenu"; import SelectionSubsetDialog from "qqq/components/query/SelectionSubsetDialog"; import TableVariantDialog from "qqq/components/query/TableVariantDialog"; import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import BaseLayout from "qqq/layouts/BaseLayout"; +import {LoadingState} from "qqq/models/LoadingState"; +import QQueryColumns, {PreLoadQueryColumns} from "qqq/models/query/QQueryColumns"; +import RecordQueryView from "qqq/models/query/RecordQueryView"; import ProcessRun from "qqq/pages/processes/ProcessRun"; import ColumnStats from "qqq/pages/records/query/ColumnStats"; import DataGridUtils from "qqq/utils/DataGridUtils"; @@ -70,18 +77,10 @@ import ProcessUtils from "qqq/utils/qqq/ProcessUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; -const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId"; -const COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT = "qqq.columnVisibility"; -const COLUMN_SORT_LOCAL_STORAGE_KEY_ROOT = "qqq.columnSort"; -const FILTER_LOCAL_STORAGE_KEY_ROOT = "qqq.filter"; -const ROWS_PER_PAGE_LOCAL_STORAGE_KEY_ROOT = "qqq.rowsPerPage"; -const PINNED_COLUMNS_LOCAL_STORAGE_KEY_ROOT = "qqq.pinnedColumns"; -const COLUMN_ORDERING_LOCAL_STORAGE_KEY_ROOT = "qqq.columnOrdering"; -const COLUMN_WIDTHS_LOCAL_STORAGE_KEY_ROOT = "qqq.columnWidths"; +const CURRENT_SAVED_VIEW_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedViewId"; const SEEN_JOIN_TABLES_LOCAL_STORAGE_KEY_ROOT = "qqq.seenJoinTables"; const DENSITY_LOCAL_STORAGE_KEY_ROOT = "qqq.density"; -const QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT = "qqq.quickFilterFieldNames"; -const MODE_LOCAL_STORAGE_KEY_ROOT = "qqq.queryScreenMode"; +const VIEW_LOCAL_STORAGE_KEY_ROOT = "qqq.recordQueryView"; export const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant"; @@ -96,8 +95,30 @@ RecordQuery.defaultProps = { launchProcess: null }; +/////////////////////////////////////////////////////// +// define possible values for our pageState variable // +/////////////////////////////////////////////////////// +type PageState = "initial" | "loadingMetaData" | "loadedMetaData" | "loadingView" | "loadedView" | "preparingGrid" | "ready"; + const qController = Client.getInstance(); +/******************************************************************************* + ** function to produce standard version of the screen while we're "loading" + ** like the main table meta data etc. + *******************************************************************************/ +const getLoadingScreen = () => +{ + return ( +   + ); +} + + +/******************************************************************************* + ** QQQ Record Query Screen component. + ** + ** Yuge component. The best. Lots of very smart people are saying so. + *******************************************************************************/ function RecordQuery({table, launchProcess}: Props): JSX.Element { const tableName = table.name; @@ -107,9 +128,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [warningAlert, setWarningAlert] = useState(null as string); const [successAlert, setSuccessAlert] = useState(null as string); - const location = useLocation(); const navigate = useNavigate(); + const location = useLocation(); + const pathParts = location.pathname.replace(/\/+$/, "").split("/"); + const [firstRender, setFirstRender] = useState(true); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // manage "state" being passed from some screens (like delete) into query screen - by grabbing, and then deleting // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if(location.state) { let state: any = location.state; @@ -128,143 +155,185 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element window.history.replaceState(state, ""); } - const pathParts = location.pathname.replace(/\/+$/, "").split("/"); - //////////////////////////////////////////// // look for defaults in the local storage // //////////////////////////////////////////// - const currentSavedFilterLocalStorageKey = `${CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const sortLocalStorageKey = `${COLUMN_SORT_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const rowsPerPageLocalStorageKey = `${ROWS_PER_PAGE_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const pinnedColumnsLocalStorageKey = `${PINNED_COLUMNS_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const columnOrderingLocalStorageKey = `${COLUMN_ORDERING_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const columnWidthsLocalStorageKey = `${COLUMN_WIDTHS_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; + const currentSavedViewLocalStorageKey = `${CURRENT_SAVED_VIEW_ID_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; const seenJoinTablesLocalStorageKey = `${SEEN_JOIN_TABLES_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const columnVisibilityLocalStorageKey = `${COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const filterLocalStorageKey = `${FILTER_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const modeLocalStorageKey = `${MODE_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; + const viewLocalStorageKey = `${VIEW_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; + + ///////////////////////////////////////////////////////////////////////////////////////////////// + // define some default values (e.g., to be used if nothing in local storage or no active view) // + ///////////////////////////////////////////////////////////////////////////////////////////////// let defaultSort = [] as GridSortItem[]; - let defaultVisibility = {} as { [index: string]: boolean }; let didDefaultVisibilityComeFromLocalStorage = false; let defaultRowsPerPage = 10; let defaultDensity = "standard" as GridDensity; - let defaultPinnedColumns = {left: ["__check__", "id"]} as GridPinnedColumns; - let defaultColumnOrdering = null as string[]; - let defaultColumnWidths = {} as {[fieldName: string]: number}; let seenJoinTables: {[tableName: string]: boolean} = {}; let defaultTableVariant: QTableVariant = null; let defaultMode = "basic"; + let defaultQueryColumns: QQueryColumns = new PreLoadQueryColumns(); + let defaultView: RecordQueryView = null; - //////////////////////////////////////////////////////////////////////////////////// - // set the to be not per table (do as above if we want per table) at a later port // - //////////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////// + // set density not to be per-table // + ///////////////////////////////////// const densityLocalStorageKey = `${DENSITY_LOCAL_STORAGE_KEY_ROOT}`; - if (localStorage.getItem(sortLocalStorageKey)) + // only load things out of local storage on the first render + if(firstRender) { - defaultSort = JSON.parse(localStorage.getItem(sortLocalStorageKey)); - } - if (localStorage.getItem(columnVisibilityLocalStorageKey)) - { - defaultVisibility = JSON.parse(localStorage.getItem(columnVisibilityLocalStorageKey)); - didDefaultVisibilityComeFromLocalStorage = true; - } - if (localStorage.getItem(pinnedColumnsLocalStorageKey)) - { - defaultPinnedColumns = JSON.parse(localStorage.getItem(pinnedColumnsLocalStorageKey)); - } - if (localStorage.getItem(columnOrderingLocalStorageKey)) - { - defaultColumnOrdering = JSON.parse(localStorage.getItem(columnOrderingLocalStorageKey)); - } - if (localStorage.getItem(columnWidthsLocalStorageKey)) - { - defaultColumnWidths = JSON.parse(localStorage.getItem(columnWidthsLocalStorageKey)); - } - if (localStorage.getItem(rowsPerPageLocalStorageKey)) - { - defaultRowsPerPage = JSON.parse(localStorage.getItem(rowsPerPageLocalStorageKey)); - } - if (localStorage.getItem(densityLocalStorageKey)) - { - defaultDensity = JSON.parse(localStorage.getItem(densityLocalStorageKey)); - } - if (localStorage.getItem(seenJoinTablesLocalStorageKey)) - { - seenJoinTables = JSON.parse(localStorage.getItem(seenJoinTablesLocalStorageKey)); - } - if (localStorage.getItem(tableVariantLocalStorageKey)) - { - defaultTableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey)); - } - if (localStorage.getItem(modeLocalStorageKey)) - { - defaultMode = localStorage.getItem(modeLocalStorageKey); + if (localStorage.getItem(densityLocalStorageKey)) + { + defaultDensity = JSON.parse(localStorage.getItem(densityLocalStorageKey)); + } + if (localStorage.getItem(seenJoinTablesLocalStorageKey)) + { + seenJoinTables = JSON.parse(localStorage.getItem(seenJoinTablesLocalStorageKey)); + } + if (localStorage.getItem(tableVariantLocalStorageKey)) + { + defaultTableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey)); + } + if (localStorage.getItem(viewLocalStorageKey)) + { + defaultView = RecordQueryView.buildFromJSON(localStorage.getItem(viewLocalStorageKey)); + } } - const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel); - const [lastFetchedQFilterJSON, setLastFetchedQFilterJSON] = useState(""); - const [lastFetchedVariant, setLastFetchedVariant] = useState(null); + if(defaultView == null) + { + defaultView = new RecordQueryView(); + defaultView.queryFilter = new QQueryFilter(); + defaultView.queryColumns = defaultQueryColumns; + defaultView.viewIdentity = "empty"; + defaultView.rowsPerPage = defaultRowsPerPage; + // ... defaultView.quickFilterFieldNames = []; + defaultView.mode = defaultMode; + } + + ///////////////////////////////////////////////////////////////////////////////////////// + // in case the view is missing any of these attributes, give them a reasonable default // + ///////////////////////////////////////////////////////////////////////////////////////// + if(!defaultView.rowsPerPage) + { + defaultView.rowsPerPage = defaultRowsPerPage; + } + if(!defaultView.mode) + { + defaultView.mode = defaultMode; + } + if(!defaultView.quickFilterFieldNames) + { + defaultView.quickFilterFieldNames = []; + } + + /////////////////////////////////// + // state models for the DataGrid // + /////////////////////////////////// const [columnSortModel, setColumnSortModel] = useState(defaultSort); - const [queryFilter, setQueryFilter] = useState(new QQueryFilter()); - const [tableVariant, setTableVariant] = useState(defaultTableVariant); - - const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultVisibility); - const [shouldSetAllNewJoinFieldsToHidden, setShouldSetAllNewJoinFieldsToHidden] = useState(!didDefaultVisibilityComeFromLocalStorage) - const [visibleJoinTables, setVisibleJoinTables] = useState(new Set()); - const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage); + const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultQueryColumns.toColumnVisibilityModel()); + const [columnsModel, setColumnsModel] = useState([] as GridColDef[]); const [density, setDensity] = useState(defaultDensity); - const [pinnedColumns, setPinnedColumns] = useState(defaultPinnedColumns); + const [loading, setLoading] = useState(true); + const [pageNumber, setPageNumber] = useState(0); + const [pinnedColumns, setPinnedColumns] = useState(defaultQueryColumns.toGridPinnedColumns()); + const [rowSelectionModel, setRowSelectionModel] = useState([]); + const [rows, setRows] = useState([] as GridRowsProp[]); + const [rowsPerPage, setRowsPerPage] = useState(defaultView.rowsPerPage); + const [totalRecords, setTotalRecords] = useState(null); + const gridApiRef = useGridApiRef(); + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // state of the page - e.g., have we loaded meta data? what about the initial view? or are we ready to render records. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const [pageState, setPageState] = useState("initial" as PageState) + + /////////////////////////////////////////////////// + // state used by the custom column-chooser panel // + /////////////////////////////////////////////////// const initialColumnChooserOpenGroups = {} as { [name: string]: boolean }; initialColumnChooserOpenGroups[tableName] = true; const [columnChooserOpenGroups, setColumnChooserOpenGroups] = useState(initialColumnChooserOpenGroups); const [columnChooserFilterText, setColumnChooserFilterText] = useState(""); - const [tableState, setTableState] = useState(""); + ///////////////////////////////// + // meta-data and derived state // + ///////////////////////////////// const [metaData, setMetaData] = useState(null as QInstance); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); - const [defaultFilterLoaded, setDefaultFilterLoaded] = useState(false); - const [actionsMenu, setActionsMenu] = useState(null); + const [tableLabel, setTableLabel] = useState(""); const [tableProcesses, setTableProcesses] = useState([] as QProcessMetaData[]); const [allTableProcesses, setAllTableProcesses] = useState([] as QProcessMetaData[]); - const [pageNumber, setPageNumber] = useState(0); - const [totalRecords, setTotalRecords] = useState(null); + + /////////////////////////////////////////// + // state of the view of the query screen // + /////////////////////////////////////////// + const [view, setView] = useState(defaultView) + const [viewAsJson, setViewAsJson] = useState(JSON.stringify(defaultView)) + const [queryFilter, setQueryFilter] = useState(defaultView.queryFilter); + const [queryColumns, setQueryColumns] = useState(defaultView.queryColumns); + const [lastFetchedQFilterJSON, setLastFetchedQFilterJSON] = useState(""); + const [lastFetchedVariant, setLastFetchedVariant] = useState(null); + const [tableVariant, setTableVariant] = useState(defaultTableVariant); + const [quickFilterFieldNames, setQuickFilterFieldNames] = useState(defaultView.quickFilterFieldNames); + + ////////////////////////////////////////////// + // misc state... needs grouped & documented // + ////////////////////////////////////////////// + const [visibleJoinTables, setVisibleJoinTables] = useState(new Set()); const [distinctRecords, setDistinctRecords] = useState(null); + const [tableVariantPromptOpen, setTableVariantPromptOpen] = useState(false); + const [alertContent, setAlertContent] = useState(""); + const [currentSavedView, setCurrentSavedView] = useState(null as QRecord); + const [filterIdInLocation, setFilterIdInLocation] = useState(null as number); + const [loadingSavedView, setLoadingSavedView] = useState(false); + + ///////////////////////////////////////////////////// + // state related to avoiding accidental row clicks // + ///////////////////////////////////////////////////// + const [gridMouseDownX, setGridMouseDownX] = useState(0); + const [gridMouseDownY, setGridMouseDownY] = useState(0); + const [gridPreferencesWindow, setGridPreferencesWindow] = useState(undefined); + + ///////////////////////////////////////////////////////////// + // state related to selecting records for using in actions // + ///////////////////////////////////////////////////////////// const [selectedIds, setSelectedIds] = useState([] as string[]); const [distinctRecordsOnPageCount, setDistinctRecordsOnPageCount] = useState(null as number); const [selectionSubsetSize, setSelectionSubsetSize] = useState(null as number); const [selectionSubsetSizePromptOpen, setSelectionSubsetSizePromptOpen] = useState(false); - const [tableVariantPromptOpen, setTableVariantPromptOpen] = useState(false); const [selectFullFilterState, setSelectFullFilterState] = useState("n/a" as "n/a" | "checked" | "filter" | "filterSubset"); - const [rowSelectionModel, setRowSelectionModel] = useState([]); - const [columnsModel, setColumnsModel] = useState([] as GridColDef[]); - const [rows, setRows] = useState([] as GridRowsProp[]); - const [loading, setLoading] = useState(true); - const [alertContent, setAlertContent] = useState(""); - const [tableLabel, setTableLabel] = useState(""); - const [gridMouseDownX, setGridMouseDownX] = useState(0); - const [gridMouseDownY, setGridMouseDownY] = useState(0); - const [gridPreferencesWindow, setGridPreferencesWindow] = useState(undefined); - const [currentSavedFilter, setCurrentSavedFilter] = useState(null as QRecord); + ////////////////////////////// + // state used for processes // + ////////////////////////////// const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData); - const [launchingProcess, setLaunchingProcess] = useState(launchProcess); const [recordIdsForProcess, setRecordIdsForProcess] = useState([] as string[] | QQueryFilter); + + ///////////////////////////////////////// + // state used for column-stats feature // + ///////////////////////////////////////// const [columnStatsFieldName, setColumnStatsFieldName] = useState(null as string); const [columnStatsField, setColumnStatsField] = useState(null as QFieldMetaData); const [columnStatsFieldTableName, setColumnStatsFieldTableName] = useState(null as string) const [filterForColumnStats, setFilterForColumnStats] = useState(null as QQueryFilter); - const [mode, setMode] = useState(defaultMode); + /////////////////////////////////////////////////// + // state used for basic/advanced query component // + /////////////////////////////////////////////////// + const [mode, setMode] = useState(defaultView.mode); const basicAndAdvancedQueryControlsRef = useRef(); - const instance = useRef({timer: null}); + ///////////////////////////////////////////////////////// + // a timer used to help avoid accidental double-clicks // + ///////////////////////////////////////////////////////// + const timerInstance = useRef({timer: null}); - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // use all these states to avoid showing results from an "old" query, that finishes loading after a newer one // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // state used to avoid showing results from an "old" query, that finishes loading after a newer one // + ////////////////////////////////////////////////////////////////////////////////////////////////////// const [latestQueryId, setLatestQueryId] = useState(0); const [countResults, setCountResults] = useState({} as any); const [receivedCountTimestamp, setReceivedCountTimestamp] = useState(new Date()); @@ -274,28 +343,155 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [queryErrors, setQueryErrors] = useState({} as any); const [receivedQueryErrorTimestamp, setReceivedQueryErrorTimestamp] = useState(new Date()); + ///////////////////////////// + // page context references // + ///////////////////////////// const {setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext); + + ////////////////////// + // ole' faithful... // + ////////////////////// const [, forceUpdate] = useReducer((x) => x + 1, 0); - const openActionsMenu = (event: any) => setActionsMenu(event.currentTarget); - const closeActionsMenu = () => setActionsMenu(null); + /////////////////////////////////////////////////////////////////////////////////////////// + // add a LoadingState object, in case the initial loads (of meta data and view) are slow // + /////////////////////////////////////////////////////////////////////////////////////////// + const [pageLoadingState, _] = useState(new LoadingState(forceUpdate)) - const gridApiRef = useGridApiRef(); + /******************************************************************************* + ** utility function to get the names of any join tables which are active, + ** either as a visible column, or as a query criteria + *******************************************************************************/ + const getVisibleJoinTables = (): Set => + { + const visibleJoinTables = new Set(); + + for (let i = 0; i < queryColumns?.columns.length; i++) + { + const column = queryColumns.columns[i]; + const fieldName = column.name; + if (column.isVisible && fieldName.indexOf(".") > -1) + { + visibleJoinTables.add(fieldName.split(".")[0]); + } + } + + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const criteria = queryFilter.criteria[i]; + const {criteriaIsValid} = validateCriteria(criteria, null); + const fieldName = criteria.fieldName; + if(criteriaIsValid && fieldName && fieldName.indexOf(".") > -1) + { + visibleJoinTables.add(fieldName.split(".")[0]); + } + } + + return (visibleJoinTables); + }; + + /******************************************************************************* + ** + *******************************************************************************/ + const isJoinMany = (tableMetaData: QTableMetaData, visibleJoinTables: Set): boolean => + { + if (tableMetaData?.exposedJoins) + { + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const join = tableMetaData.exposedJoins[i]; + if (visibleJoinTables.has(join.joinTable.name)) + { + if(join.isMany) + { + return (true); + } + } + } + } + return (false); + } + + /******************************************************************************* + ** + *******************************************************************************/ + const getPageHeader = (tableMetaData: QTableMetaData, visibleJoinTables: Set, tableVariant: QTableVariant): string | JSX.Element => + { + let label: string = tableMetaData?.label ?? ""; + + if (visibleJoinTables.size > 0) + { + let joinLabels = []; + if (tableMetaData?.exposedJoins) + { + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const join = tableMetaData.exposedJoins[i]; + if (visibleJoinTables.has(join.joinTable.name)) + { + joinLabels.push(join.label); + } + } + } + + let joinLabelsString = joinLabels.join(", "); + if(joinLabels.length == 2) + { + let lastCommaIndex = joinLabelsString.lastIndexOf(","); + joinLabelsString = joinLabelsString.substring(0, lastCommaIndex) + " and " + joinLabelsString.substring(lastCommaIndex + 1); + } + if(joinLabels.length > 2) + { + let lastCommaIndex = joinLabelsString.lastIndexOf(","); + joinLabelsString = joinLabelsString.substring(0, lastCommaIndex) + ", and " + joinLabelsString.substring(lastCommaIndex + 1); + } + + let tooltipHTML =
    + You are viewing results from the {tableMetaData.label} table joined with {joinLabels.length} other table{joinLabels.length == 1 ? "" : "s"}: +
      + {joinLabels.map((name) =>
    • {name}
    • )} +
    +
    + + return( +
    + {label} + + emergency + + {tableVariant && getTableVariantHeader(tableVariant)} +
    ); + } + else + { + return ( +
    + {label} + {tableVariant && getTableVariantHeader(tableVariant)} +
    ); + } + }; + + /******************************************************************************* + ** + *******************************************************************************/ + const getTableVariantHeader = (tableVariant: QTableVariant) => + { + return ( + + {tableMetaData?.variantTableLabel}: {tableVariant?.name} + + settings + + + ); + } /////////////////////// // Keyboard handling // /////////////////////// useEffect(() => { - if(tableMetaData == null) - { - (async() => - { - const tableMetaData = await qController.loadTableMetaData(tableName); - setTableMetaData(tableMetaData); - })(); - } - const down = (e: KeyboardEvent) => { const type = (e.target as any).type; @@ -321,7 +517,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element else if (! e.metaKey && e.key === "f") { e.preventDefault() - gridApiRef.current.showFilterPanel() + + // @ts-ignore + if(basicAndAdvancedQueryControlsRef?.current?.getCurrentMode() == "advanced") + { + gridApiRef.current.showFilterPanel() + } } } } @@ -368,49 +569,70 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } ///////////////////////////////////////////////////////////////////// - // the path for a savedFilter looks like: .../table/savedFilter/32 // - // so if path has '/savedFilter/' get last parsed string // + // the path for a savedView looks like: .../table/savedView/32 // + // so if path has '/savedView/' get last parsed string // ///////////////////////////////////////////////////////////////////// - let currentSavedFilterId = null as number; - if (location.pathname.indexOf("/savedFilter/") != -1) + let currentSavedViewId = null as number; + if (location.pathname.indexOf("/savedView/") != -1) { const parts = location.pathname.split("/"); - currentSavedFilterId = Number.parseInt(parts[parts.length - 1]); + currentSavedViewId = Number.parseInt(parts[parts.length - 1]); + setFilterIdInLocation(currentSavedViewId); + + ///////////////////////////////////////////////////////////////////////////////////////////// + // in case page-state has already advanced to "ready" (e.g., and we're dealing with a user // + // hitting back & forth between filters), then do a load of the new saved-view right here // + ///////////////////////////////////////////////////////////////////////////////////////////// + if(pageState == "ready") + { + handleSavedViewChange(currentSavedViewId); + } } else if (!searchParams.has("filter")) { - if (localStorage.getItem(currentSavedFilterLocalStorageKey)) + if (localStorage.getItem(currentSavedViewLocalStorageKey)) { - currentSavedFilterId = Number.parseInt(localStorage.getItem(currentSavedFilterLocalStorageKey)); - navigate(`${metaData.getTablePathByName(tableName)}/savedFilter/${currentSavedFilterId}`); + currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey)); + navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); } else { - doSetCurrentSavedFilter(null); + doSetCurrentSavedView(null); } } - if (currentSavedFilterId != null) - { - (async () => - { - const formData = new FormData(); - formData.append("id", currentSavedFilterId); - formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000); - const processResult = await qController.processInit("querySavedFilter", formData, qController.defaultMultipartFormDataHeaders()); - if (processResult instanceof QJobError) - { - const jobError = processResult as QJobError; - console.error("Could not retrieve saved filter: " + jobError.userFacingError); - } - else - { - const result = processResult as QJobComplete; - const qRecord = new QRecord(result.values.savedFilterList[0]); - doSetCurrentSavedFilter(qRecord); - } - })(); - } + //... if (currentSavedViewId != null) + //... { + //... /* hmm... + //... if(currentSavedView && currentSavedView.values.get("id") == currentSavedViewId) + //... { + //... console.log("@dk - mmm, current saved filter is already the one we're trying to go to, so, avoid double-dipping..."); + //... } + //... else + //... */ + //... { + //... console.log("@dk - have saved filter in url, going to query it now."); + //... (async () => + //... { + //... const formData = new FormData(); + //... formData.append("id", currentSavedViewId); + //... formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000); + //... const processResult = await qController.processInit("querySavedView", formData, qController.defaultMultipartFormDataHeaders()); + //... if (processResult instanceof QJobError) + //... { + //... const jobError = processResult as QJobError; + //... console.error("Could not retrieve saved filter: " + jobError.userFacingError); + //... } + //... else + //... { + //... const result = processResult as QJobComplete; + //... const qRecord = new QRecord(result.values.savedViewList[0]); + //... console.log("@dk - got saved filter from backend, going to set it now"); + //... doSetCurrentSavedView(qRecord); + //... } + //... })(); + //... } + //... } } catch (e) { @@ -424,345 +646,126 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }, [location, tableMetaData]); - function promptForTableVariantSelection() + /******************************************************************************* + ** + *******************************************************************************/ + const handleColumnVisibilityChange = (columnVisibilityModel: GridColumnVisibilityModel) => + { + setColumnVisibilityModel(columnVisibilityModel); + queryColumns.updateVisibility(columnVisibilityModel) + + view.queryColumns = queryColumns; + doSetView(view) + + forceUpdate(); + }; + + /******************************************************************************* + ** + *******************************************************************************/ + const setupGridColumnModels = (metaData: QInstance, tableMetaData: QTableMetaData, queryColumns: QQueryColumns) => + { + let linkBase = metaData.getTablePath(tableMetaData); + linkBase += linkBase.endsWith("/") ? "" : "/"; + const columns = DataGridUtils.setupGridColumns(tableMetaData, linkBase, metaData, "alphabetical"); + + /////////////////////////////////////////////// + // sort columns based on queryColumns object // + /////////////////////////////////////////////// + const columnSortValues = queryColumns.getColumnSortValues(); + columns.sort((a: GridColDef, b: GridColDef) => + { + const aIndex = columnSortValues[a.field]; + const bIndex = columnSortValues[b.field]; + return aIndex - bIndex; + }); + + /////////////////////////////////////////////////////////////////////// + // if there are column widths (e.g., from local storage), apply them // + /////////////////////////////////////////////////////////////////////// + const columnWidths = queryColumns.getColumnWidths(); + for (let i = 0; i < columns.length; i++) + { + const width = columnWidths[columns[i].field]; + if (width) + { + columns[i].width = width; + } + } + + setPinnedColumns(queryColumns.toGridPinnedColumns()); + setColumnVisibilityModel(queryColumns.toColumnVisibilityModel()); + setColumnsModel(columns); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + const promptForTableVariantSelection = () => { setTableVariantPromptOpen(true); } - const updateColumnVisibilityModel = () => + /******************************************************************************* + ** + *******************************************************************************/ + const prepQueryFilterForBackend = (sourceFilter: QQueryFilter) => { - if (localStorage.getItem(columnVisibilityLocalStorageKey)) + const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.booleanOperator); + for (let i = 0; i < sourceFilter?.criteria?.length; i++) { - const visibility = JSON.parse(localStorage.getItem(columnVisibilityLocalStorageKey)); - setColumnVisibilityModel(visibility); - didDefaultVisibilityComeFromLocalStorage = true; - } - } - - /////////////////////////////////////////////////////////////////////// - // any time these are out of sync, it means we need to reload things // - /////////////////////////////////////////////////////////////////////// - if (tableMetaData && tableMetaData.name !== tableName) - { - setTableMetaData(null); - setColumnSortModel([]); - updateColumnVisibilityModel(); - setColumnsModel([]); - setFilterModel({items: []}); - setQueryFilter(new QQueryFilter()); - setDefaultFilterLoaded(false); - setRows([]); - } - - ////////////////////////////////////////////////////////////////////////////////////////////////////// - // note - important to take tableMetaData as a param, even though it's a state var, as the // - // first time we call in here, we may not yet have set it in state (but will have fetched it async) // - // so we'll pass in the local version of it! // - ////////////////////////////////////////////////////////////////////////////////////////////////////// - const buildQFilter = (tableMetaData: QTableMetaData, filterModel: GridFilterModel, limit?: number) => - { - let filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, limit); - filter = FilterUtils.convertFilterPossibleValuesToIds(filter); - return (filter); - }; - - const getVisibleJoinTables = (): Set => - { - const visibleJoinTables = new Set(); - columnsModel.forEach((gridColumn) => - { - const fieldName = gridColumn.field; - if (columnVisibilityModel[fieldName] !== false) + const criteria = sourceFilter.criteria[i]; + const {criteriaIsValid} = validateCriteria(criteria, null); + if (criteriaIsValid) { - if (fieldName.indexOf(".") > -1) + if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK) { - visibleJoinTables.add(fieldName.split(".")[0]); + /////////////////////////////////////////////////////////////////////////////////////////// + // do this to avoid submitting an empty-string argument for blank/not-blank operators... // + /////////////////////////////////////////////////////////////////////////////////////////// + filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, [])); } - } - }); - - filterModel.items.forEach((item) => - { - // todo - some test if there is a value? see FilterUtils.buildQFilterFromGridFilter (re-use if needed) - - const fieldName = item.columnField; - if(fieldName.indexOf(".") > -1) - { - visibleJoinTables.add(fieldName.split(".")[0]); - } - }); - - return (visibleJoinTables); - }; - - const isJoinMany = (tableMetaData: QTableMetaData, visibleJoinTables: Set): boolean => - { - if (tableMetaData?.exposedJoins) - { - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) - { - const join = tableMetaData.exposedJoins[i]; - if (visibleJoinTables.has(join.joinTable.name)) + else { - if(join.isMany) - { - return (true); - } + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName) + filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field))); } } } - return (false); - } - - const getPageHeader = (tableMetaData: QTableMetaData, visibleJoinTables: Set, tableVariant: QTableVariant): string | JSX.Element => - { - let label: string = tableMetaData?.label ?? ""; - - if (visibleJoinTables.size > 0) - { - let joinLabels = []; - if (tableMetaData?.exposedJoins) - { - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) - { - const join = tableMetaData.exposedJoins[i]; - if (visibleJoinTables.has(join.joinTable.name)) - { - joinLabels.push(join.label); - } - } - } - - let joinLabelsString = joinLabels.join(", "); - if(joinLabels.length == 2) - { - let lastCommaIndex = joinLabelsString.lastIndexOf(","); - joinLabelsString = joinLabelsString.substring(0, lastCommaIndex) + " and " + joinLabelsString.substring(lastCommaIndex + 1); - } - if(joinLabels.length > 2) - { - let lastCommaIndex = joinLabelsString.lastIndexOf(","); - joinLabelsString = joinLabelsString.substring(0, lastCommaIndex) + ", and " + joinLabelsString.substring(lastCommaIndex + 1); - } - - let tooltipHTML =
    - You are viewing results from the {tableMetaData.label} table joined with {joinLabels.length} other table{joinLabels.length == 1 ? "" : "s"}: -
      - {joinLabels.map((name) =>
    • {name}
    • )} -
    -
    - - return( -
    - {label} - - emergency - - {tableVariant && getTableVariantHeader()} -
    ); - } - else - { - return ( -
    - {label} - {tableVariant && getTableVariantHeader()} -
    ); - } - }; - - const getTableVariantHeader = () => - { - return ( - - {tableMetaData?.variantTableLabel}: {tableVariant?.name} - - settings - - - ); + filterForBackend.skip = pageNumber * rowsPerPage; + filterForBackend.limit = rowsPerPage; + + // FilterUtils.convertFilterPossibleValuesToIds(filterForBackend); + // todo - expressions? + // todo - utc + return filterForBackend; } + /******************************************************************************* + ** This is the method that actually executes a query to update the data in the table. + *******************************************************************************/ const updateTable = (reason?: string) => { - console.log(`In updateTable for ${reason}`); + if(pageState != "ready") + { + return; + } + + console.log(`@dk In updateTable for ${reason} ${JSON.stringify(queryFilter)}`); setLoading(true); setRows([]); (async () => { - const tableMetaData = await qController.loadTableMetaData(tableName); - const visibleJoinTables = getVisibleJoinTables(); - setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, tableVariant)); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - // if there's an exposedJoin that we haven't seen before, we want to make sure that all of its fields // - // don't immediately become visible to the user, so, turn them all off! // - //////////////////////////////////////////////////////////////////////////////////////////////////////// - if (tableMetaData?.exposedJoins) - { - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) - { - const join = tableMetaData.exposedJoins[i]; - const joinTableName = join.joinTable.name; - if(!seenJoinTables[joinTableName] || shouldSetAllNewJoinFieldsToHidden) - { - for (let fieldName of join.joinTable.fields.keys()) - { - columnVisibilityModel[`${join.joinTable.name}.${fieldName}`] = false; - } - } - } - handleColumnVisibilityChange(columnVisibilityModel); - setShouldSetAllNewJoinFieldsToHidden(false); - } - - setColumnVisibilityModel(columnVisibilityModel); - - /////////////////////////////////////////////////////////////////////////////////////////////////// - // store the set of join tables that the user has "seen" (e.g, have been in the table meta data) // - // this is part of the turning-off of new joins seen above // - /////////////////////////////////////////////////////////////////////////////////////////////////// - if(tableMetaData?.exposedJoins) - { - const newSeenJoins: {[tableName: string]: boolean} = {}; - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) - { - const join = tableMetaData.exposedJoins[i]; - newSeenJoins[join.joinTable.name] = true; - } - localStorage.setItem(seenJoinTablesLocalStorageKey, JSON.stringify(newSeenJoins)); - } - - //////////////////////////////////////////////////////////////////////////////////////////////// - // we need the table meta data to look up the default filter (if it comes from query string), // - // 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); - - let models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, null, searchParams, filterLocalStorageKey, sortLocalStorageKey); - setFilterModel(models.filter); - setColumnSortModel(models.sort); - setWarningAlert(models.warning); - - const newQueryFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage); - setQueryFilter(newQueryFilter); - - //////////////////////////////////////////////////////////////////////////////////////// - // this ref may not be defined on the initial render, so, make this call in a timeout // - //////////////////////////////////////////////////////////////////////////////////////// - setTimeout(() => - { - // @ts-ignore - basicAndAdvancedQueryControlsRef?.current?.ensureAllFilterCriteriaAreActiveQuickFilters(newQueryFilter, "defaultFilterLoaded") - }); - - return; - } - - setTableMetaData(tableMetaData); - setTableLabel(tableMetaData.label); - - if (tableMetaData?.usesVariants && !tableVariant) - { - promptForTableVariantSelection(); - return; - } - - if (columnsModel.length == 0) - { - let linkBase = metaData.getTablePath(table); - linkBase += linkBase.endsWith("/") ? "" : "/"; - const columns = DataGridUtils.setupGridColumns(tableMetaData, linkBase, metaData, "alphabetical"); - - /////////////////////////////////////////////////////////////////////// - // if there's a column-ordering (e.g., from local storage), apply it // - /////////////////////////////////////////////////////////////////////// - if(defaultColumnOrdering) - { - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - // note - may need to put this in its own function, e.g., for restoring "Saved Columns" when we add that // - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - columns.sort((a: GridColDef, b: GridColDef) => - { - const aIndex = defaultColumnOrdering.indexOf(a.field); - const bIndex = defaultColumnOrdering.indexOf(b.field); - return aIndex - bIndex; - }); - } - - /////////////////////////////////////////////////////////////////////// - // if there are column widths (e.g., from local storage), apply them // - /////////////////////////////////////////////////////////////////////// - if(defaultColumnWidths) - { - for (let i = 0; i < columns.length; i++) - { - const width = defaultColumnWidths[columns[i].field]; - if(width) - { - columns[i].width = width; - } - } - } - - setColumnsModel(columns); - - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // let the next render (since columnsModel is watched below) build the filter, using the new columnsModel (in case of joins) // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - return; - } - - ////////////////////////////////////////////////////////////////////////////////////////////////// - // make sure that any if any sort columns are from a join table, that the join table is visible // - ////////////////////////////////////////////////////////////////////////////////////////////////// - let resetColumnSortModel = false; - for (let i = 0; i < columnSortModel.length; i++) - { - const gridSortItem = columnSortModel[i]; - if (gridSortItem.field.indexOf(".") > -1) - { - const tableName = gridSortItem.field.split(".")[0]; - if (!visibleJoinTables?.has(tableName)) - { - columnSortModel.splice(i, 1); - setColumnSortModel(columnSortModel); - // todo - need to setQueryFilter? - resetColumnSortModel = true; - i--; - } - } - } - - /////////////////////////////////////////////////////////// - // if there's no column sort, make a default - pkey desc // - /////////////////////////////////////////////////////////// - if (columnSortModel.length === 0) - { - columnSortModel.push({ - field: tableMetaData.primaryKeyField, - sort: "desc", - }); - setColumnSortModel(columnSortModel); - // todo - need to setQueryFilter? - resetColumnSortModel = true; - } - - if (resetColumnSortModel && latestQueryId > 0) - { - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// - // let the next render (since columnSortModel is watched below) build the filter, using the new columnSort // - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// - return; - } - - const qFilter = buildQFilter(tableMetaData, localFilterModel); - qFilter.skip = pageNumber * rowsPerPage; - qFilter.limit = rowsPerPage; + ///////////////////////////////////////////////////////////////////////////////////// + // build filter object to submit to backend count & query endpoints // + // copy the orderBys & operator into it - but we'll build its criteria one-by-one, // + // as clones, as we'll need to tweak them a bit // + ///////////////////////////////////////////////////////////////////////////////////// + const filterForBackend = prepQueryFilterForBackend(queryFilter); ////////////////////////////////////////// // figure out joins to use in the query // @@ -785,7 +788,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (tableMetaData.capabilities.has(Capability.TABLE_COUNT)) { let includeDistinct = isJoinMany(tableMetaData, getVisibleJoinTables()); - qController.count(tableName, qFilter, queryJoins, includeDistinct, tableVariant).then(([count, distinctCount]) => + qController.count(tableName, filterForBackend, queryJoins, includeDistinct, tableVariant).then(([count, distinctCount]) => { console.log(`Received count results for query ${thisQueryId}: ${count} ${distinctCount}`); countResults[thisQueryId] = []; @@ -802,9 +805,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return; } - setLastFetchedQFilterJSON(JSON.stringify(qFilter)); + setLastFetchedQFilterJSON(JSON.stringify(queryFilter)); setLastFetchedVariant(tableVariant); - qController.query(tableName, qFilter, queryJoins, tableVariant).then((results) => + qController.query(tableName, filterForBackend, queryJoins, tableVariant).then((results) => { console.log(`Received results for query ${thisQueryId}`); queryResults[thisQueryId] = results; @@ -839,6 +842,19 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element })(); }; + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if, after a column was turned on or off, the set of visibleJoinTables is changed, then update the table // + // check this on each render - it should only be different if there was a change. note that putting this // + // in handleColumnVisibilityChange "didn't work" - it was always "behind by one" (like, maybe data grid // + // calls that function before it updates the visible model or some-such). // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + const newVisibleJoinTables = getVisibleJoinTables(); + if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()])) + { + updateTable("visible joins change"); + setVisibleJoinTables(newVisibleJoinTables); + } + /////////////////////////// // display count results // /////////////////////////// @@ -928,33 +944,53 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }, [receivedQueryErrorTimestamp]); - const handlePageChange = (page: number) => + /******************************************************************************* + ** Event handler from grid - when page number changes + *******************************************************************************/ + const handlePageNumberChange = (page: number) => { setPageNumber(page); }; + /******************************************************************************* + ** Event handler from grid - when rows per page changes + *******************************************************************************/ const handleRowsPerPageChange = (size: number) => { setRowsPerPage(size); - localStorage.setItem(rowsPerPageLocalStorageKey, JSON.stringify(size)); + + view.rowsPerPage = size; + doSetView(view) }; + /******************************************************************************* + ** event handler from grid - when user changes pins + *******************************************************************************/ const handlePinnedColumnsChange = (pinnedColumns: GridPinnedColumns) => { setPinnedColumns(pinnedColumns); - localStorage.setItem(pinnedColumnsLocalStorageKey, JSON.stringify(pinnedColumns)); + queryColumns.setPinnedLeftColumns(pinnedColumns.left) + queryColumns.setPinnedRightColumns(pinnedColumns.right) + + view.queryColumns = queryColumns; + doSetView(view) }; + /******************************************************************************* + ** event handler from grid - when "state" changes - which we use just for density + *******************************************************************************/ const handleStateChange = (state: GridState, event: MuiEvent, details: GridCallbackDetails) => { if (state && state.density && state.density.value !== density) { setDensity(state.density.value); localStorage.setItem(densityLocalStorageKey, JSON.stringify(state.density.value)); - } }; + /******************************************************************************* + ** event handler from grid - for when user clicks a row. + *******************************************************************************/ const handleRowClick = (params: GridRowParams, event: MuiEvent, details: GridCallbackDetails) => { ///////////////////////////////////////////////////////////////// @@ -963,7 +999,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element console.log(gridPreferencesWindow); if (gridPreferencesWindow !== undefined) { - clearTimeout(instance.current.timer); + clearTimeout(timerInstance.current.timer); return; } @@ -973,11 +1009,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element id = encodeURIComponent(params.row[tableMetaData.primaryKeyField]); } const tablePath = `${metaData.getTablePathByName(table.name)}/${id}`; - DataGridUtils.handleRowClick(tablePath, event, gridMouseDownX, gridMouseDownY, navigate, instance); + DataGridUtils.handleRowClick(tablePath, event, gridMouseDownX, gridMouseDownY, navigate, timerInstance); }; - - const selectionChanged = (selectionModel: GridSelectionModel, details: GridCallbackDetails) => + /******************************************************************************* + ** event handler from grid - for when selection (checked rows) changes. + *******************************************************************************/ + const handleSelectionChanged = (selectionModel: GridSelectionModel, details: GridCallbackDetails) => { //////////////////////////////////////////////////// // since we manage this object, we must re-set it // @@ -1004,122 +1042,217 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; - const handleColumnVisibilityChange = (columnVisibilityModel: GridColumnVisibilityModel) => - { - setColumnVisibilityModel(columnVisibilityModel); - if (columnVisibilityLocalStorageKey) - { - localStorage.setItem(columnVisibilityLocalStorageKey, JSON.stringify(columnVisibilityModel)); - } - }; - - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// - // if, after a column was turned on or off, the set of visibleJoinTables is changed, then update the table // - // check this on each render - it should only be different if there was a change. note that putting this // - // in handleColumnVisibilityChange "didn't work" - it was always "behind by one" (like, maybe data grid // - // calls that function before it updates the visible model or some-such). // - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// - const newVisibleJoinTables = getVisibleJoinTables(); - if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()])) - { - console.log("calling update table for visible join table change"); - updateTable("visible joins change"); - setVisibleJoinTables(newVisibleJoinTables); - } - - /******************************************************************************* - ** Event handler for column ordering change + ** event handler from grid - for when the order of columns changes *******************************************************************************/ const handleColumnOrderChange = (columnOrderChangeParams: GridColumnOrderChangeParams) => { + ///////////////////////////////////////////////////////////////////////////////////// + // get current state from gridApiRef - as the changeParams only have the delta // + // and we don't want to worry about being out of sync - just reset fully each time // + ///////////////////////////////////////////////////////////////////////////////////// const columnOrdering = gridApiRef.current.state.columns.all; - localStorage.setItem(columnOrderingLocalStorageKey, JSON.stringify(columnOrdering)); + queryColumns.updateColumnOrder(columnOrdering); + + view.queryColumns = queryColumns; + doSetView(view) }; /******************************************************************************* - ** Event handler for column resizing + ** event handler from grid - for when user resizes a column *******************************************************************************/ const handleColumnResize = (params: GridColumnResizeParams, event: MuiEvent, details: GridCallbackDetails) => { - defaultColumnWidths[params.colDef.field] = params.width; - localStorage.setItem(columnWidthsLocalStorageKey, JSON.stringify(defaultColumnWidths)); + queryColumns.updateColumnWidth(params.colDef.field, params.width); + + view.queryColumns = queryColumns; + doSetView(view) }; - const handleFilterChange = (filterModel: GridFilterModel, doSetQueryFilter = true, isChangeFromDataGrid = false) => + + /******************************************************************************* + ** event handler from grid - for when the sort-model changes (e.g., user clicks + ** a column header to re-sort table). + *******************************************************************************/ + const handleSortChange = (gridSort: GridSortModel) => { - setFilterModel(filterModel); + /////////////////////////////////////// + // store the sort model for the grid // + /////////////////////////////////////// + setColumnSortModel(gridSort); - if (doSetQueryFilter) + //////////////////////////////////////////////// + // convert the grid's sort to qqq-filter sort // + //////////////////////////////////////////////// + queryFilter.orderBys = []; + for (let i = 0; i < gridSort?.length; i++) { - ////////////////////////////////////////////////////////////////////////////////// - // someone might have already set the query filter, so, only set it if asked to // - ////////////////////////////////////////////////////////////////////////////////// - setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage)); + const fieldName = gridSort[i].field; + const isAscending = gridSort[i].sort == "asc"; + queryFilter.orderBys.push(new QFilterOrderBy(fieldName, isAscending)) } - if (isChangeFromDataGrid) + ////////////////////////////////////////////////////////// + // set a default order-by, if none is otherwise present // + ////////////////////////////////////////////////////////// + if(queryFilter.orderBys.length == 0) { - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // this function is called by our code several times, but also from dataGridPro when its filter model changes. // - // in general, we don't want a "partial" criteria to be part of our query filter object (e.g., w/ no values) // - // BUT - for one use-case, when the user adds a "filter" (criteria) from column-header "..." menu, then dataGridPro // - // puts a partial item in its filter - so - in that case, we do like to get this partial criteria in our QFilter. // - // so far, not seeing any negatives to this being here, and it fixes that user experience, so keep this. // - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage, true)); + queryFilter.orderBys.push(new QFilterOrderBy(tableMetaData.primaryKeyField, false)); } - if (filterLocalStorageKey) - { - localStorage.setItem(filterLocalStorageKey, JSON.stringify(filterModel)); - } + //////////////////////////////// + // store the new query filter // + //////////////////////////////// + doSetQueryFilter(queryFilter); }; - const handleSortChangeForDataGrid = (gridSort: GridSortModel) => + /******************************************************************************* + ** set the current view in state & local-storage - but do NOT update any + ** child-state data. + *******************************************************************************/ + const doSetView = (view: RecordQueryView): void => { - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - // this method just wraps handleSortChange, but w/o the optional 2nd param, so we can use it in data grid // - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - handleSortChange(gridSort); + setView(view); + setViewAsJson(JSON.stringify(view)); + localStorage.setItem(viewLocalStorageKey, JSON.stringify(view)); } - const handleSortChange = (gridSort: GridSortModel, overrideFilterModel?: GridFilterModel) => + + /******************************************************************************* + ** bigger than doSetView - this method does call doSetView, but then also + ** updates all other related state on the screen from the view. + *******************************************************************************/ + const activateView = (view: RecordQueryView): void => { - if (gridSort && gridSort.length > 0) - { - setColumnSortModel(gridSort); - const gridFilterModelToUse = overrideFilterModel ?? filterModel; - setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, gridFilterModelToUse, gridSort, rowsPerPage)); - localStorage.setItem(sortLocalStorageKey, JSON.stringify(gridSort)); - } - }; + ///////////////////////////////////////////////////////////////////////////////////////////// + // pass the 'isFromActivateView' flag into these functions - so that they don't try to set // + // the filter (or columns) back into the old view. // + ///////////////////////////////////////////////////////////////////////////////////////////// + doSetQueryFilter(view.queryFilter, true); + doSetQueryColumns(view.queryColumns, true); - if (tableName !== tableState) - { - (async () => - { - setTableMetaData(null); - setTableState(tableName); - const metaData = await qController.loadMetaData(); - setMetaData(metaData); + setRowsPerPage(view.rowsPerPage ?? defaultRowsPerPage); + setMode(view.mode ?? defaultMode); + setQuickFilterFieldNames(view.quickFilterFieldNames) // todo not i think ?? getDefaultQuickFilterFieldNames(tableMetaData)); - setTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName)); // these are the ones to show in the dropdown - setAllTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName, true)); // these include hidden ones (e.g., to find the bulks) + ////////////////////////////////////////////////////////////////////////////////////////////////// + // do this last - in case anything in the view got modified in any of those other doSet methods // + ////////////////////////////////////////////////////////////////////////////////////////////////// + doSetView(view); - if (launchingProcess) - { - setLaunchingProcess(null); - setActiveModalProcess(launchingProcess); - } - - // reset rows to trigger rerender - setRows([]); - })(); + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // do this in a timeout - so the current view can get set into state properly, before it potentially // + // gets modified inside these calls (e.g., if a new field gets turned on) // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // @ts-ignore + setTimeout(() => basicAndAdvancedQueryControlsRef?.current?.ensureAllFilterCriteriaAreActiveQuickFilters(view.queryFilter, "activatedView")); } - function getNoOfSelectedRecords() + + /******************************************************************************* + ** Wrapper around setQueryFilter that also puts it in the view, and calls doSetView + *******************************************************************************/ + const doSetQueryFilter = (queryFilter: QQueryFilter, isFromActivateView = false): void => + { + console.log(`@dk Setting a new query filter: ${JSON.stringify(queryFilter)}`); + + /////////////////////////////////////////////////// + // in case there's no orderBys, set default here // + /////////////////////////////////////////////////// + if(!queryFilter.orderBys || queryFilter.orderBys.length == 0) + { + queryFilter.orderBys = [new QFilterOrderBy(tableMetaData?.primaryKeyField, false)]; + view.queryFilter = queryFilter; + } + + setQueryFilter(queryFilter); + + /////////////////////////////////////////////////////// + // propagate filter's orderBy into grid's sort model // + /////////////////////////////////////////////////////// + const gridSort = FilterUtils.getGridSortFromQueryFilter(view.queryFilter); + setColumnSortModel(gridSort); + + /////////////////////////////////////////////// + // put this query filter in the current view // + /////////////////////////////////////////////// + if(!isFromActivateView) + { + view.queryFilter = queryFilter; + doSetView(view) + } + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // this force-update causes a re-render that'll see the changed filter hash/json string, and make an updateTable run (if appropriate) // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + forceUpdate(); + } + + /******************************************************************************* + ** Wrapper around setQueryColumns that also sets column models for the grid, puts + ** updated queryColumns in the view, and calls doSetView + *******************************************************************************/ + const doSetQueryColumns = (queryColumns: QQueryColumns, isFromActivateView = false): void => + { + /////////////////////////////////////////////////////////////////////////////////////// + // if we didn't get queryColumns from our view, it should be a PreLoadQueryColumns - // + // so that means we should now replace it with defaults for the table. // + /////////////////////////////////////////////////////////////////////////////////////// + if (queryColumns instanceof PreLoadQueryColumns || queryColumns.columns.length == 0) + { + console.log(`Building new default QQueryColumns for table [${tableMetaData.name}]`); + queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData); + view.queryColumns = queryColumns; + } + + setQueryColumns(queryColumns); + + //////////////////////////////// + // set the DataGridPro models // + //////////////////////////////// + setupGridColumnModels(metaData, tableMetaData, queryColumns); + // const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage); + + /////////////////////////////////////////// + // put these columns in the current view // + /////////////////////////////////////////// + if(!isFromActivateView) + { + view.queryColumns = queryColumns; + doSetView(view) + } + } + + + /******************************************************************************* + ** Event handler from BasicAndAdvancedQueryControls for when quickFilterFields change + *******************************************************************************/ + const doSetQuickFilterFieldNames = (names: string[]) => + { + setQuickFilterFieldNames([...names]); + + view.quickFilterFieldNames = names; + doSetView(view) + }; + + + /******************************************************************************* + ** Wrapper around setMode - places it into the view and state. + *******************************************************************************/ + const doSetMode = (newValue: string) => + { + setMode(newValue); + + view.mode = newValue; + doSetView(view); + } + + + /******************************************************************************* + ** Helper function for launching processes - counts selected records. + *******************************************************************************/ + const getNoOfSelectedRecords = () => { if (selectFullFilterState === "filter") { @@ -1133,16 +1266,21 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return (selectedIds.length); } - function getRecordsQueryString() + + /******************************************************************************* + ** get a query-string to put on the url to indicate what records are going into + ** a process. + *******************************************************************************/ + const getRecordsQueryString = () => { if (selectFullFilterState === "filter") { - return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel)))}`; + return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(queryFilter))}`; } if (selectFullFilterState === "filterSubset") { - return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel, selectionSubsetSize)))}`; + return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(queryFilter))}`; } if (selectedIds.length > 0) @@ -1153,15 +1291,20 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return ""; } + + /******************************************************************************* + ** launch/open a modal process. Ends up navigating to the process's path w/ + ** records selected via query string. + *******************************************************************************/ const openModalProcess = (process: QProcessMetaData = null) => { if (selectFullFilterState === "filter") { - setRecordIdsForProcess(buildQFilter(tableMetaData, filterModel)); + setRecordIdsForProcess(queryFilter); } else if (selectFullFilterState === "filterSubset") { - setRecordIdsForProcess(buildQFilter(tableMetaData, filterModel, selectionSubsetSize)); + setRecordIdsForProcess(new QQueryFilter(queryFilter.criteria, queryFilter.orderBys, queryFilter.booleanOperator, 0, selectionSubsetSize)); } else if (selectedIds.length > 0) { @@ -1173,21 +1316,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } navigate(`${metaData?.getTablePathByName(tableName)}/${process.name}${getRecordsQueryString()}`); - closeActionsMenu(); }; - const closeColumnStats = (event: object, reason: string) => - { - if (reason === "backdropClick" || reason === "escapeKeyDown") - { - return; - } - - setColumnStatsFieldName(null); - setColumnStatsFieldTableName(null); - setColumnStatsField(null); - }; + /******************************************************************************* + ** close callback for modal processes + *******************************************************************************/ const closeModalProcess = (event: object, reason: string) => { if (reason === "backdropClick" || reason === "escapeKeyDown") @@ -1205,6 +1339,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element updateTable("close modal process"); }; + + /******************************************************************************* + ** function to open one of the bulk (insert/edit/delete) processes. + *******************************************************************************/ const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete", processLabelPart: "Load" | "Edit" | "Delete") => { const processList = allTableProcesses.filter(p => p.name.endsWith(`.bulk${processNamePart}`)); @@ -1218,15 +1356,20 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; + /******************************************************************************* + ** Event handler for the bulk-load process being selected + *******************************************************************************/ const bulkLoadClicked = () => { - closeActionsMenu(); openBulkProcess("Insert", "Load"); }; + + /******************************************************************************* + ** Event handler for the bulk-edit process being selected + *******************************************************************************/ const bulkEditClicked = () => { - closeActionsMenu(); if (getNoOfSelectedRecords() === 0) { setAlertContent("No records were selected to Bulk Edit."); @@ -1235,9 +1378,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element openBulkProcess("Edit", "Edit"); }; + + /******************************************************************************* + ** Event handler for the bulk-delete process being selected + *******************************************************************************/ const bulkDeleteClicked = () => { - closeActionsMenu(); if (getNoOfSelectedRecords() === 0) { setAlertContent("No records were selected to Bulk Delete."); @@ -1246,6 +1392,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element openBulkProcess("Delete", "Delete"); }; + + /******************************************************************************* + ** Event handler for selecting a process from the menu + *******************************************************************************/ const processClicked = (process: QProcessMetaData) => { // todo - let the process specify that it needs initial rows - err if none selected. @@ -1253,138 +1403,121 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element openModalProcess(process); }; - // @ts-ignore - const defaultLabelDisplayedRows = ({from, to, count}) => - { - const tooltipHTML = <> - The number of rows shown on this screen may be greater than the number of {tableMetaData?.label} records - that match your query, because you have included fields from other tables which may have - more than one record associated with each {tableMetaData?.label}. - - let distinctPart = isJoinMany(tableMetaData, getVisibleJoinTables()) ? ( -  ({ValueUtils.safeToLocaleString(distinctRecords)} distinct - info_outlined - - ) - ) : <>; - - if (tableMetaData && !tableMetaData.capabilities.has(Capability.TABLE_COUNT)) - { - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // to avoid a non-countable table showing (this is what data-grid did) 91-100 even if there were only 95 records, // - // we'll do this... not quite good enough, but better than the original // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if (rows.length > 0 && rows.length < to - from) - { - to = from + rows.length; - } - return (`Showing ${from.toLocaleString()} to ${to.toLocaleString()}`); - } - - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // treat -1 as the sentinel that it's set as below -- remember, we did that so that 'to' would have a value in here when there's no count. // - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if (count !== null && count !== undefined && count !== -1) - { - if (count === 0) - { - return (loading ? "Counting..." : "No rows"); - } - - return - Showing {from.toLocaleString()} to {to.toLocaleString()} of - { - count == -1 ? - <>more than {to.toLocaleString()} - : <> {count.toLocaleString()}{distinctPart} - } - ; - } - else - { - return ("Counting..."); - } - }; + ////////////////////////////////////////////// + // custom pagination component for DataGrid // + ////////////////////////////////////////////// function CustomPagination() { - return ( - handlePageChange(value)} - onRowsPerPageChange={(event) => handleRowsPerPageChange(Number(event.target.value))} - labelDisplayedRows={defaultLabelDisplayedRows} - /> - ); + return (); } - function Loading() + ///////////////////////////////////////// + // custom loading overlay for DataGrid // + ///////////////////////////////////////// + function CustomLoadingOverlay() { return ( ); } - function doSetCurrentSavedFilter(savedFilter: QRecord) + /******************************************************************************* + ** wrapper around setting current saved view (as a QRecord) - which also activates + ** that view. + *******************************************************************************/ + const doSetCurrentSavedView = (savedView: QRecord) => { - setCurrentSavedFilter(savedFilter); + setCurrentSavedView(savedView); - if(savedFilter) + if(savedView) { (async () => { - let localTableMetaData = tableMetaData; - if(!localTableMetaData) - { - localTableMetaData = await qController.loadTableMetaData(tableName); - } + const viewJson = savedView.values.get("viewJson") + const newView = RecordQueryView.buildFromJSON(viewJson); + newView.viewIdentity = "savedView:" + savedView.values.get("id"); + activateView(newView); - const models = await FilterUtils.determineFilterAndSortModels(qController, localTableMetaData, savedFilter.values.get("filterJson"), null, null, null); - const newQueryFilter = FilterUtils.buildQFilterFromGridFilter(localTableMetaData, models.filter, models.sort, rowsPerPage); - // todo?? ensureAllFilterCriteriaAreActiveQuickFilters(localTableMetaData, newQueryFilter, "savedFilterSelected") + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // todo - we used to be able to set "warnings" here (i think, like, for if a field got deleted from a table... // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // setWarningAlert(models.warning); - const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(localTableMetaData, newQueryFilter); - handleFilterChange(gridFilterModel, true); + //////////////////////////////////////////////////////////////// + // todo can/should/does this move into the view's "identity"? // + //////////////////////////////////////////////////////////////// + localStorage.setItem(currentSavedViewLocalStorageKey, `${savedView.values.get("id")}`); })() } - } - - async function handleSavedFilterChange(selectedSavedFilterId: number) - { - if (selectedSavedFilterId != null) - { - const qRecord = await fetchSavedFilter(selectedSavedFilterId); - doSetCurrentSavedFilter(qRecord); // this fixed initial load not showing filter name - - const models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, qRecord.values.get("filterJson"), null, null, null); - handleFilterChange(models.filter); - handleSortChange(models.sort, models.filter); - setWarningAlert(models.warning); - - localStorage.setItem(currentSavedFilterLocalStorageKey, selectedSavedFilterId.toString()); - } else { - handleFilterChange({items: []} as GridFilterModel); - handleSortChange([{field: tableMetaData.primaryKeyField, sort: "desc"}], {items: []} as GridFilterModel); - localStorage.removeItem(currentSavedFilterLocalStorageKey); + localStorage.removeItem(currentSavedViewLocalStorageKey); } } - async function fetchSavedFilter(filterId: number): Promise + /******************************************************************************* + ** event handler for SavedViews component, to handle user selecting a view + ** (or clearing / selecting new) + *******************************************************************************/ + const handleSavedViewChange = async (selectedSavedViewId: number) => + { + if (selectedSavedViewId != null) + { + ////////////////////////////////////////////// + // fetch, then activate the selected filter // + ////////////////////////////////////////////// + setLoading(true); + setLoadingSavedView(true); + const qRecord = await fetchSavedView(selectedSavedViewId); + setLoading(false); + setLoadingSavedView(false); + doSetCurrentSavedView(qRecord); + } + else + { + ///////////////////////////////// + // this is 'new view' - right? // + ///////////////////////////////// + + ////////////////////////////// + // wipe away the saved view // + ////////////////////////////// + setCurrentSavedView(null); + localStorage.removeItem(currentSavedViewLocalStorageKey); + + ///////////////////////////////////////////////////// + // go back to a default query filter for the table // + ///////////////////////////////////////////////////// + doSetQueryFilter(new QQueryFilter()); + // todo not i think doSetQuickFilterFieldNames(getDefaultQuickFilterFieldNames(tableMetaData)); + + const queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData); + doSetQueryColumns(queryColumns) + } + } + + /******************************************************************************* + ** utility function to fetch a saved view from the backend. + *******************************************************************************/ + const fetchSavedView = async (filterId: number): Promise => { let qRecord = null; const formData = new FormData(); formData.append("id", filterId); formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000); - const processResult = await qController.processInit("querySavedFilter", formData, qController.defaultMultipartFormDataHeaders()); + const processResult = await qController.processInit("querySavedView", formData, qController.defaultMultipartFormDataHeaders()); if (processResult instanceof QJobError) { const jobError = processResult as QJobError; @@ -1393,12 +1526,16 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element else { const result = processResult as QJobComplete; - qRecord = new QRecord(result.values.savedFilterList[0]); + qRecord = new QRecord(result.values.savedViewList[0]); } return (qRecord); } + + /******************************************************************************* + ** event handler from columns menu - that copies values from that column + *******************************************************************************/ const copyColumnValues = async (column: GridColDef) => { let data = ""; @@ -1430,9 +1567,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; + + /******************************************************************************* + ** event handler from columns menu - to open the column statistics modal + *******************************************************************************/ const openColumnStatistics = async (column: GridColDef) => { - setFilterForColumnStats(buildQFilter(tableMetaData, filterModel)); + setFilterForColumnStats(queryFilter); setColumnStatsFieldName(column.field); const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, column.field); @@ -1440,12 +1581,33 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setColumnStatsFieldTableName(fieldTable.name); }; + + /******************************************************************************* + ** close handler for column stats modal + *******************************************************************************/ + const closeColumnStats = (event: object, reason: string) => + { + if (reason === "backdropClick" || reason === "escapeKeyDown") + { + return; + } + + setColumnStatsFieldName(null); + setColumnStatsFieldTableName(null); + setColumnStatsField(null); + }; + + + ///////////////////////////////////////////////// + // custom component for the grid's column-menu // + // todo - break out into own component/file?? // + ///////////////////////////////////////////////// const CustomColumnMenu = forwardRef( function GridColumnMenu(props: GridColumnMenuProps, ref) { const {hideMenu, currentColumn} = props; - /* + /* see below where this could be used for future additional copy functions const [copyMoreMenu, setCopyMoreMenu] = useState(null) const openCopyMoreMenu = (event: any) => { @@ -1473,10 +1635,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element mode == "basic" && { hideMenu(e); - // @ts-ignore !? + // @ts-ignore basicAndAdvancedQueryControlsRef.current.addField(currentColumn.field); }}> - Filter (BASIC) TODO edit text + Filter } @@ -1494,7 +1656,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }}> Copy values - {/* + {/* idea here was, more options, like what format, or copy all, not just current page... Oh @@ -1515,6 +1677,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); }); + ///////////////////////////////////////////////////////////// + // custom component for the column header cells // + // where we need custom event handlers for the filter icon // + // todo - break out into own component/file?? // + ///////////////////////////////////////////////////////////// const CustomColumnHeaderFilterIconButton = forwardRef( function ColumnHeaderFilterIconButton(props: ColumnHeaderFilterIconButtonProps, ref) { @@ -1546,8 +1713,16 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return (<>); }); + //////////////////////////////////////////////// + // custom component for the grid toolbar // + // todo - break out into own component/file?? // + //////////////////////////////////////////////// function CustomToolbar() { + + /******************************************************************************* + ** event handler for mouse-down event - helps w/ avoiding accidental clicks into rows + *******************************************************************************/ const handleMouseDown: GridEventListener<"cellMouseDown"> = ( params, // GridRowParams event, // MuiEvent> @@ -1556,15 +1731,17 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { setGridMouseDownX(event.clientX); setGridMouseDownY(event.clientY); - clearTimeout(instance.current.timer); + clearTimeout(timerInstance.current.timer); }; + /******************************************************************************* + ** event handler for double-click event - helps w/ avoiding accidental clicks into rows + *******************************************************************************/ const handleDoubleClick: GridEventListener<"rowDoubleClick"> = (event: any) => { - clearTimeout(instance.current.timer); + clearTimeout(timerInstance.current.timer); }; - const apiRef = useGridApiContext(); useGridApiEventHandler(apiRef, "cellMouseDown", handleMouseDown); useGridApiEventHandler(apiRef, "rowDoubleClick", handleDoubleClick); @@ -1586,6 +1763,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element selectionMenuOptions.push(`Subset of the query result ${selectionSubsetSize ? `(${ValueUtils.safeToLocaleString(selectionSubsetSize)} ${joinIsMany ? "distinct " : ""}record${selectionSubsetSize == 1 ? "" : "s"})` : "..."}`); selectionMenuOptions.push("Clear selection"); + + /******************************************************************************* + ** util function to check boxes for some or all rows in the grid, in response to + ** selection menu actions + *******************************************************************************/ function programmaticallySelectSomeOrAllRows(max?: number) { /////////////////////////////////////////////////////////////////////////////////////////// @@ -1619,6 +1801,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setSelectedIds([...selectedPrimaryKeys.values()]); } + + /******************************************************************************* + ** event handler (callback) for optiosn in the selection menu + *******************************************************************************/ const selectionMenuCallback = (selectedIndex: number) => { if(selectedIndex == 0) @@ -1643,6 +1829,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; + ////////////////////////////////////////////////////////////////// + // props that get passed into all of the ExportMenuItem's below // + ////////////////////////////////////////////////////////////////// const exportMenuItemRestProps = { tableMetaData: tableMetaData, @@ -1743,72 +1932,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } - const pushDividerIfNeeded = (menuItems: JSX.Element[]) => - { - if (menuItems.length > 0) - { - menuItems.push(); - } - }; - - const menuItems: JSX.Element[] = []; - if (table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) - { - menuItems.push(library_addBulk Load); - } - if (table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission) - { - menuItems.push(editBulk Edit); - } - if (table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission) - { - menuItems.push(deleteBulk Delete); - } - - const runRecordScriptProcess = metaData?.processes.get("runRecordScript"); - if (runRecordScriptProcess) - { - const process = runRecordScriptProcess; - menuItems.push( processClicked(process)}>{process.iconName ?? "arrow_forward"}{process.label}); - } - - menuItems.push( navigate(`${metaData.getTablePathByName(tableName)}/dev`)}>codeDeveloper Mode); - - if (tableProcesses && tableProcesses.length) - { - pushDividerIfNeeded(menuItems); - } - - tableProcesses.sort((a, b) => a.label.localeCompare(b.label)); - tableProcesses.map((process) => - { - menuItems.push( processClicked(process)}>{process.iconName ?? "arrow_forward"}{process.label}); - }); - - if (menuItems.length === 0) - { - menuItems.push(blockNo actions available); - } - - const renderActionsMenu = ( - - {menuItems} - - ); - /////////////////////////////////////////////////////////////////////////////////////////// // for changes in table controls that don't change the count, call to update the table - // // but without clearing out totalRecords (so pagination doesn't flash) // @@ -1821,41 +1944,22 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // to avoid both this useEffect and the one below from both doing an "initial query", // // only run this one if at least 1 query has already been ran // //////////////////////////////////////////////////////////////////////////////////////// - updateTable("useEffect(pageNumber,rowsPerPage,columnSortModel,currentSavedFilter)"); + updateTable("useEffect(pageNumber,rowsPerPage)"); } - }, [pageNumber, rowsPerPage, columnSortModel, currentSavedFilter]); - - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // for state changes that DO change the filter, call to update the table - and DO clear out the totalRecords // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - useEffect(() => - { - const currentQFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage); - currentQFilter.skip = pageNumber * rowsPerPage; - const currentQFilterJSON = JSON.stringify(currentQFilter); - const currentVariantJSON = JSON.stringify(tableVariant); - - if(currentQFilterJSON !== lastFetchedQFilterJSON || currentVariantJSON !== lastFetchedVariant) - { - setTotalRecords(null); - setDistinctRecords(null); - updateTable("useEffect(filterModel)"); - } - }, [filterModel, columnsModel, tableState, tableVariant]); + }, [pageNumber, rowsPerPage]); + //////////////////////////////////////////////////////////// + // scroll to the origin when pageNo or rowsPerPage change // + //////////////////////////////////////////////////////////// useEffect(() => { document.documentElement.scrollTop = 0; document.scrollingElement.scrollTop = 0; }, [pageNumber, rowsPerPage]); - const updateFilterFromFilterPanel = (newFilter: QQueryFilter): void => - { - setQueryFilter(newFilter); - const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); - handleFilterChange(gridFilterModel, false); - }; - + //////////////////////////////////////////////////////////////////// + // if user doesn't have read permission, just show an error alert // + //////////////////////////////////////////////////////////////////// if (tableMetaData && !tableMetaData.readPermission) { return ( @@ -1867,6 +1971,255 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } + /******************************************************************************* + ** maybe something to do with how page header is in a context, but, it didn't + ** work to check pageLoadingState.isLoadingSlow inside an element that we put + ** in the page header, so, this works instead. + *******************************************************************************/ + const setPageHeaderToLoadingSlow = (): void => + { + setPageHeader("Loading...") + } + + ///////////////////////////////////////////////////////////////////////////////// + // use this to make changes to the queryFilter more likely to re-run the query // + ///////////////////////////////////////////////////////////////////////////////// + const [filterHash, setFilterHash] = useState(""); + + if(pageState == "ready") + { + const newFilterHash = JSON.stringify(prepQueryFilterForBackend(queryFilter)); + if (filterHash != newFilterHash) + { + setFilterHash(newFilterHash); + updateTable("hash change"); + } + } + + //////////////////////////////////////////////////////////// + // handle the initial page state -- by fetching meta-data // + //////////////////////////////////////////////////////////// + if (pageState == "initial") + { + console.log("@dk - page state is initial - going to loadingMetaData..."); + setPageState("loadingMetaData"); + pageLoadingState.setLoading(); + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // reset the page header to blank, and tell the pageLoadingState object that if it becomes slow, to show 'Loading' // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + setPageHeader(""); + pageLoadingState.setUponSlowCallback(setPageHeaderToLoadingSlow); + + (async () => + { + const metaData = await qController.loadMetaData(); + setMetaData(metaData); + + const tableMetaData = await qController.loadTableMetaData(tableName); + setTableMetaData(tableMetaData); + setTableLabel(tableMetaData.label); + + setTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName)); // these are the ones to show in the dropdown + setAllTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName, true)); // these include hidden ones (e.g., to find the bulks) + + setPageState("loadedMetaData"); + })(); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // handle the secondary page state - after meta-data is in state - by figuring out the current view // + ////////////////////////////////////////////////////////////////////////////////////////////////////// + if (pageState == "loadedMetaData") + { + console.log("@dk - page state is loadedMetaData - going to loadingView..."); + setPageState("loadingView"); + + (async () => + { + if (searchParams && searchParams.has("filter")) + { + ////////////////////////////////////////////////////////////////////////////////////// + // if there's a filter in the URL - then set that as the filter in the current view // + ////////////////////////////////////////////////////////////////////////////////////// + try + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // todo - some version of "you've browsed back here, so if active view (local-storage) is the same as this, then keep old... // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + console.log(`history state: ${JSON.stringify(window.history.state)}`); + + /////////////////////////////////////////////////////////////////////////////////////////////////// + // parse the filter json into a filer object - then clean up values in it (e.g., translate PV's) // + /////////////////////////////////////////////////////////////////////////////////////////////////// + const filterJSON = JSON.parse(searchParams.get("filter")); + const queryFilter = filterJSON as QQueryFilter; + + await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, queryFilter); + + /////////////////////////////////////////////////////////////////////////////////////////// + // set this new query filter in the view, and activate the full view // + // stuff other than the query filter should "stick" from what user had active previously // + /////////////////////////////////////////////////////////////////////////////////////////// + view.queryFilter = queryFilter; + activateView(view); + + ///////////////////////////////////////////////////////////////////////////////////////////// + // make sure that we clear out any currently saved view - we're no longer in such a state. // + ///////////////////////////////////////////////////////////////////////////////////////////// + doSetCurrentSavedView(null); + } + catch(e) + { + setAlertContent("Error parsing filter from URL"); + } + } + else if (filterIdInLocation) + { + if(view.viewIdentity == `savedView:${filterIdInLocation}`) + { + ///////////////////////////////////////////////////////////////////////////////////////////////// + // if the view id in the location is the same as the view that was most-recently active here, // + // then we want to act like that old view is active - but - in case the user changed anything, // + // we want to keep their current settings as the active view - thus - use the current 'view' // + // state variable (e.g., from local storage) as the view to be activated. // + ///////////////////////////////////////////////////////////////////////////////////////////////// + console.log(`Initializing view to a (potentially dirty) saved view (id=${filterIdInLocation})`); + activateView(view); + + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // now fetch that savedView, and set it in state, but don't activate it - because that would overwrite // + // anything the user may have changed (e.g., anything in the local-storage/state view). // + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + const savedViewRecord = await fetchSavedView(filterIdInLocation); + setCurrentSavedView(savedViewRecord); + } + else + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if there's a filterId in the location, but it isn't the last one the user had active, then set that as our active view // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + console.log(`Initializing view to a clean saved view (id=${filterIdInLocation})`); + await handleSavedViewChange(filterIdInLocation); + } + } + else + { + ////////////////////////////////////////////////////////////////// + // view is ad-hoc - just activate the view that was last active // + ////////////////////////////////////////////////////////////////// + activateView(view); + } + + setPageState("loadedView"); + })(); + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + // handle the 3rd page state - after we have the view loaded - prepare the grid for display // + ////////////////////////////////////////////////////////////////////////////////////////////// + if (pageState == "loadedView") + { + console.log("@dk - page state is loadedView - going to preparingGrid..."); + setPageState("preparingGrid"); + + (async () => + { + const visibleJoinTables = getVisibleJoinTables(); + setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, tableVariant)); + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // todo - we used to be able to set "warnings" here (i think, like, for if a field got deleted from a table... // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // setWarningAlert(models.warning); + + //////////////////////////////////////////////////////////////////////////////////////// + // this ref may not be defined on the initial render, so, make this call in a timeout // + //////////////////////////////////////////////////////////////////////////////////////// + setTimeout(() => + { + // @ts-ignore + basicAndAdvancedQueryControlsRef?.current?.ensureAllFilterCriteriaAreActiveQuickFilters(view.queryFilter, "defaultFilterLoaded") + }); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // make sure that any if any sort columns are from a join table, that the join table is visible // + // todo - figure out what this is, see if still needed, etc... + ////////////////////////////////////////////////////////////////////////////////////////////////// + /* + let resetColumnSortModel = false; + for (let i = 0; i < columnSortModel.length; i++) + { + const gridSortItem = columnSortModel[i]; + if (gridSortItem.field.indexOf(".") > -1) + { + const tableName = gridSortItem.field.split(".")[0]; + if (!visibleJoinTables?.has(tableName)) + { + columnSortModel.splice(i, 1); + setColumnSortModel(columnSortModel); + // todo - need to setQueryFilter? + resetColumnSortModel = true; + i--; + } + } + } + + if (resetColumnSortModel && latestQueryId > 0) + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // let the next render (since columnSortModel is watched below) build the filter, using the new columnSort // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + return; + } + */ + + console.log("@dk - finished preparing grid, going to page state ready"); + setPageState("ready"); + + // todo - is this sufficient? + if (tableMetaData?.usesVariants && !tableVariant) + { + promptForTableVariantSelection(); + return; + } + })(); + return (getLoadingScreen()); + } + + //////////////////////////////////////////////////////////////////////// + // trigger initial update-table call after page-state goes into ready // + //////////////////////////////////////////////////////////////////////// + useEffect(() => + { + if(pageState == "ready") + { + pageLoadingState.setNotLoading() + + if(!tableVariantPromptOpen) + { + updateTable("pageState is now ready") + } + } + }, [pageState, tableVariantPromptOpen]); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // any time these are out of sync, it means we've navigated to a different table, so we need to reload :allthethings: // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (tableMetaData && tableMetaData.name !== tableName) + { + console.log(`Found mis-match between tableMetaData.name and tableName [${tableMetaData.name}]!=[${tableName}] - reload everything.`); + setPageState("initial"); + setTableMetaData(null); + setColumnSortModel([]); + setColumnsModel([]); + setQueryFilter(new QQueryFilter()); + setQueryColumns(new PreLoadQueryColumns()); + setRows([]); + + return (getLoadingScreen()); + } + ///////////////////////////////////////////////////////////////////////////////////////////// // if the table doesn't allow QUERY, but does allow GET, don't render a data grid - // // instead, try to just render a Goto Record button, in auto-open, and may-not-close modes // @@ -1892,7 +2245,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element let gotoVariantSubHeader = <>; if(tableMetaData?.usesVariants) { - gotoVariantSubHeader = {getTableVariantHeader()} + gotoVariantSubHeader = {getTableVariantHeader(tableVariant)} } return ( @@ -1902,10 +2255,22 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } - const doSetMode = (newValue: string) => + /////////////////////////////////////////////////////////// + // render a loading screen if the page state isn't ready // + /////////////////////////////////////////////////////////// + if(pageState != "ready") { - setMode(newValue); - localStorage.setItem(modeLocalStorageKey, newValue); + console.log(`@dk - page state is ${pageState}... no-op while those complete async's run...`); + return (getLoadingScreen()); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // if the table isn't loaded yet, display loading screen. // + // this shouldn't be possible, to be out-of-sync with pageState, but just as a fail-safe // + /////////////////////////////////////////////////////////////////////////////////////////// + if(!tableMetaData) + { + return (getLoadingScreen()); } /////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -1917,6 +2282,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element restOfDataGridProCustomComponents.ColumnHeaderFilterIconButton = CustomColumnHeaderFilterIconButton; } + //////////////////////// + // main screen render // + //////////////////////// return (
    @@ -1966,15 +2334,25 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { - metaData && metaData.processes.has("querySavedFilter") && - + metaData && metaData.processes.has("querySavedView") && + } - - {renderActionsMenu} + { + tableMetaData && + + } { table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && @@ -1989,10 +2367,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element metaData={metaData} tableMetaData={tableMetaData} queryFilter={queryFilter} - gridApiRef={gridApiRef} - setQueryFilter={setQueryFilter} - handleFilterChange={handleFilterChange} queryFilterJSON={JSON.stringify(queryFilter)} + setQueryFilter={doSetQueryFilter} + quickFilterFieldNames={quickFilterFieldNames} + setQuickFilterFieldNames={doSetQuickFilterFieldNames} + gridApiRef={gridApiRef} mode={mode} setMode={doSetMode} /> @@ -2005,7 +2384,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element components={{ Toolbar: CustomToolbar, Pagination: CustomPagination, - LoadingOverlay: Loading, + LoadingOverlay: CustomLoadingOverlay, ColumnMenu: CustomColumnMenu, ColumnsPanel: CustomColumnsPanel, FilterPanel: CustomFilterPanel, @@ -2026,7 +2405,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element tableMetaData: tableMetaData, metaData: metaData, queryFilter: queryFilter, - updateFilter: updateFilterFromFilterPanel, + updateFilter: doSetQueryFilter, } }} localeText={{ @@ -2056,14 +2435,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element onStateChange={handleStateChange} density={density} loading={loading} - filterModel={filterModel} - onFilterModelChange={(model) => handleFilterChange(model, true, true)} columnVisibilityModel={columnVisibilityModel} onColumnVisibilityModelChange={handleColumnVisibilityChange} onColumnOrderChange={handleColumnOrderChange} onColumnResize={handleColumnResize} - onSelectionModelChange={selectionChanged} - onSortModelChange={handleSortChangeForDataGrid} + onSelectionModelChange={handleSelectionChanged} + onSortModelChange={handleSortChange} sortingOrder={["asc", "desc"]} sortModel={columnSortModel} getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")} @@ -2091,6 +2468,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { setTableVariantPromptOpen(false); setTableVariant(value); + setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, value)); }} /> } From 6c75ce281ee9c6d3f660817aba579ddc43f334c2 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 30 Jan 2024 09:56:31 -0600 Subject: [PATCH 23/40] CE-793 - pre-code-review cleanups --- .../query/BasicAndAdvancedQueryControls.tsx | 2 +- src/qqq/pages/records/query/RecordQuery.tsx | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx index beef1dc..b43dc6f 100644 --- a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx +++ b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx @@ -550,7 +550,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo handleFieldChange={(e, newValue, reason) => addQuickFilterField(newValue, reason)} autoFocus={true} forceOpen={Boolean(addQuickFilterMenu)} - hiddenFieldNames={[...defaultQuickFilterFieldNames, ...quickFilterFieldNames]} + hiddenFieldNames={[...(defaultQuickFilterFieldNames??[]), ...(quickFilterFieldNames??[])]} />
    diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 005f114..0a66e1d 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -1134,7 +1134,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setRowsPerPage(view.rowsPerPage ?? defaultRowsPerPage); setMode(view.mode ?? defaultMode); - setQuickFilterFieldNames(view.quickFilterFieldNames) // todo not i think ?? getDefaultQuickFilterFieldNames(tableMetaData)); + setQuickFilterFieldNames(view.quickFilterFieldNames ?? []) // todo not i think ?? getDefaultQuickFilterFieldNames(tableMetaData)); ////////////////////////////////////////////////////////////////////////////////////////////////// // do this last - in case anything in the view got modified in any of those other doSet methods // @@ -1227,10 +1227,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element /******************************************************************************* ** Event handler from BasicAndAdvancedQueryControls for when quickFilterFields change + ** or other times we need to change them (e.g., activating a view) *******************************************************************************/ - const doSetQuickFilterFieldNames = (names: string[]) => + const doSetQuickFilterFieldNames = (names: string[]): void => { - setQuickFilterFieldNames([...names]); + setQuickFilterFieldNames([...(names ?? [])]); view.quickFilterFieldNames = names; doSetView(view) @@ -1501,10 +1502,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // go back to a default query filter for the table // ///////////////////////////////////////////////////// doSetQueryFilter(new QQueryFilter()); - // todo not i think doSetQuickFilterFieldNames(getDefaultQuickFilterFieldNames(tableMetaData)); const queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData); doSetQueryColumns(queryColumns) + + ///////////////////////////////////////////////////// + // also reset the (user-added) quick-filter fields // + ///////////////////////////////////////////////////// + doSetQuickFilterFieldNames([]); } } From dc7aeef6bfe3c1741ca18b2e51b8b938687c426f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 30 Jan 2024 09:57:09 -0600 Subject: [PATCH 24/40] CE-793 - cleanup pre-code-review; fix alerts; add disabled-state upon-button, etc. --- src/qqq/components/misc/SavedViews.tsx | 63 +++++++++++++++++--------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/src/qqq/components/misc/SavedViews.tsx b/src/qqq/components/misc/SavedViews.tsx index 89c58cb..6ce7e8a 100644 --- a/src/qqq/components/misc/SavedViews.tsx +++ b/src/qqq/components/misc/SavedViews.tsx @@ -72,7 +72,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie const [savedViews, setSavedViews] = useState([] as QRecord[]); const [savedViewsMenu, setSavedViewsMenu] = useState(null); const [savedViewsHaveLoaded, setSavedViewsHaveLoaded] = useState(false); - // const [viewIsModified, setViewIsModified] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false); const [isSaveFilterAs, setIsSaveFilterAs] = useState(false); @@ -104,14 +104,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie .then(() => { setSavedViewsHaveLoaded(true); - /* - if (currentSavedView != null) - { - const isModified = JSON.stringify(view) !== currentSavedView.values.get("viewJson"); - console.log(`Is view modified? ${isModified}\n${JSON.stringify(view)}\n${currentSavedView.values.get("viewJson")}`); - setViewIsModified(isModified); - } - */ }); }, [location, tableMetaData, currentSavedView, view]) // todo#elimGrid does this monitoring work?? @@ -396,8 +388,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie diffVisibilityFunction(activeView.queryColumns, savedView.queryColumns, "Turned off visibility for "); diffPinsFunction(savedView.queryColumns, activeView.queryColumns, "Changed pinned state for "); - // console.log(`Saved: ${savedView.queryColumns.columns.map(c => c.name).join(",")}`); - // console.log(`Active: ${activeView.queryColumns.columns.map(c => c.name).join(",")}`); if(savedView.queryColumns.columns.map(c => c.name).join(",") != activeView.queryColumns.columns.map(c => c.name).join(",")) { viewDiffs.push("Changed the order of 1 or more columns."); @@ -515,7 +505,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie const handleDropdownOptionClick = (optionName: string) => { setSaveOptionsOpen(false); - setPopupAlertContent(null); + setPopupAlertContent(""); closeSavedViewsMenu(); setSaveFilterPopupOpen(true); setIsSaveFilterAs(false); @@ -556,11 +546,18 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie { try { + setPopupAlertContent(""); + setIsSubmitting(true); + const formData = new FormData(); if (isDeleteFilter) { formData.append("id", currentSavedView.values.get("id")); await makeSavedViewRequest("deleteSavedView", formData); + + setSaveFilterPopupOpen(false); + setSaveOptionsOpen(false); + await(async() => { handleDropdownOptionClick(CLEAR_OPTION); @@ -603,10 +600,28 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie } })(); } + + setSaveFilterPopupOpen(false); + setSaveOptionsOpen(false); } catch (e: any) { - setPopupAlertContent(JSON.stringify(e.message)); + let message = JSON.stringify(e); + if(typeof e == "string") + { + message = e; + } + else if(typeof e == "object" && e.message) + { + message = e.message; + } + + setPopupAlertContent(message); + console.log(`Setting error: ${message}`); + } + finally + { + setIsSubmitting(false); } } @@ -764,7 +779,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
    } { - + handleDropdownOptionClick(CLEAR_OPTION)}> monitor New View @@ -834,7 +849,11 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie aria-describedby="alert-dialog-description" onKeyPress={(e) => { - if (e.key == "Enter") + //////////////////////////////////////////////////// + // make user actually hit delete button // + // but for other modes, let Enter submit the form // + //////////////////////////////////////////////////// + if (e.key == "Enter" && !isDeleteFilter) { handleFilterDialogButtonOnClick(); } @@ -860,6 +879,11 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie ) } + {popupAlertContent ? ( + + setPopupAlertContent("")}>{popupAlertContent} + + ) : ("")} { (! currentSavedView || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? ( @@ -893,19 +917,14 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie ) ) } - {popupAlertContent ? ( - - {popupAlertContent} - - ) : ("")} { isDeleteFilter ? - + : - + } From d4d13d06fe9e033e92b2330b9b10ca775cf32446 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 30 Jan 2024 09:57:53 -0600 Subject: [PATCH 25/40] CE-793 - Add disabled prop to delete button --- src/qqq/components/buttons/DefaultButtons.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/qqq/components/buttons/DefaultButtons.tsx b/src/qqq/components/buttons/DefaultButtons.tsx index 982ab82..4af1237 100644 --- a/src/qqq/components/buttons/DefaultButtons.tsx +++ b/src/qqq/components/buttons/DefaultButtons.tsx @@ -73,13 +73,17 @@ export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveBu interface QDeleteButtonProps { onClickHandler: any + disabled?: boolean } -export function QDeleteButton({onClickHandler}: QDeleteButtonProps): JSX.Element +QDeleteButton.defaultProps = { + disabled: false +}; +export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): JSX.Element { return ( - delete}> + delete} disabled={disabled}> Delete From 550006586a70ab7fdd48eeded66e72fb1adefc46 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 30 Jan 2024 12:03:58 -0600 Subject: [PATCH 26/40] CE-793 - Fixes for failed selnium tests --- src/qqq/components/query/QuickFilter.tsx | 3 ++ src/qqq/pages/records/query/RecordQuery.tsx | 18 ++++++++-- src/qqq/utils/qqq/FilterUtils.tsx | 13 +++----- .../selenium/lib/QueryScreenLib.java | 5 ++- ...ueryScreenFilterInUrlAdvancedModeTest.java | 33 ++++++++++++------- .../QueryScreenFilterInUrlBasicModeTest.java | 14 ++++---- .../person/possibleValues/homeCityId=1.json | 8 +++++ .../resources/fixtures/metaData/index.json | 18 +++++----- 8 files changed, 69 insertions(+), 43 deletions(-) create mode 100644 src/test/resources/fixtures/data/person/possibleValues/homeCityId=1.json diff --git a/src/qqq/components/query/QuickFilter.tsx b/src/qqq/components/query/QuickFilter.tsx index 3807bf3..adb3141 100644 --- a/src/qqq/components/query/QuickFilter.tsx +++ b/src/qqq/components/query/QuickFilter.tsx @@ -375,11 +375,13 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData let buttonAdditionalStyles: any = {}; let buttonContent = {tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label} + let buttonClassName = "filterNotActive"; if (criteriaIsValid) { buttonAdditionalStyles.backgroundColor = accentColor + " !important"; buttonAdditionalStyles.borderColor = accentColor + " !important"; buttonAdditionalStyles.color = "white !important"; + buttonClassName = "filterActive"; buttonContent = ( @@ -390,6 +392,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData let button = fieldMetaData &&
    ); + let buttonText = "Views"; + let buttonBackground = "none"; + let buttonBorder = colors.grayLines.main; + let buttonColor = colors.gray.main; + + if(loadingSavedView) + { + buttonText = "Loading..."; + } + else if(currentSavedView) + { + buttonText = currentSavedView.values.get("label") + } + + if(currentSavedView) + { + if (viewIsModified) + { + buttonBackground = accentColorLight; + buttonBorder = buttonBackground; + buttonColor = accentColor; + } + else + { + buttonBackground = accentColor; + buttonBorder = buttonBackground; + buttonColor = "#FFFFFF"; + } + } + + const buttonStyles = { + border: `1px solid ${buttonBorder}`, + backgroundColor: buttonBackground, + color: buttonColor, + "&:focus:not(:hover)": { + color: buttonColor, + backgroundColor: buttonBackground, + }, + "&:hover": { + color: buttonColor, + backgroundColor: buttonBackground, + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + function isSaveButtonDisabled(): boolean + { + if(isSubmitting) + { + return (true); + } + + const haveInputText = (savedViewNameInputValue != null && savedViewNameInputValue.trim() != "") + + if(isSaveFilterAs || isRenameFilter || currentSavedView == null) + { + if(!haveInputText) + { + return (true); + } + } + + return (false); + } + + const linkButtonStyle = { + minWidth: "unset", + textTransform: "none", + fontSize: "0.875rem", + fontWeight: "500", + padding: "0.5rem" + }; + return ( hasQueryPermission && tableMetaData ? ( - - - {renderSavedViewsMenu} - + <> + + + {renderSavedViewsMenu} + + { - savedViewsHaveLoaded && currentSavedView && ( - Current View:  - + !currentSavedView && viewIsModified && <> + + Unsaved Changes +
      { - loadingSavedView - ? "..." - : - <> - {currentSavedView.values.get("label")} - { - viewIsModified && ( - The current view has been modified: -
        - { - viewDiffs.map((s: string, i: number) =>
      • {s}
      • ) - } -
      Click "Save..." to save the changes.}> - -
      - ) - } - + viewDiffs.map((s: string, i: number) =>
    • {s}
    • ) } - - - ) +
    + }> + +
    + + {/* vertical rule */} + + + + + } + { + currentSavedView && viewIsModified && <> + + Unsaved Changes +
      + { + viewDiffs.map((s: string, i: number) =>
    • {s}
    • ) + } +
    }> + {viewDiffs.length} Unsaved Change{viewDiffs.length == 1 ? "" : "s"} +
    + + + + {/* vertical rule */} + + + + }
    @@ -917,7 +653,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie autoFocus name="custom-delimiter-value" placeholder="View Name" - label="View Name" inputProps={{width: "100%", maxLength: 100}} value={savedViewNameInputValue} sx={{width: "100%"}} @@ -943,12 +678,12 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie isDeleteFilter ? : - + } } -
    + ) : null ); } diff --git a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx index 18617dd..2a6e563 100644 --- a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx +++ b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx @@ -27,8 +27,9 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection"; import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; -import {Badge, ToggleButton, ToggleButtonGroup, Typography} from "@mui/material"; +import {Badge, ToggleButton, ToggleButtonGroup} from "@mui/material"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Dialog from "@mui/material/Dialog"; @@ -37,15 +38,17 @@ import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; import Icon from "@mui/material/Icon"; -import Menu from "@mui/material/Menu"; import Tooltip from "@mui/material/Tooltip"; import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro"; -import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react"; +import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react"; +import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; -import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; +import FieldListMenu from "qqq/components/query/FieldListMenu"; import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter"; +import XIcon from "qqq/components/query/XIcon"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; @@ -54,6 +57,9 @@ interface BasicAndAdvancedQueryControlsProps metaData: QInstance; tableMetaData: QTableMetaData; + savedViewsComponent: JSX.Element; + columnMenuComponent: JSX.Element; + quickFilterFieldNames: string[]; setQuickFilterFieldNames: (names: string[]) => void; @@ -83,7 +89,7 @@ let debounceTimeout: string | number | NodeJS.Timeout; *******************************************************************************/ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) => { - const {metaData, tableMetaData, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props + const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props ///////////////////// // state variables // @@ -95,6 +101,8 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false); const [, forceUpdate] = useReducer((x) => x + 1, 0); + const {accentColor} = useContext(QContext); + ////////////////////////////////////////////////////////////////////////////////// // make some functions available to our parent - so it can tell us to do things // ////////////////////////////////////////////////////////////////////////////////// @@ -279,6 +287,11 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo const fieldName = newValue ? newValue.fieldName : null; if (fieldName) { + if(defaultQuickFilterFieldNameMap[fieldName]) + { + return; + } + if (quickFilterFieldNames.indexOf(fieldName) == -1) { ///////////////////////////////// @@ -309,7 +322,22 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo /******************************************************************************* - ** event handler for the Filter Buidler button - e.g., opens the parent's grid's + ** + *******************************************************************************/ + const handleFieldListMenuSelection = (field: QFieldMetaData, table: QTableMetaData): void => + { + let fullFieldName = field.name; + if(table && table.name != tableMetaData.name) + { + fullFieldName = `${table.name}.${field.name}`; + } + + addQuickFilterField({fieldName: fullFieldName}, "selectedFromAddFilterMenu"); + } + + + /******************************************************************************* + ** event handler for the Filter Builder button - e.g., opens the parent's grid's ** filter panel *******************************************************************************/ const openFilterBuilder = (e: React.MouseEvent | React.MouseEvent) => @@ -326,11 +354,21 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo if (isYesButton || event.key == "Enter") { setShowClearFiltersWarning(false); - setQueryFilter(new QQueryFilter()); + setQueryFilter(new QQueryFilter([], queryFilter.orderBys)); } }; + /******************************************************************************* + ** + *******************************************************************************/ + const removeCriteriaByIndex = (index: number) => + { + queryFilter.criteria.splice(index, 1); + setQueryFilter(queryFilter); + } + + /******************************************************************************* ** format the current query as a string for showing on-screen as a preview. *******************************************************************************/ @@ -344,20 +382,20 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo let counter = 0; return ( - + {queryFilter.criteria.map((criteria, i) => { const {criteriaIsValid} = validateCriteria(criteria, null); if(criteriaIsValid) { - const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName); counter++; return ( - - {counter > 1 ? {queryFilter.booleanOperator}  : } + + {counter > 1 ? {queryFilter.booleanOperator}  : } {FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)} - + removeCriteriaByIndex(i)} /> + ); } else @@ -365,7 +403,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo return (); } })} - + ); }; @@ -427,12 +465,15 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo return; } - for (let i = 0; i < queryFilter?.criteria?.length; i++) + if(mode == "basic") { - const criteria = queryFilter.criteria[i]; - if (criteria && criteria.fieldName) + for (let i = 0; i < queryFilter?.criteria?.length; i++) { - addQuickFilterField(criteria, reason); + const criteria = queryFilter.criteria[i]; + if (criteria && criteria.fieldName) + { + addQuickFilterField(criteria, reason); + } } } } @@ -455,6 +496,70 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo return count; } + + /******************************************************************************* + ** Event handler for setting the sort from that menu + *******************************************************************************/ + const handleSetSort = (field: QFieldMetaData, table: QTableMetaData, isAscending: boolean = true): void => + { + const fullFieldName = table && table.name != tableMetaData.name ? `${table.name}.${field.name}` : field.name; + queryFilter.orderBys = [new QFilterOrderBy(fullFieldName, isAscending)] + + setQueryFilter(queryFilter); + forceUpdate(); + } + + + /******************************************************************************* + ** event handler for a click on a field's up or down arrow in the sort menu + *******************************************************************************/ + const handleSetSortArrowClick = (field: QFieldMetaData, table: QTableMetaData, event: any): void => + { + event.stopPropagation(); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // make sure this is an event handler for one of our icons (not something else in the dom here in our end-adornments) // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const isAscending = event.target.innerHTML == "arrow_upward"; + const isDescending = event.target.innerHTML == "arrow_downward"; + if(isAscending || isDescending) + { + handleSetSort(field, table, isAscending); + } + } + + + /******************************************************************************* + ** event handler for clicking the current sort up/down arrow, to toggle direction. + *******************************************************************************/ + function toggleSortDirection(event: React.MouseEvent): void + { + event.stopPropagation(); + try + { + queryFilter.orderBys[0].isAscending = !queryFilter.orderBys[0].isAscending; + setQueryFilter(queryFilter); + forceUpdate(); + } + catch(e) + { + console.log(`Error toggling sort: ${e}`) + } + } + + ///////////////////////////////// + // set up the sort menu button // + ///////////////////////////////// + let sortButtonContents = <>Sort... + if(queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0) + { + const orderBy = queryFilter.orderBys[0]; + const orderByFieldName = orderBy.fieldName; + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName); + const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`; + sortButtonContents = <>Sort: {fieldLabel} {orderBy.isAscending ? "arrow_upward" : "arrow_downward"} + } + //////////////////////////////////////////////////////////////////////////////////////////////////////////// // this is being used as a version of like forcing that we get re-rendered if the query filter changes... // //////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -481,140 +586,172 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo } + const borderGray = colors.grayLines.main; + + const sortMenuComponent = ( + arrow_upwardarrow_downward
    } + handleAdornmentClick={handleSetSortArrowClick} + />); + return ( - - + + + {/* First row: Saved Views button (with Columns button in the middle of it), then space-between, then basic|advanced toggle */} + + + {savedViewsComponent} + {columnMenuComponent} + + + + modeToggleClicked(newValue)} + size="small" + sx={{pl: 0.5, width: "10rem"}} + > + Basic + Advanced + + + + + + {/* Second row: Basic or advanced mode - with sort-by control on the right (of each) */} + { + /////////////////////////////////////////////////////////////////////////////////// + // basic mode - wrapping-list of fields & add-field button, then sort-by control // + /////////////////////////////////////////////////////////////////////////////////// mode == "basic" && - - <> - { - tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) => + + + <> { - const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); - let defaultOperator = getDefaultOperatorForField(field); + tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) => + { + const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = getDefaultOperatorForField(field); - return (); - }) - } - - { - tableMetaData && quickFilterFieldNames?.map((fieldName) => + return (); + }) + } + {/* vertical rule */} + { - const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); - let defaultOperator = getDefaultOperatorForField(field); + tableMetaData && quickFilterFieldNames?.map((fieldName) => + { + const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = getDefaultOperatorForField(field); - return (defaultQuickFilterFieldNameMap[fieldName] ? null : ); + }) + } + { + tableMetaData && ); - }) - } - { - tableMetaData && - <> - - - - - - addQuickFilterField(newValue, reason)} - autoFocus={true} - forceOpen={Boolean(addQuickFilterMenu)} - hiddenFieldNames={[...(defaultQuickFilterFieldNames??[]), ...(quickFilterFieldNames??[])]} - /> - - - - } - + fieldNamesToHide={[...(defaultQuickFilterFieldNames ?? []), ...(quickFilterFieldNames ?? [])]} + placeholder="Search Fields" + buttonProps={{sx: quickFilterButtonStyles, startIcon: (add)}} + buttonChildren={"Add Filter"} + isModeSelectOne={true} + handleSelectedField={handleFieldListMenuSelection} + /> + } + + + + {sortMenuComponent} + } { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // advanced mode - 2 rows - one for Filter Builder button & sort control, 2nd row for the filter-detail box // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// metaData && tableMetaData && mode == "advanced" && - <> - - - -
    - { - hasValidFilters && ( + + + + <> - - setShowClearFiltersWarning(true)}>clear - - setShowClearFiltersWarning(true)} onKeyPress={(e) => handleClearFiltersAction(e)}> - Confirm - - Are you sure you want to remove all conditions from the current filter? - - - setShowClearFiltersWarning(true)} /> - handleClearFiltersAction(null, true)} /> - - + + { + hasValidFilters && setShowClearFiltersWarning(true)} /> + } - ) - } -
    - - Current Filter: +
    + setShowClearFiltersWarning(false)} onKeyPress={(e) => handleClearFiltersAction(e)}> + Confirm + + Are you sure you want to remove all conditions from the current filter? + + + setShowClearFiltersWarning(false)} /> + handleClearFiltersAction(null, true)} /> + + +
    + + {sortMenuComponent} + +
    + { - + {queryToAdvancedString()} } - - } - - - { - metaData && tableMetaData && - - - modeToggleClicked(newValue)} - size="small" - sx={{pl: 0.5, width: "10rem"}} - > - Basic - Advanced - - } @@ -668,4 +805,4 @@ export function getDefaultQuickFilterFieldNames(table: QTableMetaData): string[] return (defaultQuickFilterFieldNames); } -export default BasicAndAdvancedQueryControls; \ No newline at end of file +export default BasicAndAdvancedQueryControls; diff --git a/src/qqq/components/query/FieldListMenu.tsx b/src/qqq/components/query/FieldListMenu.tsx new file mode 100644 index 0000000..b277aa2 --- /dev/null +++ b/src/qqq/components/query/FieldListMenu.tsx @@ -0,0 +1,726 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; +import List from "@mui/material/List/List"; +import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem"; +import Menu from "@mui/material/Menu"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import React, {useState} from "react"; + +interface FieldListMenuProps +{ + idPrefix: string; + heading?: string; + placeholder?: string; + tableMetaData: QTableMetaData; + showTableHeaderEvenIfNoExposedJoins: boolean; + fieldNamesToHide?: string[]; + buttonProps: any; + buttonChildren: JSX.Element | string; + + isModeSelectOne?: boolean; + handleSelectedField?: (field: QFieldMetaData, table: QTableMetaData) => void; + + isModeToggle?: boolean; + toggleStates?: {[fieldName: string]: boolean}; + handleToggleField?: (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) => void; + + fieldEndAdornment?: JSX.Element + handleAdornmentClick?: (field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent) => void; +} + +FieldListMenu.defaultProps = { + showTableHeaderEvenIfNoExposedJoins: false, + isModeSelectOne: false, + isModeToggle: false, +}; + +interface TableWithFields +{ + table?: QTableMetaData; + fields: QFieldMetaData[]; +} + +/******************************************************************************* + ** Component to render a list of fields from a table (and its join tables) + ** which can be interacted with... + *******************************************************************************/ +export default function FieldListMenu({idPrefix, heading, placeholder, tableMetaData, showTableHeaderEvenIfNoExposedJoins, buttonProps, buttonChildren, isModeSelectOne, fieldNamesToHide, handleSelectedField, isModeToggle, toggleStates, handleToggleField, fieldEndAdornment, handleAdornmentClick}: FieldListMenuProps): JSX.Element +{ + const [menuAnchorElement, setMenuAnchorElement] = useState(null); + const [searchText, setSearchText] = useState(""); + const [focusedIndex, setFocusedIndex] = useState(null as number); + + const [fieldsByTable, setFieldsByTable] = useState([] as TableWithFields[]); + const [collapsedTables, setCollapsedTables] = useState({} as {[tableName: string]: boolean}); + + const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0}); + const [timeOfLastArrow, setTimeOfLastArrow] = useState(0) + + ////////////////// + // check usages // + ////////////////// + if(isModeSelectOne) + { + if(!handleSelectedField) + { + throw("In FieldListMenu, if isModeSelectOne=true, then a callback for handleSelectedField must be provided."); + } + } + + if(isModeToggle) + { + if(!toggleStates) + { + throw("In FieldListMenu, if isModeToggle=true, then a model for toggleStates must be provided."); + } + if(!handleToggleField) + { + throw("In FieldListMenu, if isModeToggle=true, then a callback for handleToggleField must be provided."); + } + } + + ///////////////////// + // init some stuff // + ///////////////////// + if (fieldsByTable.length == 0) + { + collapsedTables[tableMetaData.name] = false; + + if (tableMetaData.exposedJoins?.length > 0) + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + fieldsByTable.push({table: tableMetaData, fields: getTableFieldsAsAlphabeticalArray(tableMetaData)}); + + for (let i = 0; i < tableMetaData.exposedJoins?.length; i++) + { + const joinTable = tableMetaData.exposedJoins[i].joinTable; + fieldsByTable.push({table: joinTable, fields: getTableFieldsAsAlphabeticalArray(joinTable)}); + + collapsedTables[joinTable.name] = false; + } + } + else + { + /////////////////////////////////////////////////////////// + // no exposed joins - just the table (w/o its meta-data) // + /////////////////////////////////////////////////////////// + fieldsByTable.push({fields: getTableFieldsAsAlphabeticalArray(tableMetaData)}); + } + + setFieldsByTable(fieldsByTable); + setCollapsedTables(collapsedTables); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function getTableFieldsAsAlphabeticalArray(table: QTableMetaData): QFieldMetaData[] + { + const fields: QFieldMetaData[] = []; + table.fields.forEach(field => + { + let fullFieldName = field.name; + if(table.name != tableMetaData.name) + { + fullFieldName = `${table.name}.${field.name}`; + } + + if(fieldNamesToHide && fieldNamesToHide.indexOf(fullFieldName) > -1) + { + return; + } + fields.push(field) + }); + fields.sort((a, b) => a.label.localeCompare(b.label)); + return (fields); + } + + const fieldsByTableToShow: TableWithFields[] = []; + let maxFieldIndex = 0; + fieldsByTable.forEach((tableWithFields) => + { + let fieldsToShowForThisTable = tableWithFields.fields.filter(doesFieldMatchSearchText); + if (fieldsToShowForThisTable.length > 0) + { + fieldsByTableToShow.push({table: tableWithFields.table, fields: fieldsToShowForThisTable}); + maxFieldIndex += fieldsToShowForThisTable.length; + } + }); + + + /******************************************************************************* + ** + *******************************************************************************/ + function getShownFieldAndTableByIndex(targetIndex: number): {field: QFieldMetaData, table: QTableMetaData} + { + let index = -1; + for (let i = 0; i < fieldsByTableToShow.length; i++) + { + const tableWithField = fieldsByTableToShow[i]; + for (let j = 0; j < tableWithField.fields.length; j++) + { + index++; + + if(index == targetIndex) + { + return {field: tableWithField.fields[j], table: tableWithField.table} + } + } + } + + return (null); + } + + + /******************************************************************************* + ** event handler for keys presses + *******************************************************************************/ + function keyDown(event: any) + { + // console.log(`Event key: ${event.key}`); + setTimeout(() => document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus()); + + if(isModeSelectOne && event.key == "Enter" && focusedIndex != null) + { + setTimeout(() => + { + event.stopPropagation(); + closeMenu(); + + const {field, table} = getShownFieldAndTableByIndex(focusedIndex); + if (field) + { + handleSelectedField(field, table ?? tableMetaData); + } + }); + return; + } + + const keyOffsetMap: { [key: string]: number } = { + "End": 10000, + "Home": -10000, + "ArrowDown": 1, + "ArrowUp": -1, + "PageDown": 5, + "PageUp": -5, + }; + + const offset = keyOffsetMap[event.key]; + if (offset) + { + event.stopPropagation(); + setTimeOfLastArrow(new Date().getTime()); + + if (isModeSelectOne) + { + let startIndex = focusedIndex; + if (offset > 0) + { + ///////////////// + // a down move // + ///////////////// + if(startIndex == null) + { + startIndex = -1; + } + + let goalIndex = startIndex + offset; + if(goalIndex > maxFieldIndex - 1) + { + goalIndex = maxFieldIndex - 1; + } + + doSetFocusedIndex(goalIndex, true); + } + else + { + //////////////// + // an up move // + //////////////// + let goalIndex = startIndex + offset; + if(goalIndex < 0) + { + goalIndex = 0; + } + + doSetFocusedIndex(goalIndex, true); + } + } + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function doSetFocusedIndex(i: number, tryToScrollIntoView: boolean): void + { + if (isModeSelectOne) + { + setFocusedIndex(i); + console.log(`Setting index to ${i}`); + + if (tryToScrollIntoView) + { + const element = document.getElementById(`field-list-dropdown-${idPrefix}-${i}`); + element?.scrollIntoView({block: "center"}); + } + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function setFocusedField(field: QFieldMetaData, table: QTableMetaData, tryToScrollIntoView: boolean) + { + let index = -1; + for (let i = 0; i < fieldsByTableToShow.length; i++) + { + const tableWithField = fieldsByTableToShow[i]; + for (let j = 0; j < tableWithField.fields.length; j++) + { + const loopField = tableWithField.fields[j]; + index++; + + const tableMatches = (table == null || table.name == tableWithField.table.name); + if (tableMatches && field.name == loopField.name) + { + doSetFocusedIndex(index, tryToScrollIntoView); + return; + } + } + } + } + + + /******************************************************************************* + ** event handler for mouse-over the menu + *******************************************************************************/ + function handleMouseOver(event: React.MouseEvent | React.MouseEvent | React.MouseEvent, field: QFieldMetaData, table: QTableMetaData) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // so we're trying to fix the case where, if you put your mouse over an element, but then press up/down arrows, // + // where the mouse will become over a different element after the scroll, and the focus will follow the mouse instead of keyboard. // + // the last x/y isn't really useful, because the mouse generally isn't left exactly where it was when the mouse-over happened (edge of the element) // + // but the keyboard last-arrow time that we capture, that's what's actually being useful in here // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(event.clientX == lastMouseOverXY.x && event.clientY == lastMouseOverXY.y) + { + // console.log("mouse didn't move, so, doesn't count"); + return; + } + + const now = new Date().getTime(); + // console.log(`Compare now [${now}] to last arrow [${timeOfLastArrow}] (diff: [${now - timeOfLastArrow}])`); + if(now < timeOfLastArrow + 300) + { + // console.log("An arrow event happened less than 300 mills ago, so doesn't count."); + return; + } + + // console.log("yay, mouse over..."); + setFocusedField(field, table, false); + setLastMouseOverXY({x: event.clientX, y: event.clientY}); + } + + + /******************************************************************************* + ** event handler for text input changes + *******************************************************************************/ + function updateSearch(event: React.ChangeEvent) + { + setSearchText(event?.target?.value ?? ""); + doSetFocusedIndex(0, true); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function doesFieldMatchSearchText(field: QFieldMetaData): boolean + { + if (searchText == "") + { + return (true); + } + + const columnLabelMinusTable = field.label.replace(/.*: /, ""); + if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase())) + { + return (true); + } + + try + { + //////////////////////////////////////////////////////////// + // try to match word-boundary followed by the filter text // + // e.g., "name" would match "First Name" or "Last Name" // + //////////////////////////////////////////////////////////// + const re = new RegExp("\\b" + searchText.toLowerCase()); + if (columnLabelMinusTable.toLowerCase().match(re)) + { + return (true); + } + } + catch (e) + { + ////////////////////////////////////////////////////////////////////////////////// + // in case text is an invalid regex... well, at least do a starts-with match... // + ////////////////////////////////////////////////////////////////////////////////// + if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase())) + { + return (true); + } + } + + const tableLabel = field.label.replace(/:.*/, ""); + if (tableLabel) + { + try + { + //////////////////////////////////////////////////////////// + // try to match word-boundary followed by the filter text // + // e.g., "name" would match "First Name" or "Last Name" // + //////////////////////////////////////////////////////////// + const re = new RegExp("\\b" + searchText.toLowerCase()); + if (tableLabel.toLowerCase().match(re)) + { + return (true); + } + } + catch (e) + { + ////////////////////////////////////////////////////////////////////////////////// + // in case text is an invalid regex... well, at least do a starts-with match... // + ////////////////////////////////////////////////////////////////////////////////// + if (tableLabel.toLowerCase().startsWith(searchText.toLowerCase())) + { + return (true); + } + } + } + + return (false); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function openMenu(event: any) + { + setFocusedIndex(null); + setMenuAnchorElement(event.currentTarget); + setTimeout(() => + { + document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus(); + doSetFocusedIndex(0, true); + }); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function closeMenu() + { + setMenuAnchorElement(null); + } + + + /******************************************************************************* + ** Event handler for toggling a field in toggle mode + *******************************************************************************/ + function handleFieldToggle(event: React.ChangeEvent, field: QFieldMetaData, table: QTableMetaData) + { + event.stopPropagation(); + handleToggleField(field, table, event.target.checked); + } + + + /******************************************************************************* + ** Event handler for toggling a table in toggle mode + *******************************************************************************/ + function handleTableToggle(event: React.ChangeEvent, table: QTableMetaData) + { + event.stopPropagation(); + + const fieldsList = [...table.fields.values()]; + for (let i = 0; i < fieldsList.length; i++) + { + const field = fieldsList[i]; + if(doesFieldMatchSearchText(field)) + { + handleToggleField(field, table, event.target.checked); + } + } + } + + + ///////////////////////////////////////////////////////// + // compute the table-level toggle state & count values // + ///////////////////////////////////////////////////////// + const tableToggleStates: {[tableName: string]: boolean} = {}; + const tableToggleCounts: {[tableName: string]: number} = {}; + + if(isModeToggle) + { + const {allOn, count} = getTableToggleState(tableMetaData, true); + tableToggleStates[tableMetaData.name] = allOn; + tableToggleCounts[tableMetaData.name] = count; + + for (let i = 0; i < tableMetaData.exposedJoins?.length; i++) + { + const join = tableMetaData.exposedJoins[i]; + const {allOn, count} = getTableToggleState(join.joinTable, false); + tableToggleStates[join.joinTable.name] = allOn; + tableToggleCounts[join.joinTable.name] = count; + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function getTableToggleState(table: QTableMetaData, isMainTable: boolean): {allOn: boolean, count: number} + { + const fieldsList = [...table.fields.values()]; + let allOn = true; + let count = 0; + for (let i = 0; i < fieldsList.length; i++) + { + const field = fieldsList[i]; + const name = isMainTable ? field.name : `${table.name}.${field.name}`; + if(!toggleStates[name]) + { + allOn = false; + } + else + { + count++; + } + } + + return ({allOn: allOn, count: count}); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function toggleCollapsedTable(tableName: string) + { + collapsedTables[tableName] = !collapsedTables[tableName] + setCollapsedTables(Object.assign({}, collapsedTables)); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function doHandleAdornmentClick(field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent) + { + console.log("In doHandleAdornmentClick"); + closeMenu(); + handleAdornmentClick(field, table, event); + } + + + let index = -1; + const textFieldId = `field-list-dropdown-${idPrefix}-textField`; + let listItemPadding = isModeToggle ? "0.125rem": "0.5rem"; + + return ( + <> + + + + { + heading && + + {heading} + + } + + + { + searchText != "" && + { + updateSearch(null); + document.getElementById(textFieldId).focus(); + }}>close + } + + + + { + fieldsByTableToShow.map((tableWithFields) => + { + let headerContents = null; + const headerTable = tableWithFields.table || tableMetaData; + if(tableWithFields.table || showTableHeaderEvenIfNoExposedJoins) + { + headerContents = ({headerTable.label} Fields); + } + + if(isModeToggle) + { + headerContents = ( handleTableToggle(event, headerTable)} + />} + label={{headerTable.label} Fields ({tableToggleCounts[headerTable.name]})} />) + } + + if(isModeToggle) + { + headerContents = ( + <> + toggleCollapsedTable(headerTable.name)} + sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}} + disableRipple={true} + > + {collapsedTables[headerTable.name] ? "expand_less" : "expand_more"} + + {headerContents} + + ) + } + + let marginLeft = "unset"; + if(isModeToggle) + { + marginLeft = "-1rem"; + } + + return ( + + <> + {headerContents && {headerContents}} + { + tableWithFields.fields.map((field) => + { + index++; + const key = `${tableWithFields.table?.name}-${field.name}` + + if(collapsedTables[headerTable.name]) + { + return (); + } + + let style = {}; + if (index == focusedIndex) + { + style = {backgroundColor: "#EFEFEF"}; + } + + const onClick: ListItemProps = {}; + if (isModeSelectOne) + { + onClick.onClick = () => + { + closeMenu(); + handleSelectedField(field, tableWithFields.table ?? tableMetaData); + } + } + + let label: JSX.Element | string = field.label; + const fullFieldName = tableWithFields.table && tableWithFields.table.name != tableMetaData.name ? `${tableWithFields.table.name}.${field.name}` : field.name; + + if(fieldEndAdornment) + { + label = + {label} + doHandleAdornmentClick(field, tableWithFields.table, event)}> + {fieldEndAdornment} + + ; + } + + let contents = <>{label}; + let paddingLeft = "0.5rem"; + + if (isModeToggle) + { + contents = ( handleFieldToggle(event, field, tableWithFields.table)} + />} + label={label} />); + paddingLeft = "2.5rem"; + } + + return handleMouseOver(event, field, tableWithFields.table)} + {...onClick} + >{contents}; + }) + } + + + ); + }) + } + { + index == -1 && No fields found. + } + + + + + + ); +} diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index efd41bc..2e88ea2 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -203,7 +203,7 @@ FilterCriteriaRow.defaultProps = { }; -export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string} +export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue?: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string} { let criteriaIsValid = true; let criteriaStatusTooltip = "This condition is fully defined and is part of your filter."; diff --git a/src/qqq/components/query/QuickFilter.tsx b/src/qqq/components/query/QuickFilter.tsx index 7cb5b96..ae88728 100644 --- a/src/qqq/components/query/QuickFilter.tsx +++ b/src/qqq/components/query/QuickFilter.tsx @@ -37,6 +37,7 @@ import QContext from "QContext"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; import {getDefaultCriteriaValue, getOperatorOptions, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; +import XIcon from "qqq/components/query/XIcon"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; @@ -62,8 +63,17 @@ QuickFilter.defaultProps = let seedId = new Date().getTime() % 173237; export const quickFilterButtonStyles = { - fontSize: "0.75rem", color: "#757575", textTransform: "none", borderRadius: "2rem", border: "1px solid #757575", - minWidth: "3.5rem", minHeight: "auto", padding: "0.375rem 0.625rem", whiteSpace: "nowrap" + fontSize: "0.75rem", + fontWeight: 600, + color: "#757575", + textTransform: "none", + borderRadius: "2rem", + border: "1px solid #757575", + minWidth: "3.5rem", + minHeight: "auto", + padding: "0.375rem 0.625rem", + whiteSpace: "nowrap", + marginBottom: "0.5rem" } /******************************************************************************* @@ -439,23 +449,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData } } - ///////////////////////////////////////////////////////////////////////////////////// - // only show the 'x' if it's to clear out a valid criteria on the field, // - // or if we were given a callback to remove the quick-filter field from the screen // - ///////////////////////////////////////////////////////////////////////////////////// - let xIcon = ; - if(criteriaIsValid || handleRemoveQuickFilterField) - { - xIcon = close - } - ////////////////////////////// // return the button & menu // ////////////////////////////// @@ -463,7 +456,13 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData return ( <> {button} - {xIcon} + { + ///////////////////////////////////////////////////////////////////////////////////// + // only show the 'x' if it's to clear out a valid criteria on the field, // + // or if we were given a callback to remove the quick-filter field from the screen // + ///////////////////////////////////////////////////////////////////////////////////// + (criteriaIsValid || handleRemoveQuickFilterField) && + } { isOpen && @@ -488,7 +487,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData operatorOption={operatorSelectedValue} criteria={criteria} field={fieldMetaData} - table={tableMetaData} // todo - joins? + table={tableForField} valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)} initiallyOpenMultiValuePvs={true} // todo - maybe not? /> diff --git a/src/qqq/components/query/XIcon.tsx b/src/qqq/components/query/XIcon.tsx new file mode 100644 index 0000000..9c66869 --- /dev/null +++ b/src/qqq/components/query/XIcon.tsx @@ -0,0 +1,92 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; +import React, {useContext} from "react"; +import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; + +interface XIconProps +{ + onClick: (e: React.MouseEvent) => void; + position: "forQuickFilter" | "forAdvancedQueryPreview" | "default"; + shade: "default" | "accent" | "accentLight" +} + +XIcon.defaultProps = { + position: "default", + shade: "default" +}; + +export default function XIcon({onClick, position, shade}: XIconProps): JSX.Element +{ + const {accentColor, accentColorLight} = useContext(QContext) + + ////////////////////////// + // for default position // + ////////////////////////// + let rest: any = { + top: "-0.75rem", + left: "-0.5rem", + } + + if(position == "forQuickFilter") + { + rest = { + left: "-1.125rem", + } + } + else if(position == "forAdvancedQueryPreview") + { + rest = { + top: "-0.375rem", + left: "-0.75rem", + } + } + + let color; + switch (shade) + { + case "default": + color = colors.gray.main; + break; + case "accent": + color = accentColor; + break; + case "accentLight": + color = accentColorLight; + break; + } + + return ( + close + ) +} diff --git a/src/qqq/models/query/QQueryColumns.ts b/src/qqq/models/query/QQueryColumns.ts index 8ba2a8d..bf27de6 100644 --- a/src/qqq/models/query/QQueryColumns.ts +++ b/src/qqq/models/query/QQueryColumns.ts @@ -114,6 +114,59 @@ export default class QQueryColumns return fields; } + + /******************************************************************************* + ** + *******************************************************************************/ + public getVisibleColumnCount(): number + { + let rs = 0; + for (let i = 0; i < this.columns.length; i++) + { + if(this.columns[i].name == "__check__") + { + continue; + } + + if(this.columns[i].isVisible) + { + rs++; + } + } + return (rs); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public getVisibilityToggleStates(): { [name: string]: boolean } + { + const rs: {[name: string]: boolean} = {}; + for (let i = 0; i < this.columns.length; i++) + { + rs[this.columns[i].name] = this.columns[i].isVisible; + } + return (rs); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public setIsVisible(name: string, isVisible: boolean) + { + for (let i = 0; i < this.columns.length; i++) + { + if(this.columns[i].name == name) + { + this.columns[i].isVisible = isVisible; + break; + } + } + } + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index f1f0c39..d8d1ddf 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -33,7 +33,7 @@ import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QC import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; -import {Alert, Collapse, Typography} from "@mui/material"; +import {Alert, Collapse, Menu, Typography} from "@mui/material"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; @@ -44,21 +44,22 @@ import LinearProgress from "@mui/material/LinearProgress"; import MenuItem from "@mui/material/MenuItem"; import Modal from "@mui/material/Modal"; import Tooltip from "@mui/material/Tooltip"; -import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} from "@mui/x-data-grid-pro"; +import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnHeaderParams, GridColumnHeaderSortIconProps, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} from "@mui/x-data-grid-pro"; import {GridRowModel} from "@mui/x-data-grid/models/gridRows"; import FormData from "form-data"; import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; import {useLocation, useNavigate, useSearchParams} from "react-router-dom"; import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; import {QCancelButton, QCreateNewButton} from "qqq/components/buttons/DefaultButtons"; import MenuButton from "qqq/components/buttons/MenuButton"; import {GotoRecordButton} from "qqq/components/misc/GotoRecordDialog"; import SavedViews from "qqq/components/misc/SavedViews"; import BasicAndAdvancedQueryControls from "qqq/components/query/BasicAndAdvancedQueryControls"; -import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; import CustomPaginationComponent from "qqq/components/query/CustomPaginationComponent"; import ExportMenuItem from "qqq/components/query/ExportMenuItem"; +import FieldListMenu from "qqq/components/query/FieldListMenu"; import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import QueryScreenActionMenu from "qqq/components/query/QueryScreenActionMenu"; import SelectionSubsetDialog from "qqq/components/query/SelectionSubsetDialog"; @@ -74,6 +75,7 @@ import DataGridUtils from "qqq/utils/DataGridUtils"; import Client from "qqq/utils/qqq/Client"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import ProcessUtils from "qqq/utils/qqq/ProcessUtils"; +import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; @@ -246,14 +248,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// const [pageState, setPageState] = useState("initial" as PageState) - /////////////////////////////////////////////////// - // state used by the custom column-chooser panel // - /////////////////////////////////////////////////// - const initialColumnChooserOpenGroups = {} as { [name: string]: boolean }; - initialColumnChooserOpenGroups[tableName] = true; - const [columnChooserOpenGroups, setColumnChooserOpenGroups] = useState(initialColumnChooserOpenGroups); - const [columnChooserFilterText, setColumnChooserFilterText] = useState(""); - ///////////////////////////////// // meta-data and derived state // ///////////////////////////////// @@ -285,6 +279,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [currentSavedView, setCurrentSavedView] = useState(null as QRecord); const [viewIdInLocation, setViewIdInLocation] = useState(null as number); const [loadingSavedView, setLoadingSavedView] = useState(false); + const [exportMenuAnchorElement, setExportMenuAnchorElement] = useState(null); + const [tableDefaultView, setTableDefaultView] = useState(new RecordQueryView()); ///////////////////////////////////////////////////// // state related to avoiding accidental row clicks // @@ -342,7 +338,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ///////////////////////////// // page context references // ///////////////////////////// - const {setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext); + const {accentColor, accentColorLight, setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext); + + ////////////////////////////////////////////////////////////////// + // we use our own header - so clear out the context page header // + ////////////////////////////////////////////////////////////////// + setPageHeader(null); ////////////////////// // ole' faithful... // @@ -416,6 +417,97 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return (false); } + + /******************************************************************************* + ** + *******************************************************************************/ + const prepQueryFilterForBackend = (sourceFilter: QQueryFilter) => + { + const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.booleanOperator); + for (let i = 0; i < sourceFilter?.criteria?.length; i++) + { + const criteria = sourceFilter.criteria[i]; + const {criteriaIsValid} = validateCriteria(criteria, null); + if (criteriaIsValid) + { + if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK) + { + /////////////////////////////////////////////////////////////////////////////////////////// + // do this to avoid submitting an empty-string argument for blank/not-blank operators... // + /////////////////////////////////////////////////////////////////////////////////////////// + filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, [])); + } + else + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName) + filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field))); + } + } + } + filterForBackend.skip = pageNumber * rowsPerPage; + filterForBackend.limit = rowsPerPage; + + return filterForBackend; + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function openExportMenu(event: any) + { + setExportMenuAnchorElement(event.currentTarget); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function closeExportMenu() + { + setExportMenuAnchorElement(null); + } + + + /////////////////////////////////////////// + // build the export menu, for the header // + /////////////////////////////////////////// + let exportMenu = <> + try + { + const exportMenuItemRestProps = + { + tableMetaData: tableMetaData, + totalRecords: totalRecords, + columnsModel: columnsModel, + columnVisibilityModel: columnVisibilityModel, + queryFilter: prepQueryFilterForBackend(queryFilter) + } + + exportMenu = (<> + save_alt + + + + + + ); + } + catch(e) + { + console.log("Error preparing export menu for page header: " + e); + } + /******************************************************************************* ** *******************************************************************************/ @@ -459,9 +551,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return(
    - {label} + {label} {exportMenu} - emergency + emergency {tableVariant && getTableVariantHeader(tableVariant)}
    ); @@ -470,7 +562,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { return (
    - {label} + {label} {exportMenu} {tableVariant && getTableVariantHeader(tableVariant)}
    ); } @@ -569,7 +661,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // in case page-state has already advanced to "ready" (e.g., and we're dealing with a user // // hitting back & forth between filters), then do a load of the new saved-view right here // ///////////////////////////////////////////////////////////////////////////////////////////// - if(pageState == "ready") + if (pageState == "ready") { handleSavedViewChange(currentSavedViewId); } @@ -593,6 +685,19 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }, [location]); + + /******************************************************************************* + ** set the current view in state & local-storage - but do NOT update any + ** child-state data. + *******************************************************************************/ + const doSetView = (view: RecordQueryView): void => + { + setView(view); + setViewAsJson(JSON.stringify(view)); + localStorage.setItem(viewLocalStorageKey, JSON.stringify(view)); + } + + /******************************************************************************* ** *******************************************************************************/ @@ -607,6 +712,40 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element forceUpdate(); }; + + /******************************************************************************* + ** function called by columns menu to turn a column on or off + *******************************************************************************/ + const handleChangeOneColumnVisibility = (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) => + { + /////////////////////////////////////// + // set the field's value in the view // + /////////////////////////////////////// + let fieldName = field.name; + if(table && table.name != tableMetaData.name) + { + fieldName = `${table.name}.${field.name}`; + } + + view.queryColumns.setIsVisible(fieldName, newValue) + + ///////////////////// + // update the grid // + ///////////////////// + setColumnVisibilityModel(queryColumns.toColumnVisibilityModel()); + + ///////////////////////////////////////////////// + // update the view (e.g., write local storage) // + ///////////////////////////////////////////////// + doSetView(view) + + /////////////////// + // ole' faithful // + /////////////////// + forceUpdate(); + } + + /******************************************************************************* ** *******************************************************************************/ @@ -656,43 +795,32 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element /******************************************************************************* - ** + ** return array of table names that need ... added to query *******************************************************************************/ - const prepQueryFilterForBackend = (sourceFilter: QQueryFilter) => + const ensureOrderBysFromJoinTablesAreVisibleTables = (queryFilter: QQueryFilter, visibleJoinTablesParam?: Set): string[] => { - const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.booleanOperator); - for (let i = 0; i < sourceFilter?.criteria?.length; i++) + const rs: string[] = []; + const vjtToUse = visibleJoinTablesParam ?? visibleJoinTables; + + for (let i = 0; i < queryFilter?.orderBys?.length; i++) { - const criteria = sourceFilter.criteria[i]; - const {criteriaIsValid} = validateCriteria(criteria, null); - if (criteriaIsValid) + const fieldName = queryFilter.orderBys[i].fieldName; + if(fieldName.indexOf(".") > -1) { - if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK) + const joinTableName = fieldName.replaceAll(/\..*/g, ""); + if(!vjtToUse.has(joinTableName)) { - /////////////////////////////////////////////////////////////////////////////////////////// - // do this to avoid submitting an empty-string argument for blank/not-blank operators... // - /////////////////////////////////////////////////////////////////////////////////////////// - filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, [])); - } - else - { - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName) - filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field))); + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + handleChangeOneColumnVisibility(field, fieldTable, true); + rs.push(fieldTable.name); } } } - filterForBackend.skip = pageNumber * rowsPerPage; - filterForBackend.limit = rowsPerPage; - // FilterUtils.convertFilterPossibleValuesToIds(filterForBackend); - // todo - expressions? - // todo - utc - return filterForBackend; + return (rs); } + /******************************************************************************* ** This is the method that actually executes a query to update the data in the table. *******************************************************************************/ @@ -729,6 +857,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (tableMetaData?.exposedJoins) { const visibleJoinTables = getVisibleJoinTables(); + const tablesToAdd = ensureOrderBysFromJoinTablesAreVisibleTables(queryFilter, visibleJoinTables); + + tablesToAdd?.forEach(t => visibleJoinTables.add(t)); + queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables); } @@ -1062,15 +1194,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element doSetQueryFilter(queryFilter); }; + /******************************************************************************* - ** set the current view in state & local-storage - but do NOT update any - ** child-state data. + ** *******************************************************************************/ - const doSetView = (view: RecordQueryView): void => + const handleColumnHeaderClick = (params: GridColumnHeaderParams, event: MuiEvent, details: GridCallbackDetails): void => { - setView(view); - setViewAsJson(JSON.stringify(view)); - localStorage.setItem(viewLocalStorageKey, JSON.stringify(view)); + event.defaultMuiPrevented = true; } @@ -1112,6 +1242,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { console.log(`Setting a new query filter: ${JSON.stringify(queryFilter)}`); + /////////////////////////////////////////////////// + // when we have a new filter, go back to page 0. // + /////////////////////////////////////////////////// + setPageNumber(0); + /////////////////////////////////////////////////// // in case there's no orderBys, set default here // /////////////////////////////////////////////////// @@ -1121,6 +1256,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element view.queryFilter = queryFilter; } + //////////////////////////////////////////////////////////////////////////////////////////////// + // in case the order-by is from a join table, and that table doesn't have any visible fields, // + // then activate the order-by field itself // + //////////////////////////////////////////////////////////////////////////////////////////////// + ensureOrderBysFromJoinTablesAreVisibleTables(queryFilter); + + ////////////////////////////// + // set the filter state var // + ////////////////////////////// setQueryFilter(queryFilter); /////////////////////////////////////////////////////// @@ -1423,6 +1567,22 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } } + + /******************************************************************************* + ** + *******************************************************************************/ + const buildTableDefaultView = (): RecordQueryView => + { + const newDefaultView = new RecordQueryView(); + newDefaultView.queryFilter = new QQueryFilter([], [new QFilterOrderBy(tableMetaData.primaryKeyField, false)]); + newDefaultView.queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData); + newDefaultView.viewIdentity = "empty"; + newDefaultView.rowsPerPage = defaultRowsPerPage; + newDefaultView.quickFilterFieldNames = []; + newDefaultView.mode = defaultMode; + return newDefaultView; + } + /******************************************************************************* ** event handler for SavedViews component, to handle user selecting a view ** (or clearing / selecting new) @@ -1453,18 +1613,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setCurrentSavedView(null); localStorage.removeItem(currentSavedViewLocalStorageKey); - ///////////////////////////////////////////////////// - // go back to a default query filter for the table // - ///////////////////////////////////////////////////// - doSetQueryFilter(new QQueryFilter()); - - const queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData); - doSetQueryColumns(queryColumns) - - ///////////////////////////////////////////////////// - // also reset the (user-added) quick-filter fields // - ///////////////////////////////////////////////////// - doSetQuickFilterFieldNames([]); + /////////////////////////////////////////////// + // activate a new default view for the table // + /////////////////////////////////////////////// + activateView(buildTableDefaultView()) } } @@ -1848,36 +2000,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; - ////////////////////////////////////////////////////////////////// - // props that get passed into all of the ExportMenuItem's below // - ////////////////////////////////////////////////////////////////// - const exportMenuItemRestProps = - { - tableMetaData: tableMetaData, - totalRecords: totalRecords, - columnsModel: columnsModel, - columnVisibilityModel: columnVisibilityModel, - queryFilter: prepQueryFilterForBackend(queryFilter) - } - return (
    - +
    - {/* @ts-ignore */} -
    {/* @ts-ignore */} - {/* @ts-ignore */} - - - - -
    @@ -1990,15 +2120,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } - /******************************************************************************* - ** maybe something to do with how page header is in a context, but, it didn't - ** work to check pageLoadingState.isLoadingSlow inside an element that we put - ** in the page header, so, this works instead. - *******************************************************************************/ - const setPageHeaderToLoadingSlow = (): void => - { - setPageHeader("Loading...") - } ///////////////////////////////////////////////////////////////////////////////// // use this to make changes to the queryFilter more likely to re-run the query // @@ -2024,12 +2145,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setPageState("loadingMetaData"); pageLoadingState.setLoading(); - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // reset the page header to blank, and tell the pageLoadingState object that if it becomes slow, to show 'Loading' // - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - setPageHeader(""); - pageLoadingState.setUponSlowCallback(setPageHeaderToLoadingSlow); - (async () => { const metaData = await qController.loadMetaData(); @@ -2056,6 +2171,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element (async () => { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // now that we know the table - build a default view - initially, only used by SavedViews component, for showing if there's anything to be saved. // + // but also used when user selects new-view from the view menu // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const newDefaultView = buildTableDefaultView(); + setTableDefaultView(newDefaultView); + ////////////////////////////////////////////////////////////////////////////////////////////// // once we've loaded meta data, let's check the location to see if we should open a process // ////////////////////////////////////////////////////////////////////////////////////////////// @@ -2212,7 +2334,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element (async () => { const visibleJoinTables = getVisibleJoinTables(); - setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, tableVariant)); ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// // todo - we used to be able to set "warnings" here (i think, like, for if a field got deleted from a table... // @@ -2362,11 +2483,135 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return (getLoadingScreen()); } + let savedViewsComponent = null; + if(metaData && metaData.processes.has("querySavedView")) + { + savedViewsComponent = (); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + const buildColumnMenu = () => + { + ////////////////////////////////////////// + // default (no saved view, and "clean") // + ////////////////////////////////////////// + let buttonBackground = "none"; + let buttonBorder = colors.grayLines.main; + let buttonColor = colors.gray.main; + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // diff the current view with either the current saved one, if there's one active, else the table default // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView; + const viewDiffs: string[] = []; + SavedViewUtils.diffColumns(tableMetaData, baseView, view, viewDiffs) + + if(viewDiffs.length == 0 && currentSavedView) + { + ///////////////////////////////////////////////////////////////// + // if 's a saved view, and it's "clean", show it in main style // + ///////////////////////////////////////////////////////////////// + buttonBackground = accentColor; + buttonBorder = accentColor; + buttonColor = "#FFFFFF"; + } + else if(viewDiffs.length > 0) + { + /////////////////////////////////////////////////// + // else if there are diffs, show alt/light style // + /////////////////////////////////////////////////// + buttonBackground = accentColorLight; + buttonBorder = accentColorLight; + buttonColor = accentColor; + } + + const columnMenuButtonStyles = { + borderRadius: "0.75rem", + border: `1px solid ${buttonBorder}`, + color: buttonColor, + textTransform: "none", + fontWeight: 500, + fontSize: "0.875rem", + p: "0.5rem", + backgroundColor: buttonBackground, + "&:focus:not(:hover)": { + color: buttonColor, + backgroundColor: buttonBackground, + }, + "&:hover": { + color: buttonColor, + backgroundColor: buttonBackground, + } + } + + return ( + view_week_outline Columns ({view.queryColumns.getVisibleColumnCount()}) keyboard_arrow_down} + isModeToggle={true} + toggleStates={view.queryColumns.getVisibilityToggleStates()} + handleToggleField={handleChangeOneColumnVisibility} + /> + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // these numbers help set the height of the grid (so page won't scroll) based on spcae above & below it // + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + let spaceBelowGrid = 40; + let spaceAboveGrid = 205; + if(tableMetaData?.usesVariants) + { + spaceAboveGrid += 30; + } + + if(mode == "advanced") + { + spaceAboveGrid += 60; + } + //////////////////////// // main screen render // //////////////////////// return ( + + + + {pageLoadingState.isLoading() && ""} + {pageLoadingState.isLoadingSlow() && "Loading..."} + {pageLoadingState.isNotLoading() && getPageHeader(tableMetaData, visibleJoinTables, tableVariant)} + + + + + + { + tableMetaData && + + } + + { + table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && + + } + +
    {/* // see code in ExportMenuItem that would use this @@ -2411,34 +2656,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ) : null } - - - { - metaData && metaData.processes.has("querySavedView") && - - } - - - - - { - tableMetaData && - - } - - { - table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && - - } - { metaData && tableMetaData && @@ -2454,6 +2671,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element gridApiRef={gridApiRef} mode={mode} setMode={doSetMode} + savedViewsComponent={savedViewsComponent} + columnMenuComponent={buildColumnMenu()} /> } @@ -2466,20 +2685,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element Pagination: CustomPagination, LoadingOverlay: CustomLoadingOverlay, ColumnMenu: CustomColumnMenu, - ColumnsPanel: CustomColumnsPanel, FilterPanel: CustomFilterPanel, + // @ts-ignore - this turns these off, whether TS likes it or not... + ColumnsPanel: "", ColumnSortedDescendingIcon: "", ColumnSortedAscendingIcon: "", ColumnUnsortedIcon: "", ColumnHeaderFilterIconButton: CustomColumnHeaderFilterIconButton, }} componentsProps={{ - columnsPanel: - { - tableMetaData: tableMetaData, - metaData: metaData, - initialOpenedGroups: columnChooserOpenGroups, - openGroupsChanger: setColumnChooserOpenGroups, - initialFilterText: columnChooserFilterText, - filterTextChanger: setColumnChooserFilterText - }, filterPanel: { tableMetaData: tableMetaData, @@ -2522,12 +2733,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element onSelectionModelChange={handleSelectionChanged} onSortModelChange={handleSortChange} sortingOrder={["asc", "desc"]} - sortModel={columnSortModel} + onColumnHeaderClick={handleColumnHeaderClick} getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")} getRowId={(row) => row.__rowIndex} selectionModel={rowSelectionModel} hideFooterSelectedRowCount={true} - sx={{border: 0, height: tableMetaData?.usesVariants ? "calc(100vh - 300px)" : "calc(100vh - 270px)"}} + sx={{border: 0, height: `calc(100vh - ${spaceAboveGrid + spaceBelowGrid}px)`}} /> @@ -2548,7 +2759,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { setTableVariantPromptOpen(false); setTableVariant(value); - setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, value)); }} /> } diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 0642ab8..b327e62 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -29,6 +29,13 @@ min-height: calc(100vh - 450px) !important; } +/* we want to leave columns w/ the sortable attribute (so they have it in the column menu), +but we've turned off the click-to-sort function, so remove hand cursor */ +.recordQuery .MuiDataGrid-columnHeader--sortable +{ + cursor: default !important; +} + /* Disable red outlines on clicked cells */ .MuiDataGrid-cell:focus, .MuiDataGrid-columnHeader:focus, @@ -402,7 +409,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } margin-right: 8px; } -.custom-columns-panel .MuiSwitch-thumb +.custom-columns-panel .MuiSwitch-thumb, +.fieldListMenuBody .MuiSwitch-thumb { width: 15px !important; height: 15px !important; @@ -428,7 +436,7 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } { /* overwrite what the grid tries to do here, where it changes based on density... we always want the same. */ /* transform: translate(274px, 305px) !important; */ - transform: translate(274px, 276px) !important; + transform: translate(274px, 264px) !important; } /* within the data-grid, the filter-panel's container has a max-height. mirror that, and set an overflow-y */ diff --git a/src/qqq/utils/qqq/FilterUtils.tsx b/src/qqq/utils/qqq/FilterUtils.tsx index fe7c45e..703e594 100644 --- a/src/qqq/utils/qqq/FilterUtils.tsx +++ b/src/qqq/utils/qqq/FilterUtils.tsx @@ -30,6 +30,7 @@ import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFil import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression"; +import Box from "@mui/material/Box"; import {GridSortModel} from "@mui/x-data-grid-pro"; import TableUtils from "qqq/utils/qqq/TableUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; @@ -539,9 +540,14 @@ class FilterUtils if(styled) { - return (<> - {fieldLabel} {FilterUtils.operatorToHumanString(criteria, field)} {valuesString}  - ); + return ( + + {fieldLabel} + {FilterUtils.operatorToHumanString(criteria, field)} + {valuesString && {valuesString}} +   + + ) } else { diff --git a/src/qqq/utils/qqq/SavedViewUtils.ts b/src/qqq/utils/qqq/SavedViewUtils.ts new file mode 100644 index 0000000..a40b962 --- /dev/null +++ b/src/qqq/utils/qqq/SavedViewUtils.ts @@ -0,0 +1,418 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; +import QQueryColumns from "qqq/models/query/QQueryColumns"; +import RecordQueryView from "qqq/models/query/RecordQueryView"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import TableUtils from "qqq/utils/qqq/TableUtils"; + +/******************************************************************************* + ** Utility class for working with QQQ Saved Views + ** + *******************************************************************************/ +export class SavedViewUtils +{ + + /******************************************************************************* + ** + *******************************************************************************/ + public static fieldNameToLabel = (tableMetaData: QTableMetaData, fieldName: string): string => + { + try + { + const [fieldMetaData, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + if (fieldTable.name != tableMetaData.name) + { + return (fieldTable.label + ": " + fieldMetaData.label); + } + + return (fieldMetaData.label); + } + catch (e) + { + return (fieldName); + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public static diffFilters = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => + { + try + { + //////////////////////////////////////////////////////////////////////////////// + // inner helper function for reporting on the number of criteria for a field. // + // e.g., will tell us "added criteria X" or "removed 2 criteria on Y" // + //////////////////////////////////////////////////////////////////////////////// + const diffCriteriaFunction = (base: QQueryFilter, compare: QQueryFilter, messagePrefix: string, isCheckForChanged = false) => + { + const baseCriteriaMap: { [name: string]: QFilterCriteria[] } = {}; + base?.criteria?.forEach((criteria) => + { + if (validateCriteria(criteria).criteriaIsValid) + { + if (!baseCriteriaMap[criteria.fieldName]) + { + baseCriteriaMap[criteria.fieldName] = []; + } + baseCriteriaMap[criteria.fieldName].push(criteria); + } + }); + + const compareCriteriaMap: { [name: string]: QFilterCriteria[] } = {}; + compare?.criteria?.forEach((criteria) => + { + if (validateCriteria(criteria).criteriaIsValid) + { + if (!compareCriteriaMap[criteria.fieldName]) + { + compareCriteriaMap[criteria.fieldName] = []; + } + compareCriteriaMap[criteria.fieldName].push(criteria); + } + }); + + for (let fieldName of Object.keys(compareCriteriaMap)) + { + const noBaseCriteria = baseCriteriaMap[fieldName]?.length ?? 0; + const noCompareCriteria = compareCriteriaMap[fieldName]?.length ?? 0; + + if (isCheckForChanged) + { + ///////////////////////////////////////////////////////////////////////////////////////////// + // first - if we're checking for changes to specific criteria (e.g., change id=5 to id<>5, // + // or change id=5 to id=6, or change id=5 to id<>7) // + // our "sweet spot" is if there's a single criteria on each side of the check // + ///////////////////////////////////////////////////////////////////////////////////////////// + if (noBaseCriteria == 1 && noCompareCriteria == 1) + { + const baseCriteria = baseCriteriaMap[fieldName][0]; + const compareCriteria = compareCriteriaMap[fieldName][0]; + const baseValuesJSON = JSON.stringify(baseCriteria.values ?? []); + const compareValuesJSON = JSON.stringify(compareCriteria.values ?? []); + if (baseCriteria.operator != compareCriteria.operator || baseValuesJSON != compareValuesJSON) + { + viewDiffs.push(`Changed a filter from ${FilterUtils.criteriaToHumanString(tableMetaData, baseCriteria)} to ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteria)}`); + } + } + else if (noBaseCriteria == noCompareCriteria) + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else - if the number of criteria on this field differs, that'll get caught in a non-isCheckForChanged call, so // + // todo, i guess - this is kinda weak - but if there's the same number of criteria on a field, then just ... do a shitty JSON compare between them... // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const baseJSON = JSON.stringify(baseCriteriaMap[fieldName]); + const compareJSON = JSON.stringify(compareCriteriaMap[fieldName]); + if (baseJSON != compareJSON) + { + viewDiffs.push(`${messagePrefix} 1 or more filters on ${SavedViewUtils.fieldNameToLabel(tableMetaData, fieldName)}`); + } + } + } + else + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else - we're not checking for changes to individual criteria - rather - we're just checking if criteria were added or removed. // + // we'll do that by starting to see if the nubmer of criteria is different. // + // and, only do it in only 1 direction, assuming we'll get called twice, with the base & compare sides flipped // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (noBaseCriteria < noCompareCriteria) + { + if (noBaseCriteria == 0 && noCompareCriteria == 1) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if the difference is 0 to 1 (1 to 0 when called in reverse), then we can report the full criteria that was added/removed // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + viewDiffs.push(`${messagePrefix} filter: ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteriaMap[fieldName][0])}`); + } + else + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else, say 0 to 2, or 2 to 1 - just report on how many were changed... // + // todo this isn't great, as you might have had, say, (A,B), and now you have (C) - but all we'll say is "removed 1"... // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const noDiffs = noCompareCriteria - noBaseCriteria; + viewDiffs.push(`${messagePrefix} ${noDiffs} filters on ${SavedViewUtils.fieldNameToLabel(tableMetaData, fieldName)}`); + } + } + } + } + }; + + diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Added"); + diffCriteriaFunction(activeView.queryFilter, savedView.queryFilter, "Removed"); + diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Changed", true); + + ////////////////////// + // boolean operator // + ////////////////////// + if (savedView.queryFilter.booleanOperator != activeView.queryFilter.booleanOperator) + { + viewDiffs.push("Changed filter from 'And' to 'Or'"); + } + + /////////////// + // order-bys // + /////////////// + const savedOrderBys = savedView.queryFilter.orderBys; + const activeOrderBys = activeView.queryFilter.orderBys; + if (savedOrderBys.length != activeOrderBys.length) + { + viewDiffs.push("Changed sort"); + } + else if (savedOrderBys.length > 0) + { + const toWord = ((b: boolean) => b ? "ascending" : "descending"); + if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName && savedOrderBys[0].isAscending != activeOrderBys[0].isAscending) + { + viewDiffs.push(`Changed sort from ${SavedViewUtils.fieldNameToLabel(tableMetaData, savedOrderBys[0].fieldName)} ${toWord(savedOrderBys[0].isAscending)} to ${SavedViewUtils.fieldNameToLabel(tableMetaData, activeOrderBys[0].fieldName)} ${toWord(activeOrderBys[0].isAscending)}`); + } + else if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName) + { + viewDiffs.push(`Changed sort field from ${SavedViewUtils.fieldNameToLabel(tableMetaData, savedOrderBys[0].fieldName)} to ${SavedViewUtils.fieldNameToLabel(tableMetaData, activeOrderBys[0].fieldName)}`); + } + else if (savedOrderBys[0].isAscending != activeOrderBys[0].isAscending) + { + viewDiffs.push(`Changed sort direction from ${toWord(savedOrderBys[0].isAscending)} to ${toWord(activeOrderBys[0].isAscending)}`); + } + } + } + catch (e) + { + console.log(`Error looking for differences in filters ${e}`); + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public static diffColumns = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => + { + try + { + if (!savedView.queryColumns || !savedView.queryColumns.columns || savedView.queryColumns.columns.length == 0) + { + viewDiffs.push("This view did not previously have columns saved with it, so the next time you save it they will be initialized."); + return; + } + + //////////////////////////////////////////////////////////// + // nested function to help diff visible status of columns // + //////////////////////////////////////////////////////////// + const diffVisibilityFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => + { + const baseColumnsMap: { [name: string]: boolean } = {}; + base.columns.forEach((column) => + { + if (column.isVisible) + { + baseColumnsMap[column.name] = true; + } + }); + + const diffFields: string[] = []; + for (let i = 0; i < compare.columns.length; i++) + { + const column = compare.columns[i]; + if (column.isVisible) + { + if (!baseColumnsMap[column.name]) + { + diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name)); + } + } + } + + if (diffFields.length > 0) + { + if (diffFields.length > 5) + { + viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); + } + else + { + viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + } + }; + + /////////////////////////////////////////////////////////// + // nested function to help diff pinned status of columns // + /////////////////////////////////////////////////////////// + const diffPinsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => + { + const baseColumnsMap: { [name: string]: string } = {}; + base.columns.forEach((column) => baseColumnsMap[column.name] = column.pinned); + + const diffFields: string[] = []; + for (let i = 0; i < compare.columns.length; i++) + { + const column = compare.columns[i]; + if (baseColumnsMap[column.name] != column.pinned) + { + diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name)); + } + } + + if (diffFields.length > 0) + { + if (diffFields.length > 5) + { + viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); + } + else + { + viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + } + }; + + /////////////////////////////////////////////////// + // nested function to help diff width of columns // + /////////////////////////////////////////////////// + const diffWidthsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => + { + const baseColumnsMap: { [name: string]: number } = {}; + base.columns.forEach((column) => baseColumnsMap[column.name] = column.width); + + const diffFields: string[] = []; + for (let i = 0; i < compare.columns.length; i++) + { + const column = compare.columns[i]; + if (baseColumnsMap[column.name] != column.width) + { + diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name)); + } + } + + if (diffFields.length > 0) + { + if (diffFields.length > 5) + { + viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); + } + else + { + viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + } + }; + + diffVisibilityFunction(savedView.queryColumns, activeView.queryColumns, "Turned on "); + diffVisibilityFunction(activeView.queryColumns, savedView.queryColumns, "Turned off "); + diffPinsFunction(savedView.queryColumns, activeView.queryColumns, "Changed pinned state for "); + + if (savedView.queryColumns.columns.map(c => c.name).join(",") != activeView.queryColumns.columns.map(c => c.name).join(",")) + { + viewDiffs.push("Changed the order of columns."); + } + + diffWidthsFunction(savedView.queryColumns, activeView.queryColumns, "Changed width for "); + } + catch (e) + { + console.log(`Error looking for differences in columns: ${e}`); + } + }; + + /******************************************************************************* + ** + *******************************************************************************/ + public static diffQuickFilterFieldNames = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => + { + try + { + const diffFunction = (base: string[], compare: string[], messagePrefix: string) => + { + const baseFieldNameMap: { [name: string]: boolean } = {}; + base.forEach((name) => baseFieldNameMap[name] = true); + const diffFields: string[] = []; + for (let i = 0; i < compare.length; i++) + { + const name = compare[i]; + if (!baseFieldNameMap[name]) + { + diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, name)); + } + } + + if (diffFields.length > 0) + { + viewDiffs.push(`${messagePrefix} basic filter${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + }; + + diffFunction(savedView.quickFilterFieldNames, activeView.quickFilterFieldNames, "Turned on"); + diffFunction(activeView.quickFilterFieldNames, savedView.quickFilterFieldNames, "Turned off"); + } + catch (e) + { + console.log(`Error looking for differences in quick filter field names: ${e}`); + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public static diffViews = (tableMetaData: QTableMetaData, baseView: RecordQueryView, activeView: RecordQueryView): string[] => + { + const viewDiffs: string[] = []; + + SavedViewUtils.diffFilters(tableMetaData, baseView, activeView, viewDiffs); + SavedViewUtils.diffColumns(tableMetaData, baseView, activeView, viewDiffs); + SavedViewUtils.diffQuickFilterFieldNames(tableMetaData, baseView, activeView, viewDiffs); + + if (baseView.mode != activeView.mode) + { + if (baseView.mode) + { + viewDiffs.push(`Mode changed from ${baseView.mode} to ${activeView.mode}`); + } + else + { + viewDiffs.push(`Mode set to ${activeView.mode}`); + } + } + + if (baseView.rowsPerPage != activeView.rowsPerPage) + { + if (baseView.rowsPerPage) + { + viewDiffs.push(`Rows per page changed from ${baseView.rowsPerPage} to ${activeView.rowsPerPage}`); + } + else + { + viewDiffs.push(`Rows per page set to ${activeView.rowsPerPage}`); + } + } + return viewDiffs; + }; + +} \ No newline at end of file From 6d44bab49be3712411f58cf14f2b4ae0826a8342 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 6 Feb 2024 19:41:12 -0600 Subject: [PATCH 34/40] CE-798 turn off fixed navbar; move pageHeader from Breadcrumbs to NavBar CE-798 turn off fixed navbar; move pageHeader from Breadcrumbs to NavBar --- src/qqq/components/horseshoe/Breadcrumbs.tsx | 11 +--------- src/qqq/components/horseshoe/NavBar.tsx | 23 +++++++++++--------- src/qqq/components/horseshoe/Styles.ts | 11 +++++----- src/qqq/components/misc/RecordSidebar.tsx | 4 ++-- src/qqq/context/index.tsx | 2 +- 5 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/qqq/components/horseshoe/Breadcrumbs.tsx b/src/qqq/components/horseshoe/Breadcrumbs.tsx index 79e5720..cc396bd 100644 --- a/src/qqq/components/horseshoe/Breadcrumbs.tsx +++ b/src/qqq/components/horseshoe/Breadcrumbs.tsx @@ -70,7 +70,7 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element } const routes: string[] | any = route.slice(0, -1); - const {pageHeader, pathToLabelMap, branding} = useContext(QContext); + const {pathToLabelMap, branding} = useContext(QContext); const fullPathToLabel = (fullPath: string, route: string): string => { @@ -149,15 +149,6 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element ))} - - {pageHeader} - ); } diff --git a/src/qqq/components/horseshoe/NavBar.tsx b/src/qqq/components/horseshoe/NavBar.tsx index 1669564..f2aefb4 100644 --- a/src/qqq/components/horseshoe/NavBar.tsx +++ b/src/qqq/components/horseshoe/NavBar.tsx @@ -22,12 +22,10 @@ import {Popper, InputAdornment} from "@mui/material"; import AppBar from "@mui/material/AppBar"; import Autocomplete from "@mui/material/Autocomplete"; -import Badge from "@mui/material/Badge"; import Box from "@mui/material/Box"; import Icon from "@mui/material/Icon"; import IconButton from "@mui/material/IconButton"; import ListItemIcon from "@mui/material/ListItemIcon"; -import Menu from "@mui/material/Menu"; import TextField from "@mui/material/TextField"; import Toolbar from "@mui/material/Toolbar"; import React, {useContext, useEffect, useState} from "react"; @@ -35,6 +33,7 @@ import {useLocation, useNavigate} from "react-router-dom"; import QContext from "QContext"; import QBreadcrumbs, {routeToLabel} from "qqq/components/horseshoe/Breadcrumbs"; import {navbar, navbarContainer, navbarRow, navbarMobileMenu, recentlyViewedMenu,} from "qqq/components/horseshoe/Styles"; +import MDTypography from "qqq/components/legacy/MDTypography"; import {setTransparentNavbar, useMaterialUIController, setMiniSidenav} from "qqq/context"; import HistoryUtils from "qqq/utils/HistoryUtils"; @@ -65,6 +64,8 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element const route = useLocation().pathname.split("/").slice(1); const navigate = useNavigate(); + const {pageHeader} = useContext(QContext); + useEffect(() => { // Setting the navbar type @@ -234,25 +235,27 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element > navbarRow(theme, {isMini})}> - + menu {isMini ? null : ( navbarRow(theme, {isMini})}> - + {renderHistory()} )} + { + pageHeader && + + + {pageHeader} + + + } ); } diff --git a/src/qqq/components/horseshoe/Styles.ts b/src/qqq/components/horseshoe/Styles.ts index c9c87c1..5e56745 100644 --- a/src/qqq/components/horseshoe/Styles.ts +++ b/src/qqq/components/horseshoe/Styles.ts @@ -66,12 +66,12 @@ function navbar(theme: Theme | any, ownerState: any) return color; }, top: absolute ? 0 : pxToRem(12), - minHeight: pxToRem(75), + minHeight: "auto", display: "grid", alignItems: "center", borderRadius: borderRadius.xl, - paddingTop: pxToRem(8), - paddingBottom: pxToRem(8), + paddingTop: pxToRem(0), + paddingBottom: pxToRem(0), paddingRight: absolute ? pxToRem(8) : 0, paddingLeft: absolute ? pxToRem(16) : 0, @@ -85,7 +85,7 @@ function navbar(theme: Theme | any, ownerState: any) "& .MuiToolbar-root": { display: "flex", justifyContent: "space-between", - alignItems: "center", + alignItems: "flex-start", [breakpoints.up("sm")]: { minHeight: "auto", @@ -99,10 +99,10 @@ const navbarContainer = ({breakpoints}: Theme): any => ({ flexDirection: "column", alignItems: "flex-start", justifyContent: "space-between", + padding: "0 !important", [breakpoints.up("md")]: { flexDirection: "row", - alignItems: "center", paddingTop: "0", paddingBottom: "0", }, @@ -152,6 +152,7 @@ const navbarDesktopMenu = ({breakpoints}: Theme) => ({ }); const recentlyViewedMenu = ({breakpoints}: Theme) => ({ + marginTop: "-0.5rem", "& .MuiInputLabel-root": { color: colors.gray.main, fontWeight: "500", diff --git a/src/qqq/components/misc/RecordSidebar.tsx b/src/qqq/components/misc/RecordSidebar.tsx index 25b6ac3..fffa5fa 100644 --- a/src/qqq/components/misc/RecordSidebar.tsx +++ b/src/qqq/components/misc/RecordSidebar.tsx @@ -41,7 +41,7 @@ interface Props QRecordSidebar.defaultProps = { light: false, - stickyTop: "110px", + stickyTop: "1rem", }; interface SidebarEntry @@ -76,7 +76,7 @@ function QRecordSidebar({tableSections, widgetMetaDataList, light, stickyTop}: P return ( - + { sidebarEntries ? sidebarEntries.map((entry: SidebarEntry, key: number) => ( diff --git a/src/qqq/context/index.tsx b/src/qqq/context/index.tsx index e7ea69b..4e1ed7d 100644 --- a/src/qqq/context/index.tsx +++ b/src/qqq/context/index.tsx @@ -116,7 +116,7 @@ function MaterialUIControllerProvider({children}: { children: ReactNode }): JSX. whiteSidenav: false, sidenavColor: "info", transparentNavbar: true, - fixedNavbar: true, + fixedNavbar: false, openConfigurator: false, direction: "ltr", layout: "dashboard", From 26f9e19222b14fc279b18d795d9f609a23ca36f0 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 6 Feb 2024 20:24:20 -0600 Subject: [PATCH 35/40] CE-798 Get seleniums passing --- .../query/BasicAndAdvancedQueryControls.tsx | 4 +- .../lib/QQQMaterialDashboardSelectors.java | 2 +- .../selenium/lib/QSeleniumLib.java | 6 +-- .../selenium/lib/QueryScreenLib.java | 4 +- .../selenium/tests/AppPageNavTest.java | 4 +- .../tests/AssociatedRecordScriptTest.java | 2 +- .../selenium/tests/AuditTest.java | 6 +-- .../selenium/tests/BulkEditTest.java | 2 +- ...ClickLinkOnRecordThenEditShortcutTest.java | 2 +- .../tests/DashboardTableWidgetExportTest.java | 2 +- ...ueryScreenFilterInUrlAdvancedModeTest.java | 17 +++---- .../QueryScreenFilterInUrlBasicModeTest.java | 12 ++--- .../selenium/tests/QueryScreenTest.java | 8 ++-- .../selenium/tests/SavedViewsTest.java | 47 ++++++++++--------- .../selenium/tests/ScriptTableTest.java | 2 +- 15 files changed, 62 insertions(+), 58 deletions(-) diff --git a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx index 2a6e563..907476a 100644 --- a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx +++ b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx @@ -710,13 +710,13 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo Filter Builder { countValidCriteria(queryFilter) > 0 && - + {countValidCriteria(queryFilter) } } { - hasValidFilters && setShowClearFiltersWarning(true)} /> + hasValidFilters && setShowClearFiltersWarning(true)} /> } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QQQMaterialDashboardSelectors.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QQQMaterialDashboardSelectors.java index 654f278..559bce2 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QQQMaterialDashboardSelectors.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QQQMaterialDashboardSelectors.java @@ -28,7 +28,7 @@ package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib; public interface QQQMaterialDashboardSelectors { String SIDEBAR_ITEM = ".MuiDrawer-paperAnchorDockedLeft li.MuiListItem-root"; - String BREADCRUMB_HEADER = ".MuiToolbar-root h3"; + String BREADCRUMB_HEADER = "h3"; String QUERY_GRID_CELL = ".MuiDataGrid-root .MuiDataGrid-cellContent"; String QUERY_FILTER_INPUT = ".customFilterPanel input.MuiInput-input"; diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java index 37a9351..10781d7 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java @@ -43,7 +43,7 @@ import org.openqa.selenium.WebElement; import org.openqa.selenium.interactions.Actions; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.fail; @@ -196,7 +196,7 @@ public class QSeleniumLib /******************************************************************************* ** *******************************************************************************/ - public void gotoAndWaitForBreadcrumbHeader(String path, String headerText) + public void gotoAndWaitForBreadcrumbHeaderToContain(String path, String expectedHeaderText) { driver.get(BASE_URL + path); @@ -204,7 +204,7 @@ public class QSeleniumLib .until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER))); LOG.debug("Navigated to [" + path + "]. Breadcrumb Header: " + header.getText()); - assertEquals(headerText, header.getText()); + assertThat(header.getText()).contains(expectedHeaderText); } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QueryScreenLib.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QueryScreenLib.java index 5fcf36a..9b08715 100644 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QueryScreenLib.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QueryScreenLib.java @@ -52,7 +52,7 @@ public class QueryScreenLib *******************************************************************************/ public WebElement assertFilterButtonBadge(int valueInBadge) { - return qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", String.valueOf(valueInBadge)); + return qSeleniumLib.waitForSelectorContaining(".filterBuilderCountBadge", String.valueOf(valueInBadge)); } @@ -62,7 +62,7 @@ public class QueryScreenLib *******************************************************************************/ public void assertNoFilterButtonBadge(int valueInBadge) { - qSeleniumLib.waitForSelectorContainingToNotExist(".MuiBadge-root", String.valueOf(valueInBadge)); + qSeleniumLib.waitForSelectorContainingToNotExist(".filterBuilderCountBadge", String.valueOf(valueInBadge)); } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AppPageNavTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AppPageNavTest.java index 8daf188..9d62a96 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AppPageNavTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AppPageNavTest.java @@ -57,7 +57,7 @@ public class AppPageNavTest extends QBaseSeleniumTest @Test void testHomeToAppPageViaLeftNav() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/", "Greetings App"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/", "Greetings App"); qSeleniumLib.waitForSelectorContaining(QQQMaterialDashboardSelectors.SIDEBAR_ITEM, "People App").click(); qSeleniumLib.waitForSelectorContaining(QQQMaterialDashboardSelectors.SIDEBAR_ITEM, "Greetings App").click(); } @@ -70,7 +70,7 @@ public class AppPageNavTest extends QBaseSeleniumTest @Test void testAppPageToTablePage() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp", "Greetings App"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp", "Greetings App"); qSeleniumLib.tryMultiple(3, () -> qSeleniumLib.waitForSelectorContaining("a", "Person").click()); qSeleniumLib.waitForSelectorContaining(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER, "Person"); } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java index fbbce56..89c7085 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java @@ -53,7 +53,7 @@ public class AssociatedRecordScriptTest extends QBaseSeleniumTest @Test void testNavigatingBackAndForth() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1", "John Doe"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1", "John Doe"); qSeleniumLib.waitForSelectorContaining("BUTTON", "actions").click(); qSeleniumLib.waitForSelectorContaining("LI", "Developer Mode").click(); diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AuditTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AuditTest.java index b7443a0..4aff845 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AuditTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AuditTest.java @@ -63,7 +63,7 @@ public class AuditTest extends QBaseSeleniumTest qSeleniumJavalin.withRouteToFile("/data/audit/query", "data/audit/query-empty.json"); qSeleniumJavalin.restart(); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1701", "John Doe"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1701", "John Doe"); qSeleniumLib.waitForSelectorContaining("BUTTON", "Actions").click(); qSeleniumLib.waitForSelectorContaining("LI", "Audit").click(); @@ -90,7 +90,7 @@ public class AuditTest extends QBaseSeleniumTest qSeleniumJavalin.withRouteToFile(auditQueryPath, "data/audit/query.json"); qSeleniumJavalin.restart(); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1701", "John Doe"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1701", "John Doe"); qSeleniumLib.waitForSelectorContaining("BUTTON", "Actions").click(); qSeleniumLib.waitForSelectorContaining("LI", "Audit").click(); @@ -121,7 +121,7 @@ public class AuditTest extends QBaseSeleniumTest qSeleniumJavalin.withRouteToFile(auditQueryPath, "data/audit/query.json"); qSeleniumJavalin.restart(); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1701", "John Doe"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1701", "John Doe"); qSeleniumLib.waitForSelectorContaining("BUTTON", "Actions").click(); qSeleniumLib.waitForSelectorContaining("LI", "Audit").click(); diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java index 2a247dd..e0a49ac 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java @@ -71,7 +71,7 @@ public class BulkEditTest extends QBaseSeleniumTest // @RepeatedTest(100) void test() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person"); qSeleniumLib.waitForSelectorContaining("button", "selection").click(); qSeleniumLib.waitForSelectorContaining("li", "This page").click(); qSeleniumLib.waitForSelectorContaining("div", "records on this page are selected"); diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ClickLinkOnRecordThenEditShortcutTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ClickLinkOnRecordThenEditShortcutTest.java index 35ed7dd..28f11c6 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ClickLinkOnRecordThenEditShortcutTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ClickLinkOnRecordThenEditShortcutTest.java @@ -55,7 +55,7 @@ public class ClickLinkOnRecordThenEditShortcutTest extends QBaseSeleniumTest @Test void testClickLinkOnRecordThenEditShortcutTest() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/developer/script/1", "Hello, Script"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/developer/script/1", "Hello, Script"); qSeleniumLib.waitForSelectorContaining("A", "100").click(); qSeleniumLib.waitForSelectorContaining("BUTTON", "actions").sendKeys("e"); diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java index 04cc731..f9d4779 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java @@ -76,7 +76,7 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest @Test void testDashboardTableWidgetExport() throws IOException { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/", "Greetings App"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/", "Greetings App"); //////////////////////////////////////////////////////////////////////// // assert that the table widget rendered its header and some contents // diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlAdvancedModeTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlAdvancedModeTest.java index d7be415..23ccaed 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlAdvancedModeTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlAdvancedModeTest.java @@ -71,7 +71,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest //////////////////////////////// // put table in advanced mode // //////////////////////////////// - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person"); queryScreenLib.gotoAdvancedMode(); //////////////////////////////////////// @@ -79,7 +79,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest //////////////////////////////////////// String filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertFilterButtonBadge(1); queryScreenLib.clickFilterButton(); @@ -90,7 +90,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest /////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertFilterButtonBadge(1); queryScreenLib.clickFilterButton(); @@ -103,7 +103,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest ////////////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.IN, 1, 2))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertFilterButtonBadge(1); queryScreenLib.clickFilterButton(); @@ -116,7 +116,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest ///////////////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS)))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertFilterButtonBadge(1); queryScreenLib.clickFilterButton(); @@ -129,7 +129,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("firstName", QCriteriaOperator.STARTS_WITH, "Dar")) .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, ThisOrLastPeriod.this_(ChronoUnit.YEARS)))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertFilterButtonBadge(2); queryScreenLib.clickFilterButton(); @@ -152,7 +152,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest ////////////////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertFilterButtonBadge(1); queryScreenLib.clickFilterButton(); @@ -162,7 +162,8 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest //////////////// // remove one // //////////////// - qSeleniumLib.waitForSelectorContaining(".MuiIcon-root", "close").click(); + qSeleniumLib.tryMultiple(3, () -> qSeleniumLib.waitForSelector(".filterBuilderXIcon BUTTON").click()); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Yes").click(); queryScreenLib.assertNoFilterButtonBadge(1); // qSeleniumLib.waitForever(); diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java index 9b59351..d2a5f9e 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenFilterInUrlBasicModeTest.java @@ -73,7 +73,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest //////////////////////////////////////// String filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("annualSalary"); queryScreenLib.clickQuickFilterButton("annualSalary"); @@ -84,7 +84,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest /////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("annualSalary"); queryScreenLib.clickQuickFilterButton("annualSalary"); @@ -97,7 +97,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest ////////////////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("homeCityId"); queryScreenLib.clickQuickFilterButton("homeCityId"); @@ -109,7 +109,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest ////////////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.IN, 1, 2))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("homeCityId"); queryScreenLib.clickQuickFilterButton("homeCityId"); @@ -122,7 +122,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest ///////////////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS)))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("createDate"); queryScreenLib.clickQuickFilterButton("createDate"); @@ -135,7 +135,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("firstName", QCriteriaOperator.STARTS_WITH, "Dar")) .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, ThisOrLastPeriod.this_(ChronoUnit.YEARS)))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("firstName"); queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("createDate"); diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java index 25520d1..24d33d9 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/QueryScreenTest.java @@ -61,7 +61,7 @@ public class QueryScreenTest extends QBaseSeleniumTest { QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.gotoAdvancedMode(); queryScreenLib.clickFilterButton(); @@ -86,13 +86,13 @@ public class QueryScreenTest extends QBaseSeleniumTest /////////////////////////////////////// qSeleniumLib.waitForSeconds(1); // todo grr. qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER).click(); - qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", "1"); + qSeleniumLib.waitForSelectorContaining(".filterBuilderCountBadge", "1"); /////////////////////////////////////////////////////////////////// // click the 'x' clear icon, then yes, then expect another query // /////////////////////////////////////////////////////////////////// qSeleniumJavalin.beginCapture(); - qSeleniumLib.tryMultiple(3, () -> qSeleniumLib.waitForSelector("#clearFiltersButton").click()); + qSeleniumLib.tryMultiple(3, () -> qSeleniumLib.waitForSelector(".filterBuilderXIcon BUTTON").click()); qSeleniumLib.waitForSelectorContaining("BUTTON", "Yes").click(); //////////////////////////////////////////////////////////////////// @@ -115,7 +115,7 @@ public class QueryScreenTest extends QBaseSeleniumTest { QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person"); queryScreenLib.waitForQueryToHaveRan(); queryScreenLib.gotoAdvancedMode(); queryScreenLib.clickFilterButton(); diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedViewsTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedViewsTest.java index 3d3c0ee..99e8702 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedViewsTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/SavedViewsTest.java @@ -22,10 +22,15 @@ package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; +import org.openqa.selenium.WebElement; +import static org.junit.jupiter.api.Assertions.assertTrue; /******************************************************************************* @@ -66,9 +71,9 @@ public class SavedViewsTest extends QBaseSeleniumTest { QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person"); - qSeleniumLib.waitForSelectorContaining("BUTTON", "Saved Views").click(); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Views").click(); qSeleniumLib.waitForSelectorContaining("LI", "Some People"); //////////////////////////////////////// @@ -85,7 +90,7 @@ public class SavedViewsTest extends QBaseSeleniumTest ///////////////////////////////////////////////////// qSeleniumLib.waitForSelectorContaining("LI", "Some People").click(); qSeleniumLib.waitForCondition("Current URL should have view id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2")); - qSeleniumLib.waitForSelectorContaining("DIV", "Current View: Some People"); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Some People"); ////////////////////////////// // click into a view screen // @@ -100,19 +105,20 @@ public class SavedViewsTest extends QBaseSeleniumTest /////////////////////////////////////////////////// qSeleniumLib.waitForSelectorContaining("A", "Person").click(); qSeleniumLib.waitForCondition("Current URL should have View id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2")); - qSeleniumLib.waitForSelectorContaining("DIV", "Current View: Some People"); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Some People"); queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("firstName"); ////////////////////// // modify the query // ////////////////////// - /* todo - right now - this is changed - but - working through it with Views story... revisit before merge! - queryScreenLib.clickFilterButton(); - queryScreenLib.addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); - qSeleniumLib.waitForSelectorContaining("H3", "Person").click(); - qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People") - .findElement(By.cssSelector("CIRCLE")); - qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "2"); + queryScreenLib.clickQuickFilterButton("lastName"); + WebElement valueInput = qSeleniumLib.waitForSelector(".filterValuesColumn INPUT"); + valueInput.click(); + valueInput.sendKeys("Kelkhoff"); + qSeleniumLib.waitForMillis(100); + + qSeleniumLib.clickBackdrop(); + qSeleniumLib.waitForSelectorContaining("DIV", "Unsaved Changes"); ////////////////////////////// // click into a view screen // @@ -127,16 +133,15 @@ public class SavedViewsTest extends QBaseSeleniumTest qSeleniumJavalin.beginCapture(); qSeleniumLib.waitForSelectorContaining("A", "Person").click(); qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2")); - qSeleniumLib.waitForSelectorContaining("DIV", "Current View: Some People") - .findElement(By.cssSelector("CIRCLE")); - qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "2"); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Some People"); + qSeleniumLib.waitForSelectorContaining("DIV", "Unsaved Changes"); CapturedContext capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); - assertTrue(capturedContext.getBody().contains("Jam")); + assertTrue(capturedContext.getBody().contains("Kelkhoff")); qSeleniumJavalin.endCapture(); - //////////////////////////////////////////////////// + ////////////////////////////////////////////////// // navigate to the table with a View in the URL // - //////////////////////////////////////////////////// + ////////////////////////////////////////////////// String filter = """ { "criteria": @@ -149,9 +154,8 @@ public class SavedViewsTest extends QBaseSeleniumTest ] } """.replace('\n', ' ').replaceAll(" ", ""); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8), "Person"); - qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1"); - qSeleniumLib.waitForSelectorContainingToNotExist("DIV", "Current View"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Save View As"); ////////////////////////////// // click into a view screen // @@ -166,11 +170,10 @@ public class SavedViewsTest extends QBaseSeleniumTest qSeleniumJavalin.beginCapture(); qSeleniumLib.waitForSelectorContaining("A", "Person").click(); qSeleniumLib.waitForCondition("Current URL should not have filter id", () -> !driver.getCurrentUrl().endsWith("/person/savedView/2")); - qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1"); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Save View As"); capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); assertTrue(capturedContext.getBody().matches("(?s).*id.*LESS_THAN.*10.*")); qSeleniumJavalin.endCapture(); - */ } } diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ScriptTableTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ScriptTableTest.java index e935d45..39f7062 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ScriptTableTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ScriptTableTest.java @@ -59,7 +59,7 @@ public class ScriptTableTest extends QBaseSeleniumTest @Test void test() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/developer/script/1", "Hello, Script"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/developer/script/1", "Hello, Script"); qSeleniumLib.waitForSelectorContaining("DIV.ace_line", "var hello;"); qSeleniumLib.waitForSelectorContaining("DIV", "2nd commit"); From fc238127a7ce4fccbebfe7917d247293491979e7 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 7 Feb 2024 09:28:33 -0600 Subject: [PATCH 36/40] CE-798 z-index fun to fix sticky-header bleed-through --- src/qqq/components/query/FieldListMenu.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/qqq/components/query/FieldListMenu.tsx b/src/qqq/components/query/FieldListMenu.tsx index b277aa2..2436d57 100644 --- a/src/qqq/components/query/FieldListMenu.tsx +++ b/src/qqq/components/query/FieldListMenu.tsx @@ -561,6 +561,14 @@ export default function FieldListMenu({idPrefix, heading, placeholder, tableMeta const textFieldId = `field-list-dropdown-${idPrefix}-textField`; let listItemPadding = isModeToggle ? "0.125rem": "0.5rem"; + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // for z-indexes, we set each table header to i+1, then the fields in that table to i (so they go behind it) // + // then we increment i by 2 for the next table (so the next header goes above the previous header) // + // this fixes a thing where, if one table's name wrapped to 2 lines, then when the next table below it would // + // come up, if it was only 1 line, then the second line from the previous one would bleed through. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + let zIndex = 1; + return ( <> { - hasValidFilters && setShowClearFiltersWarning(true)} /> + hasValidFilters && mouseOverElement == "filterBuilderButton" && setShowClearFiltersWarning(true)} /> } @@ -738,14 +766,15 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo { {queryToAdvancedString()} diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index 2e88ea2..e170d00 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -53,7 +53,7 @@ export enum ValueMode PVS_MULTI = "PVS_MULTI", } -const getValueModeRequiredCount = (valueMode: ValueMode): number => +export const getValueModeRequiredCount = (valueMode: ValueMode): number => { switch (valueMode) { diff --git a/src/qqq/components/query/QuickFilter.tsx b/src/qqq/components/query/QuickFilter.tsx index ae88728..f881f56 100644 --- a/src/qqq/components/query/QuickFilter.tsx +++ b/src/qqq/components/query/QuickFilter.tsx @@ -24,18 +24,16 @@ import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QField import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; -import {Badge, Tooltip} from "@mui/material"; +import {Tooltip} from "@mui/material"; import Autocomplete from "@mui/material/Autocomplete"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Icon from "@mui/material/Icon"; -import IconButton from "@mui/material/IconButton"; import Menu from "@mui/material/Menu"; import TextField from "@mui/material/TextField"; import React, {SyntheticEvent, useContext, useState} from "react"; import QContext from "QContext"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; -import {getDefaultCriteriaValue, getOperatorOptions, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow"; +import {getDefaultCriteriaValue, getOperatorOptions, getValueModeRequiredCount, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; import XIcon from "qqq/components/query/XIcon"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; @@ -71,8 +69,7 @@ export const quickFilterButtonStyles = { border: "1px solid #757575", minWidth: "3.5rem", minHeight: "auto", - padding: "0.375rem 0.625rem", - whiteSpace: "nowrap", + padding: "0.375rem 0.625rem", whiteSpace: "nowrap", marginBottom: "0.5rem" } @@ -149,6 +146,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData const [isOpen, setIsOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); + const [isMouseOver, setIsMouseOver] = useState(false); const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? criteriaParam as QFilterCriteriaWithId : null); const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId); @@ -156,13 +154,29 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator)); const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label); - const [startIconName, setStartIconName] = useState("filter_alt"); - const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue); const {accentColor} = useContext(QContext); + /******************************************************************************* + ** + *******************************************************************************/ + function handleMouseOverElement() + { + setIsMouseOver(true); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function handleMouseOutElement() + { + setIsMouseOver(false); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// // handle a change to the criteria from outside this component (e.g., the prop isn't the same as the state) // ////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -171,7 +185,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData const newCriteria = criteriaParam as QFilterCriteriaWithId; setCriteria(newCriteria); const operatorOption = operatorOptions.filter(o => o.value == newCriteria.operator)[0]; - console.log(`B: setOperatorSelectedValue [${JSON.stringify(operatorOption)}]`); setOperatorSelectedValue(operatorOption); setOperatorInputValue(operatorOption.label); } @@ -202,7 +215,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData const operatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0]; const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption?.value, getDefaultCriteriaValue()); criteria.id = id; - console.log(`C: setOperatorSelectedValue [${JSON.stringify(operatorOption)}]`); setOperatorSelectedValue(operatorOption); setOperatorInputValue(operatorOption?.label); setCriteria(criteria); @@ -216,6 +228,12 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData { setIsOpen(!isOpen); setAnchorEl(event.currentTarget); + + setTimeout(() => + { + const element = document.getElementById("value-" + criteria.id); + element?.focus(); + }) }; /******************************************************************************* @@ -236,7 +254,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData if (newValue) { - console.log(`D: setOperatorSelectedValue [${JSON.stringify(newValue)}]`); setOperatorSelectedValue(newValue); setOperatorInputValue(newValue.label); @@ -244,10 +261,27 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData { criteria.values = newValue.implicitValues; } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // we've seen cases where switching operators can sometimes put a null in as the first value... // + // that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. // + ////////////////////////////////////////////////////////////////////////////////////////////////// + if(criteria.values && criteria.values.length == 1 && criteria.values[0] == null) + { + criteria.values = []; + } + + if(newValue.valueMode) + { + const requiredValueCount = getValueModeRequiredCount(newValue.valueMode); + if(requiredValueCount != null && criteria.values.length > requiredValueCount) + { + criteria.values.splice(requiredValueCount); + } + } } else { - console.log("E: setOperatorSelectedValue [null]"); setOperatorSelectedValue(null); setOperatorInputValue(""); } @@ -307,30 +341,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData e.stopPropagation(); const newCriteria = makeNewCriteria(); updateCriteria(newCriteria, false, true); - setStartIconName("filter_alt"); } } - /******************************************************************************* - ** event handler for mouse-over on the filter icon - that changes to an 'x' - ** if there's a valid criteria in the quick-filter - *******************************************************************************/ - const startIconMouseOver = () => - { - if(criteriaIsValid) - { - setStartIconName("clear"); - } - } - - /******************************************************************************* - ** event handler for mouse-out on the filter icon - always resets it. - *******************************************************************************/ - const startIconMouseOut = () => - { - setStartIconName("filter_alt"); - } - /******************************************************************************* ** event handler for clicking the (x) icon that turns off this quick filter field. ** hands off control to the function that was passed in (e.g., from RecordQueryOrig). @@ -359,7 +372,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator); if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue)) { - console.log(`A: setOperatorSelectedValue [${JSON.stringify(maybeNewOperatorSelectedValue)}]`); setOperatorSelectedValue(maybeNewOperatorSelectedValue) setOperatorInputValue(maybeNewOperatorSelectedValue?.label) } @@ -377,11 +389,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData ///////////////////////// const tooComplex = criteriaParam == "tooComplex"; const tooltipEnterDelay = 500; - let startIcon = {startIconName} - if(criteriaIsValid) - { - startIcon = {startIcon} - } let buttonAdditionalStyles: any = {}; let buttonContent = {tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label} @@ -402,16 +409,19 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData valuesString = ""; } - buttonContent = ( - - {buttonContent} - - ); + buttonContent = (<>{buttonContent}: {operatorSelectedValue.label} {valuesString}); } + const mouseEvents = + { + onMouseOver: () => handleMouseOverElement(), + onMouseOut: () => handleMouseOutElement() + }; + let button = fieldMetaData &&