Merge pull request #21 from Kingsrook/feature/custom-filter-panel

Feature/custom filter panel
This commit is contained in:
2023-06-20 10:18:06 -05:00
committed by GitHub
17 changed files with 2079 additions and 96 deletions

View File

@ -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<readonly QPossibleValue[]>([]);
const [ searchTerm, setSearchTerm ] = useState(null);
const [ firstRender, setFirstRender ] = useState(true);
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<readonly QPossibleValue[]>([]);
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 = (
<Autocomplete
id={fieldName}
id={overrideId ?? fieldName}
sx={{background: isDisabled ? "#f0f2f5!important" : "initial"}}
open={open}
fullWidth
@ -291,6 +299,8 @@ function DynamicSelect({tableName, processName, fieldName, fieldLabel, inForm, i
disabled={isDisabled}
multiple={isMultiple}
disableCloseOnSelect={isMultiple}
limitTags={5}
slotProps={{popper: {className: "DynamicSelectPopper"}}}
renderInput={(params) => (
<TextField
{...params}

View File

@ -19,6 +19,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<any, GridColumnsPanelProps>(
{
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<any, GridColumnsPanelProps>(
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<any, GridColumnsPanelProps>(
}
}
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);
};

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 <DataGrid componentsProps> //
///////////////////////////////////////////////////////////////////////
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<any, GridFilterPanelProps>(
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 (
<Box className="customFilterPanel">
{
queryFilter.criteria.map((criteria: QFilterCriteriaWithId, index: number) =>
(
<Box key={criteria.id}>
<FilterCriteriaRow
id={criteria.id}
index={index}
tableMetaData={props.tableMetaData}
metaData={props.metaData}
criteria={criteria}
booleanOperator={booleanOperator}
updateCriteria={(newCriteria, needDebounce) => updateCriteria(newCriteria, index, needDebounce)}
removeCriteria={() => removeCriteria(index)}
updateBooleanOperator={(newValue) => updateBooleanOperator(newValue)}
/>
{/*JSON.stringify(criteria)*/}
</Box>
))
}
<Box p={1}>
<Button onClick={() => addCriteria()} startIcon={<Icon>add</Icon>} size="medium" sx={{px: 0.75}}>Add Condition</Button>
</Box>
</Box>
);
}
);

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 (
<Box>
<Tooltip title="Quickly add many values to your filter by pasting them from a spreadsheet or any other data source.">
<Icon onClick={handlePasteClick} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}}>paste_content</Icon>
</Tooltip>
{
pasteModalIsOpen &&
(
<Modal open={pasteModalIsOpen}>
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
<Card sx={mainCardStyles}>
<Box p={4} pb={2}>
<Grid container>
<Grid item pr={3} xs={12} lg={12}>
<Typography variant="h5">Bulk Add Filter Values</Typography>
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
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.
</Typography>
</Grid>
</Grid>
</Box>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<TextField
id="outlined-multiline-static"
label="PASTE TEXT"
multiline
onChange={handleTextChange}
rows={16}
value={inputText}
/>
</FormControl>
</Grid>
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<ChipTextField
handleChipChange={() =>
{
}}
chipData={chipData}
chipType={type}
multiline
fullWidth
variant="outlined"
id="tags"
rows={0}
name="tags"
label="FILTER VALUES REVIEW"
/>
</FormControl>
</Grid>
</Grid>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pl={1} pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<Box sx={{display: "inline-flex", alignItems: "baseline"}}>
<FormControl sx={{mt: 2, width: "50%"}}>
<InputLabel htmlFor="select-native">
SEPARATOR
</InputLabel>
<Select
multiline
native
value={delimiter}
onChange={handleDelimiterChange}
label="SEPARATOR"
size="medium"
inputProps={{
id: "select-native",
}}
>
{delimiterDropdownOptions.map((delimiter) => (
<option key={delimiter} value={delimiter}>
{delimiter}
</option>
))}
</Select>
</FormControl>
{delimiter === Delimiter.CUSTOM.valueOf() && (
<FormControl sx={{pl: 2, top: 5, width: "50%"}}>
<TextField
name="custom-delimiter-value"
placeholder="Custom Separator"
label="Custom Separator"
variant="standard"
value={customDelimiterValue}
onChange={handleCustomDelimiterChange}
inputProps={{maxLength: 1}}
/>
</FormControl>
)}
{inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && (
<Typography pl={2} variant="button" sx={{top: "1px", textTransform: "revert"}}>
<i>{detectedText}</i>
</Typography>
)}
</Box>
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={3} lg={3}>
{
errorText && chipData.length > 0 && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
<Icon color="error">error</Icon>
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">{errorText}</Typography>
</Box>
)
}
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={3} lg={3}>
{
chipData && chipData.length > 0 && (
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"}</Typography>
)
}
</Grid>
</Grid>
<Box p={3} pt={0}>
<Grid container pl={1} pr={1} justifyContent="right" alignItems="stretch" sx={{display: "flex-inline "}}>
<QCancelButton
onClickHandler={handleCancelClicked}
iconName="cancel"
disabled={false} />
<QSaveButton onClickHandler={handleSaveClicked} label="Add Filters" disabled={false} />
</Grid>
</Box>
</Card>
</Box>
</Box>
</Modal>
)
}
</Box>
);
}
export default FilterCriteriaPaster;

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<HTMLLIElement>, option: any, state: AutocompleteRenderOptionState): ReactNode
{
let label = ""
if(option && option.field)
{
label = (option.field.label);
}
return (<li {...props}>{label}</li>);
}
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 (
<Box className="filterCriteriaRow" pt={0.5} display="flex" alignItems="flex-end">
<Box display="inline-block">
<Tooltip title="Remove this condition from your filter" enterDelay={750} placement="left">
<IconButton onClick={removeCriteria}><Icon fontSize="small">close</Icon></IconButton>
</Tooltip>
</Box>
<Box display="inline-block" width={55} className="booleanOperatorColumn">
{booleanOperator && index > 0 ?
<FormControl variant="standard" sx={{verticalAlign: "bottom"}} fullWidth>
<Select value={booleanOperator} disabled={index > 1} onChange={handleBooleanOperatorChange}>
<MenuItem value="AND">And</MenuItem>
<MenuItem value="OR">Or</MenuItem>
</Select>
</FormControl>
: <span />}
</Box>
<Box display="inline-block" width={250} className="fieldColumn">
<Autocomplete
id={`field-${id}`}
renderInput={(params) => (<TextField {...params} label={"Field"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
// @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"}}}}
/>
</Box>
<Box display="inline-block" width={200} className="operatorColumn">
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={750}>
<Autocomplete
id={"criteriaOperator"}
renderInput={(params) => (<TextField {...params} label={"Operator"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
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}*/
/>
</Tooltip>
</Box>
<Box display="inline-block" width={300} className="filterValuesColumn">
<FilterCriteriaRowValues
operatorOption={operatorSelectedValue}
criteria={{id: id, ...criteria}}
field={field}
table={fieldTable}
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
/>
</Box>
<Box display="inline-block" pl={0.5} pr={1}>
<Tooltip title={criteriaStatusTooltip} enterDelay={750} placement="right">
{
criteriaIsValid
? <Icon color="success">check</Icon>
: <Icon color="disabled">pending</Icon>
}
</Tooltip>
</Box>
</Box>
);
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {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 <br />;
}
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<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
{
valueChangeHandler(event, index, "");
document.getElementById(`${idPrefix}${criteria.id}`).focus();
};
const inputProps: any = {};
inputProps.endAdornment = (
<InputAdornment position="end">
<IconButton sx={{visibility: value ? "visible" : "hidden"}} onClick={(event) => clearValue(event, valueIndex)}>
<Icon>close</Icon>
</IconButton>
</InputAdornment>
);
return <TextField
id={`${idPrefix}${criteria.id}`}
label={label}
variant="standard"
autoComplete="off"
type={type}
onChange={(event) => 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 <br />;
case ValueMode.SINGLE:
return makeTextField();
case ValueMode.SINGLE_DATE:
return makeTextField();
case ValueMode.SINGLE_DATE_TIME:
return makeTextField();
case ValueMode.DOUBLE:
return <Box>
<Box width="50%" display="inline-block">
{ makeTextField(0, "From", "from-") }
</Box>
<Box width="50%" display="inline-block">
{makeTextField(1, "To", "to-")}
</Box>
</Box>;
case ValueMode.MULTI:
let values = criteria.values;
if (values && values.length == 1 && values[0] == "")
{
values = [];
}
return <Box display="flex" alignItems="flex-end">
<Autocomplete
renderInput={(params) => (<TextField {...params} variant="standard" label="Values" />)}
options={[]}
multiple
freeSolo // todo - no debounce after enter?
selectOnFocus
clearOnBlur
fullWidth
limitTags={5}
value={values}
onChange={(event, value) => valueChangeHandler(event, "all", value)}
/>
<Box>
<FilterCriteriaPaster type={getTypeForTextField()} onSave={(newValues: any[]) => saveNewPasterValues(newValues)} />
</Box>
</Box>;
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 <Box mb={-1.5}>
<DynamicSelect
tableName={table.name}
fieldName={field.name}
overrideId={field.name + "-single-" + criteria.id}
key={field.name + "-single-" + criteria.id}
fieldLabel="Value"
initialValue={selectedPossibleValue?.id}
initialDisplayValue={selectedPossibleValue?.label}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, 0, value)}
/>
</Box>;
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 <Box mb={-1.5}>
<DynamicSelect
tableName={table.name}
fieldName={field.name}
overrideId={field.name + "-multi-" + criteria.id}
key={field.name + "-multi-" + criteria.id}
isMultiple
fieldLabel="Values"
initialValues={initialValues}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, "all", value)}
/>
</Box>
}
return (<br />);
}
export default FilterCriteriaRowValues;

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 (
<BaseLayout>
{
tableMetaData &&
<Box>
<Box sx={{background: "white"}} border="1px solid gray">
{/* @ts-ignore */}
<CustomFilterPanel tableMetaData={tableMetaData} queryFilter={queryFilter} updateFilter={updateFilter} />
</Box>
<pre style={{fontSize: "12px"}}>
{JSON.stringify(queryFilter, null, 3)})
</pre>
</Box>
}
</BaseLayout>
);
}
export default FilterPoc;

