diff --git a/src/qqq/components/forms/DynamicSelect.tsx b/src/qqq/components/forms/DynamicSelect.tsx index 46ee247..ba22d4b 100644 --- a/src/qqq/components/forms/DynamicSelect.tsx +++ b/src/qqq/components/forms/DynamicSelect.tsx @@ -38,6 +38,7 @@ interface Props tableName?: string; processName?: string; fieldName: string; + overrideId?: string; fieldLabel: string; inForm: boolean; initialValue?: any; @@ -70,29 +71,34 @@ DynamicSelect.defaultProps = { const qController = Client.getInstance(); -function DynamicSelect({tableName, processName, fieldName, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues}: Props) +function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues}: Props) { - const [ open, setOpen ] = useState(false); - const [ options, setOptions ] = useState([]); - const [ searchTerm, setSearchTerm ] = useState(null); - const [ firstRender, setFirstRender ] = useState(true); + const [open, setOpen] = useState(false); + const [options, setOptions] = useState([]); + const [searchTerm, setSearchTerm] = useState(null); + const [firstRender, setFirstRender] = useState(true); //////////////////////////////////////////////////////////////////////////////////////////////// // default value - needs to be an array (from initialValues (array) prop) for multiple mode - // // else non-multiple, assume we took in an initialValue (id) and initialDisplayValue (label), // // and build a little object that looks like a possibleValue out of those // //////////////////////////////////////////////////////////////////////////////////////////////// - const [defaultValue, _] = isMultiple ? useState(initialValues ?? undefined) + let [defaultValue, _] = isMultiple ? useState(initialValues ?? undefined) : useState(initialValue && initialDisplayValue ? [{id: initialValue, label: initialDisplayValue}] : null); + if (isMultiple && defaultValue === null) + { + defaultValue = []; + } + // const loading = open && options.length === 0; const [loading, setLoading] = useState(false); - const [ switchChecked, setSwitchChecked ] = useState(false); - const [ isDisabled, setIsDisabled ] = useState(!isEditable || bulkEditMode); + const [switchChecked, setSwitchChecked] = useState(false); + const [isDisabled, setIsDisabled] = useState(!isEditable || bulkEditMode); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); let setFieldValueRef: (field: string, value: any, shouldValidate?: boolean) => void = null; - if(inForm) + if (inForm) { const {setFieldValue} = useFormikContext(); setFieldValueRef = setFieldValue; @@ -239,9 +245,11 @@ function DynamicSelect({tableName, processName, fieldName, fieldLabel, inForm, i bulkEditSwitchChangeHandler(fieldName, newSwitchValue); }; + // console.log(`default value: ${JSON.stringify(defaultValue)}`); + const autocomplete = ( ( . */ +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {Box, FormControlLabel, FormGroup} from "@mui/material"; import Button from "@mui/material/Button"; @@ -37,6 +38,7 @@ declare module "@mui/x-data-grid" interface ColumnsPanelPropsOverrides { tableMetaData: QTableMetaData; + metaData: QInstance; initialOpenedGroups: { [name: string]: boolean }; openGroupsChanger: (openedGroups: { [name: string]: boolean }) => void; initialFilterText: string; @@ -70,7 +72,11 @@ export const CustomColumnsPanel = forwardRef( { for (let i = 0; i < props.tableMetaData.exposedJoins.length; i++) { - tables.push(props.tableMetaData.exposedJoins[i].joinTable); + const exposedJoin = props.tableMetaData.exposedJoins[i]; + if (props.metaData.tables.has(exposedJoin.joinTable.name)) + { + tables.push(exposedJoin.joinTable); + } } } @@ -112,7 +118,7 @@ export const CustomColumnsPanel = forwardRef( return (true); } } - catch(e) + catch (e) { ////////////////////////////////////////////////////////////////////////////////// // in case text is an invalid regex... well, at least do a starts-with match... // @@ -123,6 +129,33 @@ export const CustomColumnsPanel = forwardRef( } } + const tableLabel = column.headerName.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" + filterText.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(filterText.toLowerCase())) + { + return (true); + } + } + } + return (false); }; diff --git a/src/qqq/components/query/CustomFilterPanel.tsx b/src/qqq/components/query/CustomFilterPanel.tsx new file mode 100644 index 0000000..03dfee3 --- /dev/null +++ b/src/qqq/components/query/CustomFilterPanel.tsx @@ -0,0 +1,192 @@ +/* + * 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 {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +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 {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button/Button"; +import Icon from "@mui/material/Icon/Icon"; +import {GridFilterPanelProps, GridSlotsComponentsProps} from "@mui/x-data-grid-pro"; +import React, {forwardRef, useReducer} from "react"; +import {FilterCriteriaRow, getDefaultCriteriaValue} from "qqq/components/query/FilterCriteriaRow"; + + +declare module "@mui/x-data-grid" +{ + /////////////////////////////////////////////////////////////////////// + // this lets these props be passed in via // + /////////////////////////////////////////////////////////////////////// + interface FilterPanelPropsOverrides + { + tableMetaData: QTableMetaData; + metaData: QInstance; + queryFilter: QQueryFilter; + updateFilter: (newFilter: QQueryFilter) => void; + } +} + + +export class QFilterCriteriaWithId extends QFilterCriteria +{ + id: number +} + + +let debounceTimeout: string | number | NodeJS.Timeout; +let criteriaId = (new Date().getTime()) + 1000; + +export const CustomFilterPanel = forwardRef( + function MyCustomFilterPanel(props: GridSlotsComponentsProps["filterPanel"], ref) + { + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + const queryFilter = props.queryFilter; + // console.log(`CustomFilterPanel: filter: ${JSON.stringify(queryFilter)}`); + + function focusLastField() + { + setTimeout(() => + { + try + { + // console.log(`Try to focus ${criteriaId - 1}`); + document.getElementById(`field-${criteriaId - 1}`).focus(); + } + catch (e) + { + console.log("Error trying to focus field ...", e); + } + }); + } + + const addCriteria = () => + { + const qFilterCriteriaWithId = new QFilterCriteriaWithId(null, QCriteriaOperator.EQUALS, getDefaultCriteriaValue()); + qFilterCriteriaWithId.id = criteriaId++; + console.log(`adding criteria id ${qFilterCriteriaWithId.id}`); + queryFilter.criteria.push(qFilterCriteriaWithId); + props.updateFilter(queryFilter); + forceUpdate(); + + focusLastField(); + }; + + if (!queryFilter.criteria) + { + queryFilter.criteria = []; + addCriteria(); + } + + if (queryFilter.criteria.length == 0) + { + ///////////////////////////////////////////// + // make sure there's at least one criteria // + ///////////////////////////////////////////// + addCriteria(); + } + else + { + //////////////////////////////////////////////////////////////////////////////////// + // make sure all criteria have an id on them (to be used as react component keys) // + //////////////////////////////////////////////////////////////////////////////////// + let updatedAny = false; + for (let i = 0; i < queryFilter.criteria.length; i++) + { + if (!queryFilter.criteria[i].id) + { + queryFilter.criteria[i].id = criteriaId++; + } + } + if (updatedAny) + { + props.updateFilter(queryFilter); + } + } + + if(queryFilter.criteria.length == 1 && !queryFilter.criteria[0].fieldName) + { + focusLastField(); + } + + let booleanOperator: "AND" | "OR" | null = null; + if (queryFilter.criteria.length > 1) + { + booleanOperator = queryFilter.booleanOperator; + } + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // needDebounce param - things like typing in a text field DO need debounce, but changing an operator doesn't // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const updateCriteria = (newCriteria: QFilterCriteria, index: number, needDebounce = false) => + { + queryFilter.criteria[index] = newCriteria; + + clearTimeout(debounceTimeout) + debounceTimeout = setTimeout(() => props.updateFilter(queryFilter), needDebounce ? 500 : 1); + + forceUpdate(); + }; + + const updateBooleanOperator = (newValue: string) => + { + queryFilter.booleanOperator = newValue; + props.updateFilter(queryFilter); + forceUpdate(); + }; + + const removeCriteria = (index: number) => + { + queryFilter.criteria.splice(index, 1); + props.updateFilter(queryFilter); + forceUpdate(); + }; + + return ( + + { + queryFilter.criteria.map((criteria: QFilterCriteriaWithId, index: number) => + ( + + updateCriteria(newCriteria, index, needDebounce)} + removeCriteria={() => removeCriteria(index)} + updateBooleanOperator={(newValue) => updateBooleanOperator(newValue)} + /> + {/*JSON.stringify(criteria)*/} + + )) + } + + + + + ); + } +); diff --git a/src/qqq/components/query/FilterCriteriaPaster.tsx b/src/qqq/components/query/FilterCriteriaPaster.tsx new file mode 100644 index 0000000..482332e --- /dev/null +++ b/src/qqq/components/query/FilterCriteriaPaster.tsx @@ -0,0 +1,418 @@ +/* + * 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 {FormControl, InputLabel, Select, SelectChangeEvent} from "@mui/material"; +import Box from "@mui/material/Box"; +import Card from "@mui/material/Card"; +import Grid from "@mui/material/Grid"; +import Icon from "@mui/material/Icon"; +import Modal from "@mui/material/Modal"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import {GridFilterItem} from "@mui/x-data-grid-pro"; +import React, {useEffect, useState} from "react"; +import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import ChipTextField from "qqq/components/forms/ChipTextField"; + +interface Props +{ + type: string; + onSave: (newValues: any[]) => void; +} + +FilterCriteriaPaster.defaultProps = {}; + +function FilterCriteriaPaster({type, onSave}: Props): JSX.Element +{ + enum Delimiter + { + DETECT_AUTOMATICALLY = "Detect Automatically", + COMMA = "Comma", + NEWLINE = "Newline", + PIPE = "Pipe", + SPACE = "Space", + TAB = "Tab", + CUSTOM = "Custom", + } + + const delimiterToCharacterMap: { [key: string]: string } = {}; + + delimiterToCharacterMap[Delimiter.COMMA] = "[,\n\r]"; + delimiterToCharacterMap[Delimiter.TAB] = "[\t,\n,\r]"; + delimiterToCharacterMap[Delimiter.NEWLINE] = "[\n\r]"; + delimiterToCharacterMap[Delimiter.PIPE] = "[\\|\r\n]"; + delimiterToCharacterMap[Delimiter.SPACE] = "[ \n\r]"; + + const delimiterDropdownOptions = Object.values(Delimiter); + + const mainCardStyles: any = {}; + mainCardStyles.width = "60%"; + mainCardStyles.minWidth = "500px"; + + //x const [gridFilterItem, setGridFilterItem] = useState(props.item); + const [pasteModalIsOpen, setPasteModalIsOpen] = useState(false); + const [inputText, setInputText] = useState(""); + const [delimiter, setDelimiter] = useState(""); + const [delimiterCharacter, setDelimiterCharacter] = useState(""); + const [customDelimiterValue, setCustomDelimiterValue] = useState(""); + const [chipData, setChipData] = useState(undefined); + const [detectedText, setDetectedText] = useState(""); + const [errorText, setErrorText] = useState(""); + + ////////////////////////////////////////////////////////////// + // handler for when paste icon is clicked in 'any' operator // + ////////////////////////////////////////////////////////////// + const handlePasteClick = (event: any) => + { + event.target.blur(); + setPasteModalIsOpen(true); + }; + + const clearData = () => + { + setDelimiter(""); + setDelimiterCharacter(""); + setChipData([]); + setInputText(""); + setDetectedText(""); + setCustomDelimiterValue(""); + setPasteModalIsOpen(false); + }; + + const handleCancelClicked = () => + { + clearData(); + setPasteModalIsOpen(false); + }; + + const handleSaveClicked = () => + { + //////////////////////////////////////// + // if numeric remove any non-numerics // + //////////////////////////////////////// + let saveData = []; + for (let i = 0; i < chipData.length; i++) + { + if (type !== "number" || !Number.isNaN(Number(chipData[i]))) + { + saveData.push(chipData[i]); + } + } + + onSave(saveData); + + clearData(); + setPasteModalIsOpen(false); + }; + + //////////////////////////////////////////////////////////////// + // when user selects a different delimiter on the parse modal // + //////////////////////////////////////////////////////////////// + const handleDelimiterChange = (event: SelectChangeEvent) => + { + const newDelimiter = event.target.value; + console.log(`Delimiter Changed to ${JSON.stringify(newDelimiter)}`); + + setDelimiter(newDelimiter); + if (newDelimiter === Delimiter.CUSTOM) + { + setDelimiterCharacter(customDelimiterValue); + } + else + { + setDelimiterCharacter(delimiterToCharacterMap[newDelimiter]); + } + }; + + const handleTextChange = (event: any) => + { + const inputText = event.target.value; + setInputText(inputText); + }; + + const handleCustomDelimiterChange = (event: any) => + { + let inputText = event.target.value; + setCustomDelimiterValue(inputText); + }; + + /////////////////////////////////////////////////////////////////////////////////////// + // iterate over each character, putting them into 'buckets' so that we can determine // + // a good default to use when data is pasted into the textarea // + /////////////////////////////////////////////////////////////////////////////////////// + const calculateAutomaticDelimiter = (text: string): string => + { + const buckets = new Map(); + for (let i = 0; i < text.length; i++) + { + let bucketName = ""; + + switch (text.charAt(i)) + { + case "\t": + bucketName = Delimiter.TAB; + break; + case "\n": + case "\r": + bucketName = Delimiter.NEWLINE; + break; + case "|": + bucketName = Delimiter.PIPE; + break; + case " ": + bucketName = Delimiter.SPACE; + break; + case ",": + bucketName = Delimiter.COMMA; + break; + } + + if (bucketName !== "") + { + let currentCount = (buckets.has(bucketName)) ? buckets.get(bucketName) : 0; + buckets.set(bucketName, currentCount + 1); + } + } + + /////////////////////// + // default is commas // + /////////////////////// + let highestCount = 0; + let delimiter = Delimiter.COMMA; + for (let j = 0; j < delimiterDropdownOptions.length; j++) + { + let bucketName = delimiterDropdownOptions[j]; + if (buckets.has(bucketName) && buckets.get(bucketName) > highestCount) + { + delimiter = bucketName; + highestCount = buckets.get(bucketName); + } + } + + setDetectedText(`${delimiter} Detected`); + return (delimiterToCharacterMap[delimiter]); + }; + + useEffect(() => + { + let currentDelimiter = delimiter; + let currentDelimiterCharacter = delimiterCharacter; + + ///////////////////////////////////////////////////////////////////////////// + // if no delimiter already set in the state, call function to determine it // + ///////////////////////////////////////////////////////////////////////////// + if (!currentDelimiter || currentDelimiter === Delimiter.DETECT_AUTOMATICALLY) + { + currentDelimiterCharacter = calculateAutomaticDelimiter(inputText); + if (!currentDelimiterCharacter) + { + return; + } + + currentDelimiter = Delimiter.DETECT_AUTOMATICALLY; + setDelimiter(Delimiter.DETECT_AUTOMATICALLY); + setDelimiterCharacter(currentDelimiterCharacter); + } + else if (currentDelimiter === Delimiter.CUSTOM) + { + //////////////////////////////////////////////////// + // if custom, make sure to split on new lines too // + //////////////////////////////////////////////////// + currentDelimiterCharacter = `[${customDelimiterValue}\r\n]`; + } + + console.log(`current delimiter is: ${currentDelimiter}, delimiting on: ${currentDelimiterCharacter}`); + + let regex = new RegExp(currentDelimiterCharacter); + let parts = inputText.split(regex); + let chipData = [] as string[]; + + /////////////////////////////////////////////////////// + // if delimiter is empty string, dont split anything // + /////////////////////////////////////////////////////// + setErrorText(""); + if (currentDelimiterCharacter !== "") + { + for (let i = 0; i < parts.length; i++) + { + let part = parts[i].trim(); + if (part !== "") + { + chipData.push(part); + + /////////////////////////////////////////////////////////// + // if numeric, check that first before pushing as a chip // + /////////////////////////////////////////////////////////// + if (type === "number" && Number.isNaN(Number(part))) + { + setErrorText("Some values are not numbers"); + } + } + } + } + + setChipData(chipData); + + }, [inputText, delimiterCharacter, customDelimiterValue, detectedText]); + + return ( + + + paste_content + + { + pasteModalIsOpen && + ( + + + + + + + + Bulk Add Filter Values + + Paste into the box on the left. + Review the filter values in the box on the right. + If the filter values are not what are expected, try changing the separator using the dropdown below. + + + + + + + + + + + + + + { + }} + chipData={chipData} + chipType={type} + multiline + fullWidth + variant="outlined" + id="tags" + rows={0} + name="tags" + label="FILTER VALUES REVIEW" + /> + + + + + + + + + SEPARATOR + + + + {delimiter === Delimiter.CUSTOM.valueOf() && ( + + + + + )} + {inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && ( + + + {detectedText} + + )} + + + + { + errorText && chipData.length > 0 && ( + + error + {errorText} + + ) + } + + + { + chipData && chipData.length > 0 && ( + {chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"} + ) + } + + + + + + + + + + + + + + ) + } + + ); +} + +export default FilterCriteriaPaster; diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx new file mode 100644 index 0000000..c93142d --- /dev/null +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -0,0 +1,550 @@ +/* + * 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 {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 {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 Box from "@mui/material/Box"; +import FormControl from "@mui/material/FormControl/FormControl"; +import Icon from "@mui/material/Icon/Icon"; +import IconButton from "@mui/material/IconButton"; +import MenuItem from "@mui/material/MenuItem"; +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 FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; + + +export enum ValueMode +{ + NONE = "NONE", + SINGLE = "SINGLE", + DOUBLE = "DOUBLE", + MULTI = "MULTI", + SINGLE_DATE = "SINGLE_DATE", + SINGLE_DATE_TIME = "SINGLE_DATE_TIME", + PVS_SINGLE = "PVS_SINGLE", + PVS_MULTI = "PVS_MULTI", +} + +export interface OperatorOption +{ + label: string; + value: QCriteriaOperator; + implicitValues?: [any]; + valueMode: ValueMode; +} + +export const getDefaultCriteriaValue = () => [""]; + +interface FilterCriteriaRowProps +{ + id: number; + index: number; + tableMetaData: QTableMetaData; + metaData: QInstance; + criteria: QFilterCriteria; + booleanOperator: "AND" | "OR" | null; + updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean) => void; + removeCriteria: () => void; + 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++) + { + const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name; + fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName}); + } +} + +export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator}: FilterCriteriaRowProps): JSX.Element +{ + // console.log(`FilterCriteriaRow: criteria: ${JSON.stringify(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 // + //////////////////////////////////////////////////////////// + let operatorOptions: OperatorOption[] = []; + + function setOperatorOptions(fieldName: string) + { + const [field, fieldTable] = FilterUtils.getField(tableMetaData, fieldName); + operatorOptions = []; + if (field && fieldTable) + { + ////////////////////////////////////////////////////// + // setup array of options for operator Autocomplete // + ////////////////////////////////////////////////////// + if (field.possibleValueSourceName) + { + operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.PVS_SINGLE}); + operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.PVS_SINGLE}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is any of", value: QCriteriaOperator.IN, valueMode: ValueMode.PVS_MULTI}); + operatorOptions.push({label: "is none of", value: QCriteriaOperator.NOT_IN, valueMode: ValueMode.PVS_MULTI}); + } + else + { + switch (field.type) + { + case QFieldType.DECIMAL: + case QFieldType.INTEGER: + operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "greater than", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "greater than or equals", value: QCriteriaOperator.GREATER_THAN_OR_EQUALS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "less than", value: QCriteriaOperator.LESS_THAN, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "less than or equals", value: QCriteriaOperator.LESS_THAN_OR_EQUALS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is between", value: QCriteriaOperator.BETWEEN, valueMode: ValueMode.DOUBLE}); + operatorOptions.push({label: "is not between", value: QCriteriaOperator.NOT_BETWEEN, valueMode: ValueMode.DOUBLE}); + operatorOptions.push({label: "is any of", value: QCriteriaOperator.IN, valueMode: ValueMode.MULTI}); + operatorOptions.push({label: "is none of", value: QCriteriaOperator.NOT_IN, valueMode: ValueMode.MULTI}); + break; + case QFieldType.DATE: + operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE_DATE}); + operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE_DATE}); + operatorOptions.push({label: "is after", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE_DATE}); + operatorOptions.push({label: "is before", value: QCriteriaOperator.LESS_THAN, valueMode: ValueMode.SINGLE_DATE}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + //? operatorOptions.push({label: "is between", value: QCriteriaOperator.BETWEEN}); + //? operatorOptions.push({label: "is not between", value: QCriteriaOperator.NOT_BETWEEN}); + //? operatorOptions.push({label: "is any of", value: QCriteriaOperator.IN}); + //? operatorOptions.push({label: "is none of", value: QCriteriaOperator.NOT_IN}); + break; + case QFieldType.DATE_TIME: + operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "is after", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "is on or after", value: QCriteriaOperator.GREATER_THAN_OR_EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "is before", value: QCriteriaOperator.LESS_THAN, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "is on or before", value: QCriteriaOperator.LESS_THAN_OR_EQUALS, valueMode: ValueMode.SINGLE_DATE_TIME}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + //? operatorOptions.push({label: "is between", value: QCriteriaOperator.BETWEEN}); + //? operatorOptions.push({label: "is not between", value: QCriteriaOperator.NOT_BETWEEN}); + break; + case QFieldType.BOOLEAN: + operatorOptions.push({label: "equals yes", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.NONE, implicitValues: [true]}); + operatorOptions.push({label: "equals no", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.NONE, implicitValues: [false]}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + /* + ? is yes or empty (is not no) + ? is no or empty (is not yes) + */ + break; + case QFieldType.BLOB: + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + break; + default: + operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "contains ", value: QCriteriaOperator.CONTAINS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "does not contain", value: QCriteriaOperator.NOT_CONTAINS, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "starts with", value: QCriteriaOperator.STARTS_WITH, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "does not start with", value: QCriteriaOperator.NOT_STARTS_WITH, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "ends with", value: QCriteriaOperator.ENDS_WITH, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "does not end with", value: QCriteriaOperator.NOT_ENDS_WITH, valueMode: ValueMode.SINGLE}); + operatorOptions.push({label: "is empty", value: QCriteriaOperator.IS_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is not empty", value: QCriteriaOperator.IS_NOT_BLANK, valueMode: ValueMode.NONE}); + operatorOptions.push({label: "is any of", value: QCriteriaOperator.IN, valueMode: ValueMode.MULTI}); + operatorOptions.push({label: "is none of", value: QCriteriaOperator.NOT_IN, valueMode: ValueMode.MULTI}); + } + } + } + } + + //////////////////////////////////////////////////////////////// + // make currently selected values appear in the Autocompletes // + //////////////////////////////////////////////////////////////// + let defaultFieldValue; + let field = null; + let fieldTable = null; + if(criteria && criteria.fieldName) + { + [field, fieldTable] = FilterUtils.getField(tableMetaData, criteria.fieldName); + if (field && fieldTable) + { + if (fieldTable.name == tableMetaData.name) + { + // @ts-ignore + defaultFieldValue = {field: field, table: tableMetaData, fieldName: criteria.fieldName}; + } + else + { + defaultFieldValue = {field: field, table: fieldTable, fieldName: criteria.fieldName}; + } + + setOperatorOptions(criteria.fieldName); + + + let newOperatorSelectedValue = operatorOptions.filter(option => + { + if(option.value == criteria.operator) + { + if(option.implicitValues) + { + return (JSON.stringify(option.implicitValues) == JSON.stringify(criteria.values)); + } + else + { + return (true); + } + } + return (false); + })[0]; + if(newOperatorSelectedValue?.label !== operatorSelectedValue?.label) + { + setOperatorSelectedValue(newOperatorSelectedValue); + setOperatorInputValue(newOperatorSelectedValue?.label); + } + } + } + + ////////////////////////////////////////////// + // event handler for booleanOperator Select // + ////////////////////////////////////////////// + const handleBooleanOperatorChange = (event: SelectChangeEvent<"AND" | "OR">, child: ReactNode) => + { + updateBooleanOperator(event.target.value); + }; + + ////////////////////////////////////////// + // event handler for field Autocomplete // + ////////////////////////////////////////// + const handleFieldChange = (event: any, newValue: any, reason: string) => + { + const oldFieldName = criteria.fieldName; + + criteria.fieldName = newValue ? newValue.fieldName : null; + + ////////////////////////////////////////////////////// + // decide if we should clear out the values or not. // + ////////////////////////////////////////////////////// + if (criteria.fieldName == null || isFieldTypeDifferent(oldFieldName, criteria.fieldName)) + { + criteria.values = getDefaultCriteriaValue(); + } + + //////////////////////////////////////////////////////////////////// + // update the operator options, and the operator on this criteria // + //////////////////////////////////////////////////////////////////// + setOperatorOptions(criteria.fieldName); + if (operatorOptions.length) + { + if (isFieldTypeDifferent(oldFieldName, criteria.fieldName)) + { + criteria.operator = operatorOptions[0].value; + setOperatorSelectedValue(operatorOptions[0]); + setOperatorInputValue(operatorOptions[0].label); + } + } + else + { + criteria.operator = null; + setOperatorSelectedValue(null); + setOperatorInputValue(""); + } + + updateCriteria(criteria, false); + }; + + ///////////////////////////////////////////// + // event handler for operator Autocomplete // + ///////////////////////////////////////////// + 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); + }; + + ////////////////////////////////////////////////// + // event handler for value field (of all types) // + ////////////////////////////////////////////////// + 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); + }; + + const isFieldTypeDifferent = (fieldNameA: string, fieldNameB: string): boolean => + { + const [fieldA] = FilterUtils.getField(tableMetaData, fieldNameA); + const [fieldB] = FilterUtils.getField(tableMetaData, fieldNameB); + if (fieldA?.type !== fieldB.type) + { + return (true); + } + if (fieldA.possibleValueSourceName !== fieldB.possibleValueSourceName) + { + return (true); + } + + 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."; + + 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) + { + if(criteria.values.length < 2) + { + 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(isNotSet(criteria.values[0])) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter a value to complete the definition of this condition."; + } + } + } + } + + return ( + + + + close + + + + {booleanOperator && index > 0 ? + + + + : } + + + ()} + // @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: {style: {padding: 0, width: "250px"}}}} + /> + + + + ()} + 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} + slotProps={{popper: {style: {padding: 0, maxHeight: "unset", width: "200px"}}}} + /*disabled={criteria.fieldName == null}*/ + /> + + + + handleValueChange(event, valueIndex, newValue)} + /> + + + + { + criteriaIsValid + ? check + : pending + } + + + + ); +} diff --git a/src/qqq/components/query/FilterCriteriaRowValues.tsx b/src/qqq/components/query/FilterCriteriaRowValues.tsx new file mode 100644 index 0000000..c8324cb --- /dev/null +++ b/src/qqq/components/query/FilterCriteriaRowValues.tsx @@ -0,0 +1,242 @@ +/* + * 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 {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment/InputAdornment"; +import TextField from "@mui/material/TextField"; +import React, {SyntheticEvent, useReducer} from "react"; +import DynamicSelect from "qqq/components/forms/DynamicSelect"; +import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; +import FilterCriteriaPaster from "qqq/components/query/FilterCriteriaPaster"; +import {OperatorOption, ValueMode} from "qqq/components/query/FilterCriteriaRow"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +interface Props +{ + operatorOption: OperatorOption; + criteria: QFilterCriteriaWithId; + field: QFieldMetaData; + table: QTableMetaData; + valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void; +} + +FilterCriteriaRowValues.defaultProps = { +}; + +function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler}: Props): JSX.Element +{ + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + if (!operatorOption) + { + return
    ; + } + + const getTypeForTextField = (): string => + { + let type = "search"; + + if (field.type == QFieldType.INTEGER) + { + type = "number"; + } + else if (field.type == QFieldType.DATE) + { + type = "date"; + } + else if (field.type == QFieldType.DATE_TIME) + { + type = "datetime-local"; + } + + return (type); + }; + + const makeTextField = (valueIndex: number = 0, label = "Value", idPrefix = "value-") => + { + let type = getTypeForTextField(); + const inputLabelProps: any = {}; + + if (field.type == QFieldType.DATE || field.type == QFieldType.DATE_TIME) + { + inputLabelProps.shrink = true; + } + + let value = criteria.values[valueIndex]; + if (field.type == QFieldType.DATE_TIME && value && String(value).indexOf("Z") > -1) + { + value = ValueUtils.formatDateTimeValueForForm(value); + } + + const clearValue = (event: React.MouseEvent | React.MouseEvent, index: number) => + { + valueChangeHandler(event, index, ""); + document.getElementById(`${idPrefix}${criteria.id}`).focus(); + }; + + const inputProps: any = {}; + inputProps.endAdornment = ( + + clearValue(event, valueIndex)}> + close + + + ); + + return valueChangeHandler(event, valueIndex)} + value={value} + InputLabelProps={inputLabelProps} + InputProps={inputProps} + fullWidth + />; + }; + + function saveNewPasterValues(newValues: any[]) + { + if (criteria.values) + { + criteria.values = [...criteria.values, ...newValues]; + } + else + { + criteria.values = newValues; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // we are somehow getting some empty-strings as first-value leaking through. they aren't cool, so, remove them if we find them // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (criteria.values.length > 0 && criteria.values[0] == "") + { + criteria.values = criteria.values.splice(1); + } + + valueChangeHandler(null, "all", criteria.values); + forceUpdate(); + } + + switch (operatorOption.valueMode) + { + case ValueMode.NONE: + return
    ; + case ValueMode.SINGLE: + return makeTextField(); + case ValueMode.SINGLE_DATE: + return makeTextField(); + case ValueMode.SINGLE_DATE_TIME: + return makeTextField(); + case ValueMode.DOUBLE: + return + + { makeTextField(0, "From", "from-") } + + + {makeTextField(1, "To", "to-")} + + ; + case ValueMode.MULTI: + let values = criteria.values; + if (values && values.length == 1 && values[0] == "") + { + values = []; + } + return + ()} + options={[]} + multiple + freeSolo // todo - no debounce after enter? + selectOnFocus + clearOnBlur + fullWidth + limitTags={5} + value={values} + onChange={(event, value) => valueChangeHandler(event, "all", value)} + /> + + saveNewPasterValues(newValues)} /> + + ; + case ValueMode.PVS_SINGLE: + console.log("Doing pvs single: " + criteria.values); + let selectedPossibleValue = null; + if (criteria.values && criteria.values.length > 0) + { + selectedPossibleValue = criteria.values[0]; + } + return + valueChangeHandler(null, 0, value)} + /> + ; + case ValueMode.PVS_MULTI: + console.log("Doing pvs multi: " + criteria.values); + let initialValues: any[] = []; + if (criteria.values && criteria.values.length > 0) + { + if (criteria.values.length == 1 && criteria.values[0] == "") + { + // we never want a tag that's just ""... + } + else + { + initialValues = criteria.values; + } + } + return + valueChangeHandler(null, "all", value)} + /> + + } + + return (
    ); +} + +export default FilterCriteriaRowValues; \ No newline at end of file diff --git a/src/qqq/pages/records/FilterPoc.tsx b/src/qqq/pages/records/FilterPoc.tsx new file mode 100644 index 0000000..a2ea88a --- /dev/null +++ b/src/qqq/pages/records/FilterPoc.tsx @@ -0,0 +1,75 @@ +/* + * 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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import Box from "@mui/material/Box"; +import {useEffect, useState} from "react"; +import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; +import BaseLayout from "qqq/layouts/BaseLayout"; +import Client from "qqq/utils/qqq/Client"; + + +interface Props +{ +} + +FilterPoc.defaultProps = {}; + +function FilterPoc({}: Props): JSX.Element +{ + const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData) + const [queryFilter, setQueryFilter] = useState(new QQueryFilter()) + + const updateFilter = (newFilter: QQueryFilter) => + { + setQueryFilter(JSON.parse(JSON.stringify(newFilter))); + } + + useEffect(() => + { + (async () => + { + const table = await Client.getInstance().loadTableMetaData("order") + setTableMetaData(table); + })(); + }, []); + + return ( + + { + tableMetaData && + + + {/* @ts-ignore */} + + +
    +                  {JSON.stringify(queryFilter, null, 3)})
    +               
    +
    + } +
    + ); +} + +export default FilterPoc; diff --git a/src/qqq/pages/records/IntersectionMatrix.tsx b/src/qqq/pages/records/IntersectionMatrix.tsx new file mode 100644 index 0000000..54657c1 --- /dev/null +++ b/src/qqq/pages/records/IntersectionMatrix.tsx @@ -0,0 +1,123 @@ +/* + * 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 Box from "@mui/material/Box"; +import Checkbox from "@mui/material/Checkbox/Checkbox"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import {makeStyles} from "@mui/styles"; +import {useState} from "react"; +import BaseLayout from "qqq/layouts/BaseLayout"; + + +interface Props +{ + foo: string; +} + +IntersectionMatrix.defaultProps = { + foo: null, +}; + +const useStyles = makeStyles({ + sticky: { + position: "sticky", + left: 0, + top: 0, + background: "white", + boxShadow: "2px 2px 2px grey", + borderRight: "2px solid grey", + zIndex: 1 + } +}); + +function IntersectionMatrix({foo}: Props): JSX.Element +{ + const permissions = ["apiLog.delete", "apiLog.edit", "apiLog.insert", "apiLog.read", "apiLogUser.delete", "apiLogUser.edit", "apiLogUser.insert", "apiLogUser.read", "audit.delete", "audit.edit", "audit.insert", "audit.read", "auditDetail.delete", "auditDetail.edit", "auditDetail.insert", "auditDetail.read", "auditTable.delete", "auditTable.edit", "auditTable.insert", "auditTable.read", "auditUser.delete", "auditUser.edit", "auditUser.insert", "auditUser.read", "availableInventoryIndex.delete", "availableInventoryIndex.edit", "availableInventoryIndex.insert", "availableInventoryIndex.read", "availablePermission.delete", "availablePermission.edit", "availablePermission.insert", "availablePermission.read", "billing.hasAccess", "billingActivity.delete", "billingActivity.edit", "billingActivity.insert", "billingActivity.read", "billingDashboard.hasAccess", "billingWorksheet.delete", "billingWorksheet.edit", "billingWorksheet.insert", "billingWorksheet.read", "billingWorksheetLine.delete", "billingWorksheetLine.edit", "billingWorksheetLine.insert", "billingWorksheetLine.read", "billingWorksheetLineDetail.hasAccess", "billingWorksheetRevenueReport.hasAccess", "billingWorksheetSummary.hasAccess", "blackboxCartonization.delete", "blackboxCartonization.edit", "blackboxCartonization.insert", "blackboxCartonization.read", "blackboxStatus.delete", "blackboxStatus.edit", "blackboxStatus.insert", "blackboxStatus.read", "cancelBillingWorksheet.hasAccess", "carrier.delete", "carrier.edit", "carrier.insert", "carrier.read", "carrierAccount.delete", "carrierAccount.edit", "carrierAccount.insert", "carrierAccount.read", "carrierInvoicing.hasAccess", "carrierPerformance.hasAccess", "carrierPerformanceDashboard.hasAccess", "carrierRevenueReport.hasAccess", "carrierService.delete", "carrierService.edit", "carrierService.insert", "carrierService.read", "carrierServiceSlaExclusionDate.delete", "carrierServiceSlaExclusionDate.edit", "carrierServiceSlaExclusionDate.insert", "carrierServiceSlaExclusionDate.read", "cartonType.delete", "cartonType.edit", "cartonType.insert", "cartonType.read", "cartonization.hasAccess", "client.delete", "client.edit", "client.insert", "client.read", "clientAlias.delete", "clientAlias.edit", "clientAlias.insert", "clientAlias.read", "clientAuth0Application.delete", "clientAuth0Application.edit", "clientAuth0Application.insert", "clientAuth0Application.read", "clientAuth0ApplicationApiKey.delete", "clientAuth0ApplicationApiKey.edit", "clientAuth0ApplicationApiKey.insert", "clientAuth0ApplicationApiKey.read", "clientBillingKey.delete", "clientBillingKey.edit", "clientBillingKey.insert", "clientBillingKey.read", "clientFeeKey.delete", "clientFeeKey.edit", "clientFeeKey.insert", "clientFeeKey.read", "clientShipStationStore.delete", "clientShipStationStore.edit", "clientShipStationStore.insert", "clientShipStationStore.read", "closeBillingWorksheet.hasAccess", "connection.delete", "connection.edit", "connection.insert", "connection.read", "createBillingWorksheet.hasAccess", "createTestOrdersProcess.hasAccess", "dashboard.hasAccess", "dashboards.hasAccess", "dataBag.delete", "dataBag.edit", "dataBag.insert", "dataBag.read", "dataBagVersion.delete", "dataBagVersion.edit", "dataBagVersion.insert", "dataBagVersion.read", "dataHealthDashboard.hasAccess", "dataIndex.delete", "dataIndex.edit", "dataIndex.insert", "dataIndex.read", "deleteSavedFilter.hasAccess", "deposcoCreateTestOrdersJob.delete", "deposcoCreateTestOrdersJob.edit", "deposcoCreateTestOrdersJob.insert", "deposcoCreateTestOrdersJob.read", "deposcoCreateTestOrdersProcess.hasAccess", "deposcoCurrentExceptionsWidget.hasAccess", "deposcoCurrentStatusWidget.hasAccess", "deposcoCustomerOrder.delete", "deposcoCustomerOrder.edit", "deposcoCustomerOrder.insert", "deposcoCustomerOrder.read", "deposcoEnterpriseInventory.delete", "deposcoEnterpriseInventory.edit", "deposcoEnterpriseInventory.insert", "deposcoEnterpriseInventory.read", "deposcoItem.delete", "deposcoItem.edit", "deposcoItem.insert", "deposcoItem.read", "deposcoOrder.delete", "deposcoOrder.edit", "deposcoOrder.insert", "deposcoOrder.read", "deposcoOrderToOrder.hasAccess", "deposcoOrdersApp.hasAccess", "deposcoOrdersByClientPieChart.hasAccess", "deposcoPollForCustomerOrders.hasAccess", "deposcoPollForOrders.hasAccess", "deposcoRecentDataParentWidget.hasAccess", "deposcoReplaceLineItemProcess.hasAccess", "deposcoSalesOrder.delete", "deposcoSalesOrder.edit", "deposcoSalesOrder.insert", "deposcoSalesOrder.read", "deposcoSalesOrderLine.delete", "deposcoSalesOrderLine.edit", "deposcoSalesOrderLine.insert", "deposcoSalesOrderLine.read", "deposcoSalesOrdersBarChart.hasAccess", "deposcoSentOrder.delete", "deposcoSentOrder.edit", "deposcoSentOrder.insert", "deposcoSentOrder.read", "deposcoShipment.delete", "deposcoShipment.edit", "deposcoShipment.insert", "deposcoShipment.read", "deposcoShipmentToSystemGeneratedTrackingNo.hasAccess", "deposcoTradingPartner.delete", "deposcoTradingPartner.edit", "deposcoTradingPartner.insert", "deposcoTradingPartner.read", "developer.hasAccess", "easypostTracker.delete", "easypostTracker.edit", "easypostTracker.insert", "easypostTracker.read", "extensivOrder.delete", "extensivOrder.edit", "extensivOrder.insert", "extensivOrder.read", "extensivOrderToOrder.hasAccess", "fedexTntCache.delete", "fedexTntCache.edit", "fedexTntCache.insert", "fedexTntCache.read", "freightStudy.delete", "freightStudy.edit", "freightStudy.insert", "freightStudy.read", "freightStudyActualShipment.delete", "freightStudyActualShipment.edit", "freightStudyActualShipment.insert", "freightStudyActualShipment.read", "freightStudyAllShipmentsReport.hasAccess", "freightStudyAllShipmentsReportProcess.hasAccess", "freightStudyApp.hasAccess", "freightStudyEstimateShipments.hasAccess", "freightStudyEstimatedShipment.delete", "freightStudyEstimatedShipment.edit", "freightStudyEstimatedShipment.insert", "freightStudyEstimatedShipment.read", "freightStudyScenario.delete", "freightStudyScenario.edit", "freightStudyScenario.insert", "freightStudyScenario.read", "fuelSurcharge.delete", "fuelSurcharge.edit", "fuelSurcharge.insert", "fuelSurcharge.read", "fulfillment.hasAccess", "generateBillingActivityFromBillingWorksheet.hasAccess", "generateBillingWorksheetDocuments.hasAccess", "generateParcelInvoiceLineFromRawAxleHire.hasAccess", "generateParcelInvoiceLineFromRawCdl.hasAccess", "generateParcelInvoiceLineFromRawFedEx.hasAccess", "generateParcelInvoiceLineFromRawLso.hasAccess", "generateParcelInvoiceLineFromRawOntrac.hasAccess", "generateParcelInvoiceLineFromRawUps.hasAccess", "graceDiscountAuditReport.hasAccess", "infoplusLOB.delete", "infoplusLOB.edit", "infoplusLOB.insert", "infoplusLOB.read", "infoplusOrder.delete", "infoplusOrder.edit", "infoplusOrder.insert", "infoplusOrder.read", "infoplusOrderToOrder.hasAccess", "infoplusShipment.delete", "infoplusShipment.edit", "infoplusShipment.insert", "infoplusShipment.read", "infoplusShipmentToSystemGeneratedTrackingNumber.hasAccess", "infoplusWarehouse.delete", "infoplusWarehouse.edit", "infoplusWarehouse.insert", "infoplusWarehouse.read", "initParcelSlaStatus.hasAccess", "integrations.hasAccess", "lineItem.delete", "lineItem.edit", "lineItem.insert", "lineItem.read", "manualUpdateInvoiceLineFromRaw.hasAccess", "markBillingActivityAsException.hasAccess", "markParcelInvoiceLineAsOrphan.hasAccess", "mergeDuplicatedParcels.hasAccess", "omsOperationsDashboard.hasAccess", "optimization.hasAccess", "optimizationCarrierServiceRulesChecker.hasAccess", "optimizationCarrierServiceStateRule.delete", "optimizationCarrierServiceStateRule.edit", "optimizationCarrierServiceStateRule.insert", "optimizationCarrierServiceStateRule.read", "optimizationCarrierServiceTNTRule.delete", "optimizationCarrierServiceTNTRule.edit", "optimizationCarrierServiceTNTRule.insert", "optimizationCarrierServiceTNTRule.read", "optimizationCarrierServiceZipCodeRule.delete", "optimizationCarrierServiceZipCodeRule.edit", "optimizationCarrierServiceZipCodeRule.insert", "optimizationCarrierServiceZipCodeRule.read", "optimizationConfig.delete", "optimizationConfig.edit", "optimizationConfig.insert", "optimizationConfig.read", "optimizationConfigApp.hasAccess", "optimizationDashboard.hasAccess", "optimizationRateChecker.hasAccess", "optimizationRulesChecker.hasAccess", "optimizationStateRule.delete", "optimizationStateRule.edit", "optimizationStateRule.insert", "optimizationStateRule.read", "optimizationTNTRule.delete", "optimizationTNTRule.edit", "optimizationTNTRule.insert", "optimizationTNTRule.read", "optimizationWarehouseRoutingStateRule.delete", "optimizationWarehouseRoutingStateRule.edit", "optimizationWarehouseRoutingStateRule.insert", "optimizationWarehouseRoutingStateRule.read", "optimizationWarehouseRoutingZipCodeRule.delete", "optimizationWarehouseRoutingZipCodeRule.edit", "optimizationWarehouseRoutingZipCodeRule.insert", "optimizationWarehouseRoutingZipCodeRule.read", "optimizationZipCodeRule.delete", "optimizationZipCodeRule.edit", "optimizationZipCodeRule.insert", "optimizationZipCodeRule.read", "order.delete", "order.edit", "order.insert", "order.read", "orderAndShipmentPerformanceDashboard.hasAccess", "orderCarton.delete", "orderCarton.edit", "orderCarton.insert", "orderCarton.read", "orderCartonization.delete", "orderCartonization.edit", "orderCartonization.insert", "orderCartonization.read", "orderExtrinsic.delete", "orderExtrinsic.edit", "orderExtrinsic.insert", "orderExtrinsic.read", "orderOptimization.hasAccess", "orders.hasAccess", "ordersAndShipmentsReport.hasAccess", "outboundApiLog.delete", "outboundApiLog.edit", "outboundApiLog.insert", "outboundApiLog.read", "outboundScannedTrackingNumber.delete", "outboundScannedTrackingNumber.edit", "outboundScannedTrackingNumber.insert", "outboundScannedTrackingNumber.read", "outboundScannedTrackingNumberToParcel.hasAccess", "overview.hasAccess", "overviewDashboard.hasAccess", "parcel.delete", "parcel.edit", "parcel.insert", "parcel.read", "parcelHealthApp.hasAccess", "parcelInvoice.delete", "parcelInvoice.edit", "parcelInvoice.insert", "parcelInvoice.read", "parcelInvoiceLine.delete", "parcelInvoiceLine.edit", "parcelInvoiceLine.insert", "parcelInvoiceLine.read", "parcelInvoiceLineChargeMappingRule.delete", "parcelInvoiceLineChargeMappingRule.edit", "parcelInvoiceLineChargeMappingRule.insert", "parcelInvoiceLineChargeMappingRule.read", "parcelInvoiceLineChargeRollupRule.read", "parcelInvoiceLineToParcel.hasAccess", "parcelInvoiceRawETLAxleHire.hasAccess", "parcelInvoiceRawETLCdl.hasAccess", "parcelInvoiceRawETLFedEx.hasAccess", "parcelInvoiceRawETLLso.hasAccess", "parcelInvoiceRawETLOntrac.hasAccess", "parcelInvoiceRawETLUps.hasAccess", "parcelInvoiceShiplabsSyncAxleHire.hasAccess", "parcelInvoiceShiplabsSyncCdl.hasAccess", "parcelInvoiceShiplabsSyncFedEx.hasAccess", "parcelInvoiceShiplabsSyncLso.hasAccess", "parcelInvoiceShiplabsSyncOntrac.hasAccess", "parcelInvoiceShiplabsSyncUps.hasAccess", "parcelSlaStatus.delete", "parcelSlaStatus.edit", "parcelSlaStatus.insert", "parcelSlaStatus.read", "parcelTrackingDetail.delete", "parcelTrackingDetail.edit", "parcelTrackingDetail.insert", "parcelTrackingDetail.read", "parcels.hasAccess", "pollExtensiveForOrders.hasAccess", "pushDeposcoSalesOrders.hasAccess", "querySavedFilter.hasAccess", "rawParcelInvoiceLineAxleHire.delete", "rawParcelInvoiceLineAxleHire.edit", "rawParcelInvoiceLineAxleHire.insert", "rawParcelInvoiceLineAxleHire.read", "rawParcelInvoiceLineCdl.delete", "rawParcelInvoiceLineCdl.edit", "rawParcelInvoiceLineCdl.insert", "rawParcelInvoiceLineCdl.read", "rawParcelInvoiceLineFedEx.delete", "rawParcelInvoiceLineFedEx.edit", "rawParcelInvoiceLineFedEx.insert", "rawParcelInvoiceLineFedEx.read", "rawParcelInvoiceLineLso.delete", "rawParcelInvoiceLineLso.edit", "rawParcelInvoiceLineLso.insert", "rawParcelInvoiceLineLso.read", "rawParcelInvoiceLineOntrac.delete", "rawParcelInvoiceLineOntrac.edit", "rawParcelInvoiceLineOntrac.insert", "rawParcelInvoiceLineOntrac.read", "rawParcelInvoiceLineUps.delete", "rawParcelInvoiceLineUps.edit", "rawParcelInvoiceLineUps.insert", "rawParcelInvoiceLineUps.read", "receiveEasypostTrackerWebhook.hasAccess", "reconcileClientsOnParcelInvoiceLine.hasAccess", "reconcileClientsOnParcelInvoiceLineFromBillingWorksheet.hasAccess", "reevaluateParcelSlaStatus.hasAccess", "registerParcelAsEasypostTracker.hasAccess", "releaseOrderToWmsProcess.hasAccess", "releaseOrdersJob.delete", "releaseOrdersJob.edit", "releaseOrdersJob.insert", "releaseOrdersJob.read", "releaseOrdersJobOrder.delete", "releaseOrdersJobOrder.edit", "releaseOrdersJobOrder.insert", "releaseOrdersJobOrder.read", "releaseOrdersToWmsProcess.hasAccess", "replaceLineItem.hasAccess", "resyncOrderFromSource.hasAccess", "resyncParcelTrackingStatus.hasAccess", "resyncSystemGeneratedTrackingNumberFromSource.hasAccess", "retrySendingReleaseOrdersJob.hasAccess", "runBillingWorksheetRevenueReport.hasAccess", "runRecordScript.hasAccess", "salesOrderAutomation.hasAccess", "savedFilter.delete", "savedFilter.edit", "savedFilter.insert", "savedFilter.read", "script.delete", "script.edit", "script.insert", "script.read", "scriptLog.delete", "scriptLog.edit", "scriptLog.insert", "scriptLog.read", "scriptLogLine.delete", "scriptLogLine.edit", "scriptLogLine.insert", "scriptLogLine.read", "scriptRevision.delete", "scriptRevision.edit", "scriptRevision.insert", "scriptRevision.read", "scriptType.delete", "scriptType.edit", "scriptType.insert", "scriptType.read", "setup.hasAccess", "shipStationOrder0.delete", "shipStationOrder0.edit", "shipStationOrder0.insert", "shipStationOrder0.read", "shipStationOrderToOrder0.hasAccess", "shipStationShipment0.delete", "shipStationShipment0.edit", "shipStationShipment0.insert", "shipStationShipment0.read", "shipStationShipmentToSystemGeneratedTrackingNumber0.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber1.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber2.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber3.hasAccess", "shipStationShipmentToSystemGeneratedTrackingNumber4.hasAccess", "shipStationStore0.delete", "shipStationStore0.edit", "shipStationStore0.insert", "shipStationStore0.read", "shipStationWarehouse0.delete", "shipStationWarehouse0.edit", "shipStationWarehouse0.insert", "shipStationWarehouse0.read", "shippedOrderToExtensivOrder.hasAccess", "shipping.hasAccess", "shippingDashboard.hasAccess", "storeDataBagVersion.hasAccess", "storeSavedFilter.hasAccess", "storeScriptRevision.hasAccess", "systemGeneratedTrackingNumber.delete", "systemGeneratedTrackingNumber.edit", "systemGeneratedTrackingNumber.insert", "systemGeneratedTrackingNumber.read", "systemGeneratedTrackingNumberToParcel.hasAccess", "tableTrigger.delete", "tableTrigger.edit", "tableTrigger.insert", "tableTrigger.read", "testScript.hasAccess", "totalDeposcoOrdersImported.hasAccess", "uploadFileArchive.delete", "uploadFileArchive.edit", "uploadFileArchive.insert", "uploadFileArchive.read", "warehouse.delete", "warehouse.edit", "warehouse.insert", "warehouse.read", "warehouseClientInt.delete", "warehouseClientInt.edit", "warehouseClientInt.insert", "warehouseClientInt.read", "warehouseShipStationWarehouse.delete", "warehouseShipStationWarehouse.edit", "warehouseShipStationWarehouse.insert", "warehouseShipStationWarehouse.read", "zipZone.delete", "zipZone.edit", "zipZone.insert", "zipZone.read", "zipZoneCdl.delete", "zipZoneCdl.edit", "zipZoneCdl.insert", "zipZoneCdl.read"]; + permissions.splice(50) + const roles = ["External - Customer - OMS API User", "External - Customer - Reports API", "External - Customer - Viewer", "External - Deposco - Cartonization API", "External - Optimization - Viewer", "Internal - Carrier Invoicing - Admin", "Internal - Carrier Invoicing - User", "Internal - Carrier Invoicing - Viewer", "Internal - Developer - Admin", "Internal - Engineering Team - Admin", "Internal - Executive Team", "Internal - Freight Study - Admin", "Internal - Freight Study - User", "Internal - Freight Study - Viewer", "Internal - Integrations - Viewer", "Internal - Optimization - Admin", "Internal - Optimization - User", "Internal - Optimization - Viewer", "Internal - Orders & Parcels - Admin", "Internal - Orders & Parcels - User"]; + + const classes = useStyles(); + + return ( + + + + {/* display: fixes apparent bug in mui? */} + + + { + roles.map((name) => ( + + {name} + + )) + } + + + + { + permissions.map((name) => ( + + + {name.split(/(?=[A-Z.])/).map((part, index) => ( + {part} + ))} + + { + roles.map((role) => ( + + + + )) + } + + )) + } + +
    +
    +
    + ); + + return ( + + + { + permissions.map((name) => + { + return ( + + {name} + + ) + }) + } + + + ); +} + +export default IntersectionMatrix; diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 3269b4a..cb47647 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -59,6 +59,7 @@ import {QActionsMenuButton, QCancelButton, QCreateNewButton, QSaveButton} from " import MenuButton from "qqq/components/buttons/MenuButton"; import SavedFilters from "qqq/components/misc/SavedFilters"; import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; +import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import BaseLayout from "qqq/layouts/BaseLayout"; import ProcessRun from "qqq/pages/processes/ProcessRun"; @@ -173,7 +174,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel); + const [lastFetchedQFilterJSON, setLastFetchedQFilterJSON] = useState(""); const [columnSortModel, setColumnSortModel] = useState(defaultSort); + const [queryFilter, setQueryFilter] = useState(new QQueryFilter()); + const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultVisibility); const [shouldSetAllNewJoinFieldsToHidden, setShouldSetAllNewJoinFieldsToHidden] = useState(!didDefaultVisibilityComeFromLocalStorage) const [visibleJoinTables, setVisibleJoinTables] = useState(new Set()); @@ -236,7 +240,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [queryErrors, setQueryErrors] = useState({} as any); const [receivedQueryErrorTimestamp, setReceivedQueryErrorTimestamp] = useState(new Date()); - const {setPageHeader} = useContext(QContext); const [, forceUpdate] = useReducer((x) => x + 1, 0); @@ -354,6 +357,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element updateColumnVisibilityModel(); setColumnsModel([]); setFilterModel({items: []}); + setQueryFilter(new QQueryFilter()); setDefaultFilterLoaded(false); setRows([]); } @@ -365,7 +369,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ////////////////////////////////////////////////////////////////////////////////////////////////////// const buildQFilter = (tableMetaData: QTableMetaData, filterModel: GridFilterModel, limit?: number) => { - const filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, limit); + let filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, limit); + filter = FilterUtils.convertFilterPossibleValuesToIds(filter); setHasValidFilters(filter.criteria && filter.criteria.length > 0); return (filter); }; @@ -531,6 +536,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element let models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, null, searchParams, filterLocalStorageKey, sortLocalStorageKey); setFilterModel(models.filter); setColumnSortModel(models.sort); + setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage)); return; } @@ -564,6 +570,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { columnSortModel.splice(i, 1); setColumnSortModel(columnSortModel); + // todo - need to setQueryFilter? resetColumnSortModel = true; i--; } @@ -580,6 +587,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element sort: "desc", }); setColumnSortModel(columnSortModel); + // todo - need to setQueryFilter? resetColumnSortModel = true; } @@ -636,6 +644,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }); } + setLastFetchedQFilterJSON(JSON.stringify(qFilter)); qController.query(tableName, qFilter, queryJoins).then((results) => { console.log(`Received results for query ${thisQueryId}`); @@ -878,6 +887,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const newVisibleJoinTables = getVisibleJoinTables(); if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()])) { + console.log("calling update table for visible join table change"); updateTable(); setVisibleJoinTables(newVisibleJoinTables); } @@ -889,9 +899,30 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element console.log(columnOrderChangeParams); }; - const handleFilterChange = (filterModel: GridFilterModel) => + const handleFilterChange = (filterModel: GridFilterModel, doSetQueryFilter = true, isChangeFromDataGrid = false) => { setFilterModel(filterModel); + + if (doSetQueryFilter) + { + ////////////////////////////////////////////////////////////////////////////////// + // someone might have already set the query filter, so, only set it if asked to // + ////////////////////////////////////////////////////////////////////////////////// + setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage)); + } + + if (isChangeFromDataGrid) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // 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)); + } + if (filterLocalStorageKey) { localStorage.setItem(filterLocalStorageKey, JSON.stringify(filterModel)); @@ -903,6 +934,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (gridSort && gridSort.length > 0) { setColumnSortModel(gridSort); + setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, gridSort, rowsPerPage)); localStorage.setItem(sortLocalStorageKey, JSON.stringify(gridSort)); } }; @@ -967,8 +999,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ////////////////////////////////////// // construct the url for the export // ////////////////////////////////////// - const d = new Date(); - const dateString = `${d.getFullYear()}-${zp(d.getMonth() + 1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`; + const dateString = ValueUtils.formatDateTimeForFileName(new Date()); const filename = `${tableMetaData.label} Export ${dateString}.${format}`; const url = `/data/${tableMetaData.name}/export/${filename}`; @@ -1115,6 +1146,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element newPath.pop(); navigate(newPath.join("/")); + console.log("calling update table for close modal"); updateTable(); }; @@ -1224,6 +1256,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return ( + { + if(fieldName.indexOf(".") > -1) + { + const nameParts = fieldName.split(".", 2); + for (let i = 0; i < tableMetaData?.exposedJoins?.length; i++) + { + const join = tableMetaData?.exposedJoins[i]; + if(join?.joinTable.name == nameParts[0]) + { + return ([join.joinTable.fields.get(nameParts[1]), join.joinTable]); + } + } + } + else + { + return ([tableMetaData.fields.get(fieldName), tableMetaData]); + } + + return (null); + } + const copyColumnValues = async (column: GridColDef) => { let data = ""; let counter = 0; if (latestQueryResults && latestQueryResults.length) { - let qFieldMetaData = tableMetaData.fields.get(column.field); + let [qFieldMetaData, fieldTable] = getFieldAndTable(column.field); for (let i = 0; i < latestQueryResults.length; i++) { let record = latestQueryResults[i] as QRecord; - const value = ValueUtils.getUnadornedValueForDisplay(qFieldMetaData, record.values.get(qFieldMetaData.name), record.displayValues.get(qFieldMetaData.name)); + const value = ValueUtils.getUnadornedValueForDisplay(qFieldMetaData, record.values.get(column.field), record.displayValues.get(column.field)); if (value !== null && value !== undefined && String(value) !== "") { data += value + "\n"; @@ -1321,24 +1376,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setFilterForColumnStats(buildQFilter(tableMetaData, filterModel)); setColumnStatsFieldName(column.field); - if(column.field.indexOf(".") > -1) - { - const nameParts = column.field.split(".", 2); - for (let i = 0; i < tableMetaData?.exposedJoins?.length; i++) - { - const join = tableMetaData?.exposedJoins[i]; - if(join?.joinTable.name == nameParts[0]) - { - setColumnStatsField(join.joinTable.fields.get(nameParts[1])); - setColumnStatsFieldTableName(nameParts[0]); - } - } - } - else - { - setColumnStatsField(tableMetaData.fields.get(column.field)); - setColumnStatsFieldTableName(tableMetaData.name); - } + const [field, fieldTable] = getFieldAndTable(column.field); + setColumnStatsField(field); + setColumnStatsFieldTableName(fieldTable.name); }; const CustomColumnMenu = forwardRef( @@ -1502,6 +1542,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; + const doClearFilter = (event: React.KeyboardEvent, isYesButton: boolean = false) => + { + if (isYesButton|| event.key == "Enter") + { + setShowClearFiltersWarning(false); + handleFilterChange({items: []} as GridFilterModel); + } + } + return (
    @@ -1516,30 +1565,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { hasValidFilters && ( - -
    - +
    + setShowClearFiltersWarning(true)}>clear - setShowClearFiltersWarning(false)} onKeyPress={(e) => - { - if (e.key == "Enter") - { - setShowClearFiltersWarning(false) - handleFilterChange({items: []} as GridFilterModel); - } - }}> + setShowClearFiltersWarning(false)} onKeyPress={(e) => doClearFilter(e)}> Confirm - Are you sure you want to clear all filters? + Are you sure you want to remove all conditions from the current filter? setShowClearFiltersWarning(false)} /> - - { - setShowClearFiltersWarning(false); - handleFilterChange({items: []} as GridFilterModel); - }}/> + doClearFilter(null, true)}/>
    @@ -1716,7 +1753,22 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setTotalRecords(null); setDistinctRecords(null); updateTable(); - }, [columnsModel, tableState, filterModel]); + }, [columnsModel, tableState]); + + useEffect(() => + { + const currentQFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage); + currentQFilter.skip = pageNumber * rowsPerPage; + const currentQFilterJSON = JSON.stringify(currentQFilter); + + if(currentQFilterJSON !== lastFetchedQFilterJSON) + { + setTotalRecords(null); + setDistinctRecords(null); + updateTable(); + } + + }, [filterModel]); useEffect(() => { @@ -1724,6 +1776,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element document.scrollingElement.scrollTop = 0; }, [pageNumber, rowsPerPage]); + const updateFilterFromFilterPanel = (newFilter: QQueryFilter): void => + { + setQueryFilter(newFilter); + const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); + handleFilterChange(gridFilterModel, false); + }; + if (tableMetaData && !tableMetaData.readPermission) { return ( @@ -1783,7 +1842,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } - + { + metaData && metaData.processes.has("querySavedFilter") && + + } @@ -1794,7 +1856,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && } - @@ -1804,18 +1865,34 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element Pagination: CustomPagination, LoadingOverlay: Loading, ColumnMenu: CustomColumnMenu, - ColumnsPanel: CustomColumnsPanel + ColumnsPanel: CustomColumnsPanel, + FilterPanel: CustomFilterPanel }} componentsProps={{ columnsPanel: { tableMetaData: tableMetaData, + metaData: metaData, initialOpenedGroups: columnChooserOpenGroups, openGroupsChanger: setColumnChooserOpenGroups, initialFilterText: columnChooserFilterText, filterTextChanger: setColumnChooserFilterText + }, + filterPanel: + { + tableMetaData: tableMetaData, + metaData: metaData, + queryFilter: queryFilter, + updateFilter: updateFilterFromFilterPanel } }} + localeText={{ + toolbarFilters: "Filter", // label on the filters button. we prefer singular (1 filter has many "conditions" in it). + toolbarFiltersLabel: "", // setting these 3 to "" turns off the "Show Filters" and "Hide Filters" tooltip (which can get in the way of the actual filters panel) + toolbarFiltersTooltipShow: "", + toolbarFiltersTooltipHide: "", + toolbarFiltersTooltipActive: count => count !== 1 ? `${count} conditions` : `${count} condition` + }} pinnedColumns={pinnedColumns} onPinnedColumnsChange={handlePinnedColumnsChange} pagination @@ -1837,7 +1914,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element density={density} loading={loading} filterModel={filterModel} - onFilterModelChange={handleFilterChange} + onFilterModelChange={(model) => handleFilterChange(model, true, true)} columnVisibilityModel={columnVisibilityModel} onColumnVisibilityModelChange={handleColumnVisibilityChange} onColumnOrderChange={handleColumnOrderChange} diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 665b5ef..8282f58 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -413,3 +413,92 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } margin-right: 0.25rem; cursor: pointer; } + +/* move the columns & filter panels on the query screen data grid up to not be below the column headers row */ +/* todo - add a class to the query screen and qualify this like that */ +.MuiDataGrid-panel +{ + top: -60px !important; +} + +/* tighten the text in the field select dropdown in custom filters */ +.customFilterPanel .MuiAutocomplete-paper +{ + line-height: 1.375; +} + +/* tighten the text in the field select dropdown in custom filters */ +.customFilterPanel .MuiAutocomplete-groupLabel +{ + line-height: 1.75; +} + +/* taller list box */ +.customFilterPanel .MuiAutocomplete-listbox +{ + max-height: 60vh; +} + +/* shrink down-arrows in custom filters panel */ +.customFilterPanel .booleanOperatorColumn .MuiSelect-iconStandard, +.customFilterPanel .MuiSvgIcon-root +{ + font-size: 14px !important; +} + +/* fix something in AND/OR dropdown in filters */ +.customFilterPanel .booleanOperatorColumn .MuiSvgIcon-root +{ + display: inline-block !important; +} + +/* adjust bottom of AND/OR dropdown in filters */ +.customFilterPanel .booleanOperatorColumn .MuiInputBase-formControl +{ + padding-bottom: calc(0.25rem + 1px); +} + +/* adjust down-arrow in AND/OR dropdown in filters */ +.customFilterPanel .booleanOperatorColumn .MuiSelect-iconStandard +{ + top: calc(50% - 0.75rem); +} + +/* change tags in any-of value fields to not be black bg with white text */ +.customFilterPanel .filterValuesColumn .MuiChip-root +{ + background: none; + color: black; + border: 1px solid gray; +} + +/* change 'x' icon in tags in any-of value */ +.customFilterPanel .filterValuesColumn .MuiChip-root .MuiChip-deleteIcon +{ + color: gray; +} + +/* change tags in any-of value fields to not be black bg with white text */ +.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag +{ + color: #191919; + background: none; +} + +/* default hover color for the 'x' to remove a tag from an 'any-of' value was white, which made it disappear */ +.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag .MuiSvgIcon-root:hover +{ + color: lightgray; +} + +.DynamicSelectPopper ul +{ + padding: 0; +} + +.DynamicSelectPopper ul li.MuiAutocomplete-option +{ + padding-left: 0.25rem; + padding-right: 0.25rem; +} + diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index 2ff1c4d..6a04389 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -25,13 +25,42 @@ import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QField import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; -import {getGridDateOperators, GridColDef, GridRowsProp} from "@mui/x-data-grid-pro"; +import {GridColDef, GridFilterItem, GridRowsProp} from "@mui/x-data-grid-pro"; import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator"; import React from "react"; import {Link} from "react-router-dom"; import {buildQGridPvsOperators, QGridBlobOperators, QGridBooleanOperators, QGridNumericOperators, QGridStringOperators} from "qqq/pages/records/query/GridFilterOperators"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +const emptyApplyFilterFn = (filterItem: GridFilterItem, column: GridColDef): null => null; + +function NullInputComponent() +{ + return (); +} + +const makeGridFilterOperator = (value: string, label: string, takesValues: boolean = false): GridFilterOperator => +{ + const rs: GridFilterOperator = {value: value, label: label, getApplyFilterFn: emptyApplyFilterFn}; + if (takesValues) + { + rs.InputComponent = NullInputComponent; + } + return (rs); +}; + +const QGridDateOperators = [ + makeGridFilterOperator("equals", "equals", true), + makeGridFilterOperator("isNot", "not equals", true), + makeGridFilterOperator("after", "is after", true), + makeGridFilterOperator("onOrAfter", "is on or after", true), + makeGridFilterOperator("before", "is before", true), + makeGridFilterOperator("onOrBefore", "is on or before", true), + makeGridFilterOperator("isEmpty", "is empty"), + makeGridFilterOperator("isNotEmpty", "is not empty"), +]; + export default class DataGridUtils { @@ -40,7 +69,7 @@ export default class DataGridUtils *******************************************************************************/ public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData): GridRowsProp[] => { - const fields = [ ...tableMetaData.fields.values() ]; + const fields = [...tableMetaData.fields.values()]; const rows = [] as any[]; let rowIndex = 0; results.forEach((record: QRecord) => @@ -205,6 +234,7 @@ export default class DataGridUtils }); } + /******************************************************************************* ** *******************************************************************************/ @@ -237,12 +267,12 @@ export default class DataGridUtils case QFieldType.DATE: columnType = "date"; columnWidth = 100; - filterOperators = getGridDateOperators(); + filterOperators = QGridDateOperators; break; case QFieldType.DATE_TIME: columnType = "dateTime"; columnWidth = 200; - filterOperators = getGridDateOperators(true); + filterOperators = QGridDateOperators; break; case QFieldType.BOOLEAN: columnType = "string"; // using boolean gives an odd 'no' for nulls. diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 8c7f1d5..e599433 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -27,7 +27,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 {GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro"; +import {GridFilterItem, GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId"; @@ -256,7 +256,7 @@ class FilterUtils } else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN) { - if (value == null && (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 // @@ -264,10 +264,10 @@ class FilterUtils ///////////////////////////////////////////////////////////////////////////////////////////////// return ([null, null]); } - return (FilterUtils.prepFilterValuesForBackend(value, fieldMetaData)); + return (FilterUtils.cleanseCriteriaValueForQQQ(value, fieldMetaData)); } - return (FilterUtils.prepFilterValuesForBackend([value], fieldMetaData)); + return (FilterUtils.cleanseCriteriaValueForQQQ([value], fieldMetaData)); }; @@ -278,7 +278,7 @@ class FilterUtils ** ** Or, if the values are date-times, convert them to UTC. *******************************************************************************/ - private static prepFilterValuesForBackend = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] => + private static cleanseCriteriaValueForQQQ = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] => { if (param === null || param === undefined) { @@ -291,10 +291,15 @@ class FilterUtils console.log(param[i]); if (param[i] && param[i].id && param[i].label) { - ///////////////////////////////////////////////////////////// - // if the param looks like a possible value, return its id // - ///////////////////////////////////////////////////////////// - rs.push(param[i].id); + ////////////////////////////////////////////////////////////////////////////////////////// + // 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 { @@ -464,7 +469,16 @@ class FilterUtils amount = -amount; } + ///////////////////////////////////////////// + // shift the date/time by the input amount // + ///////////////////////////////////////////// value.setTime(value.getTime() + 1000 * amount); + + ///////////////////////////////////////////////// + // now also shift from local-timezone into UTC // + ///////////////////////////////////////////////// + value.setTime(value.getTime() + 1000 * 60 * value.getTimezoneOffset()); + values = [ValueUtils.formatDateTimeISO8601(value)]; } } @@ -585,10 +599,67 @@ class FilterUtils } + /******************************************************************************* + ** 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): QQueryFilter + public static buildQFilterFromGridFilter(tableMetaData: QTableMetaData, filterModel: GridFilterModel, columnSortModel: GridSortItem[], limit?: number, allowIncompleteCriteria = false): QQueryFilter { console.log("Building q filter with model:"); console.log(filterModel); @@ -628,13 +699,15 @@ class FilterUtils //////////////////////////////////////////////////////////////////////////////// // if no value set and not 'empty' or 'not empty' operators, skip this filter // //////////////////////////////////////////////////////////////////////////////// - if ((!item.value || item.value.length == 0) && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty") + if ((!item.value || item.value.length == 0 || (item.value.length == 1 && item.value[0] == "")) && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty") { - return; + if (!allowIncompleteCriteria) + { + return; + } } - var fieldMetadata = tableMetaData?.fields.get(item.columnField); - + const fieldMetadata = tableMetaData?.fields.get(item.columnField); const operator = FilterUtils.gridCriteriaOperatorToQQQ(item.operatorValue); const values = FilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue, fieldMetadata); qFilter.addCriteria(new QFilterCriteria(item.columnField, operator, values)); @@ -654,6 +727,37 @@ class FilterUtils return qFilter; }; + + /******************************************************************************* + ** 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); + } + } export default FilterUtils; diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 4667fae..857d71a 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -379,7 +379,7 @@ class ValueUtils ////////////////////////////////////////////////////////////////// return (value + "T00:00"); } - else if (value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?Z$/)) + else if (value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/)) { /////////////////////////////////////////////////////////////////////////////////////////////////////// // If the passed in string has a Z on the end (e.g., in UTC) - make a Date object - the browser will // diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java index 3fbea4a..7b2b5c1 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java @@ -10,5 +10,5 @@ public interface QQQMaterialDashboardSelectors String BREADCRUMB_HEADER = ".MuiToolbar-root h5"; String QUERY_GRID_CELL = ".MuiDataGrid-root .MuiDataGrid-cellContent"; - String QUERY_FILTER_INPUT = ".MuiDataGrid-filterForm input.MuiInput-input"; + String QUERY_FILTER_INPUT = ".customFilterPanel input.MuiInput-input"; } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java index d53692e..9bbc344 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java @@ -116,6 +116,26 @@ public class QSeleniumLib + /******************************************************************************* + ** + *******************************************************************************/ + public void waitForMillis(int n) + { + try + { + new WebDriverWait(driver, Duration.ofMillis(n)) + .until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".wontEverBePresent"))); + } + catch(Exception e) + { + /////////////////// + // okay, resume. // + /////////////////// + } + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java index 2d1d9b4..997c522 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java @@ -29,8 +29,8 @@ import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext; import com.kingsrook.qqq.materialdashboard.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 org.openqa.selenium.support.ui.Select; import static org.assertj.core.api.Assertions.assertThat; @@ -49,7 +49,8 @@ public class QueryScreenTest extends QBaseSeleniumTest super.addJavalinRoutes(qSeleniumJavalin); qSeleniumJavalin .withRouteToFile("/data/person/count", "data/person/count.json") - .withRouteToFile("/data/person/query", "data/person/index.json"); + .withRouteToFile("/data/person/query", "data/person/index.json") + .withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json"); } @@ -62,15 +63,18 @@ public class QueryScreenTest extends QBaseSeleniumTest { qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL); - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filters").click(); + qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); ///////////////////////////////////////////////////////////////////// // 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(); - filterInput.sendKeys("1"); + driver.switchTo().activeElement().sendKeys("1"); /////////////////////////////////////////////////////////////////// // assert that query & count both have the expected filter value // @@ -117,10 +121,10 @@ public class QueryScreenTest extends QBaseSeleniumTest { qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL); - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filters").click(); + qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); - addQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or"); qSeleniumJavalin.beginCapture(); + addQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or"); addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); String expectedFilterContents0 = """ @@ -145,27 +149,43 @@ public class QueryScreenTest extends QBaseSeleniumTest { if(index > 0) { - qSeleniumLib.waitForSelectorContaining("BUTTON", "Add filter").click(); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Add condition").click(); } - WebElement subFormForField = qSeleniumLib.waitForSelectorAll(".MuiDataGrid-filterForm", index + 1).get(index); + WebElement subFormForField = qSeleniumLib.waitForSelectorAll(".filterCriteriaRow", index + 1).get(index); if(index == 1) { - Select linkOperatorSelect = new Select(subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormLinkOperatorInput SELECT"))); - linkOperatorSelect.selectByVisibleText(booleanOperator); + 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); } - Select fieldSelect = new Select(subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormColumnInput SELECT"))); - fieldSelect.selectByVisibleText(fieldLabel); + 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); - Select operatorSelect = new Select(subFormForField.findElement(By.cssSelector(".MuiDataGrid-filterFormOperatorInput SELECT"))); - operatorSelect.selectByVisibleText(operator); + 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(".MuiDataGrid-filterFormValueInput INPUT")); + WebElement valueInput = subFormForField.findElement(By.cssSelector(".filterValuesColumn INPUT")); valueInput.click(); valueInput.sendKeys(value); - qSeleniumLib.waitForSeconds(1); + qSeleniumLib.waitForMillis(100); } } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java index 1252d6b..9bbe204 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java @@ -108,7 +108,7 @@ public class SavedFiltersTest extends QBaseSeleniumTest ////////////////////// // modify the query // ////////////////////// - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filters").click(); + qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); qSeleniumLib.waitForSelectorContaining("H5", "Person").click(); qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People")