File diff suppressed because one or more lines are too long

View File

@ -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<string>());
@ -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 (
<TablePagination
component="div"
sx={{minWidth: "450px"}}
// note - passing null here makes the 'to' param in the defaultLabelDisplayedRows also be null,
// so pass a sentinel value of -1...
count={totalRecords === null || totalRecords === undefined ? -1 : totalRecords}
@ -1285,17 +1318,39 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
return (qRecord);
}
const getFieldAndTable = (fieldName: string): [QFieldMetaData, QTableMetaData] =>
{
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<HTMLUListElement, GridColumnMenuProps>(
@ -1502,6 +1542,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
}
};
const doClearFilter = (event: React.KeyboardEvent<HTMLDivElement>, isYesButton: boolean = false) =>
{
if (isYesButton|| event.key == "Enter")
{
setShowClearFiltersWarning(false);
handleFilterChange({items: []} as GridFilterModel);
}
}
return (
<GridToolbarContainer>
<div>
@ -1516,30 +1565,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
<GridToolbarFilterButton nonce={undefined} />
{
hasValidFilters && (
<div id="clearFiltersButton" style={{position: "absolute", left: "84px", top: "6px"}}>
<Tooltip title="Clear All Filters">
<div id="clearFiltersButton" style={{display: "inline-block", position: "relative", top: "2px", left: "-0.75rem", width: "1rem"}}>
<Tooltip title="Clear Filter">
<Icon sx={{cursor: "pointer"}} onClick={() => setShowClearFiltersWarning(true)}>clear</Icon>
</Tooltip>
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(false)} onKeyPress={(e) =>
{
if (e.key == "Enter")
{
setShowClearFiltersWarning(false)
handleFilterChange({items: []} as GridFilterModel);
}
}}>
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(false)} onKeyPress={(e) => doClearFilter(e)}>
<DialogTitle id="alert-dialog-title">Confirm</DialogTitle>
<DialogContent>
<DialogContentText>Are you sure you want to clear all filters?</DialogContentText>
<DialogContentText>Are you sure you want to remove all conditions from the current filter?</DialogContentText>
</DialogContent>
<DialogActions>
<QCancelButton label="No" disabled={false} onClickHandler={() => setShowClearFiltersWarning(false)} />
<QSaveButton label="Yes" iconName="check" disabled={false} onClickHandler={() =>
{
setShowClearFiltersWarning(false);
handleFilterChange({items: []} as GridFilterModel);
}}/>
<QSaveButton label="Yes" iconName="check" disabled={false} onClickHandler={() => doClearFilter(null, true)}/>
</DialogActions>
</Dialog>
</div>
@ -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
}
<Box display="flex" justifyContent="flex-end" alignItems="flex-start" mb={2}>
<Box display="flex" marginRight="auto">
<SavedFilters qController={qController} metaData={metaData} tableMetaData={tableMetaData} currentSavedFilter={currentSavedFilter} filterModel={filterModel} columnSortModel={columnSortModel} filterOnChangeCallback={handleSavedFilterChange} />
{
metaData && metaData.processes.has("querySavedFilter") &&
<SavedFilters qController={qController} metaData={metaData} tableMetaData={tableMetaData} currentSavedFilter={currentSavedFilter} filterModel={filterModel} columnSortModel={columnSortModel} filterOnChangeCallback={handleSavedFilterChange} />
}
</Box>
<Box display="flex" width="150px">
@ -1794,7 +1856,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
<QCreateNewButton tablePath={metaData?.getTablePathByName(tableName)} />
}
</Box>
<Card>
<Box height="100%">
@ -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}

View File

@ -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;
}

View File

@ -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 (<React.Fragment />);
}
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.

View File

@ -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;

View File

@ -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 //

View File

@ -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";
}

View File

@ -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. //
///////////////////
}
}
/*******************************************************************************
**
*******************************************************************************/

View File

@ -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);
}
}

View File

@ -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")