Initial qfmd work to support datetime query expressions from frontend

This commit is contained in:
2023-07-06 18:56:20 -05:00
parent 5686b606d8
commit 50ad1760d5
5 changed files with 476 additions and 22 deletions

View File

@ -0,0 +1,207 @@
/*
* 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 {NowWithOffsetExpression, NowWithOffsetOperator, NowWithOffsetUnit} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression";
import {FormControl, FormControlLabel, Radio, RadioGroup, Select} 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 MenuItem from "@mui/material/MenuItem";
import Modal from "@mui/material/Modal";
import {SelectChangeEvent} from "@mui/material/Select/Select";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import React, {ReactNode, useState} from "react";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
interface Props
{
type: "date" | "datetime";
expression: any;
onSave: (expression: any) => void;
}
AdvancedDateTimeFilterValues.defaultProps = {};
function AdvancedDateTimeFilterValues({type, expression, onSave}: Props): JSX.Element
{
const [originalExpression, setOriginalExpression] = useState(JSON.stringify(expression));
const [expressionType, setExpressionType] = useState(expression?.type ?? "NowWithOffset")
const [amount, setAmount] = useState(expression?.amount ?? 1)
const [timeUnit, setTimeUnit] = useState(expression?.timeUnit ?? "DAYS" as NowWithOffsetUnit);
const [operator, setOperator] = useState(expression?.operator ?? "MINUS" as NowWithOffsetOperator);
const [isOpen, setIsOpen] = useState(false)
//////////////////////////////////////////////////////////////////////////////////
// if the expression (prop) has changed, re-set the state variables based on it //
//////////////////////////////////////////////////////////////////////////////////
if(JSON.stringify(expression) !== originalExpression)
{
setExpressionType(expression?.type ?? "NowWithOffset")
setAmount(expression?.amount ?? 1)
setTimeUnit(expression?.timeUnit ?? "DAYS")
setOperator(expression?.operator ?? "MINUS")
setOriginalExpression(JSON.stringify(expression))
}
const openDialog = () =>
{
setIsOpen(true);
}
const handleSaveClicked = () =>
{
switch(expressionType)
{
case "NowWithOffset":
{
const expression = new NowWithOffsetExpression()
expression.operator = operator;
expression.amount = amount;
expression.timeUnit = timeUnit;
onSave(expression);
}
}
close();
}
const close = () =>
{
setIsOpen(false);
}
function handleExpressionTypeChange(e: React.ChangeEvent<HTMLInputElement>)
{
setExpressionType(e.target.value);
}
function handleAmountChange(event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>)
{
setAmount(parseInt(event.target.value));
}
function handleTimeUnitChange(event: SelectChangeEvent<NowWithOffsetUnit>, child: ReactNode)
{
// @ts-ignore
setTimeUnit(event.target.value)
}
function handleOperatorChange(event: SelectChangeEvent<NowWithOffsetOperator>, child: ReactNode)
{
// @ts-ignore
setOperator(event.target.value)
}
const mainCardStyles: any = {};
mainCardStyles.width = "600px";
/////////////////////////////////////////////////////////////////////////
// for the time units, have them end in an 's' if the amount is plural //
/////////////////////////////////////////////////////////////////////////
const tuS = (amount == 1 ? "" : "s");
return (
<Box>
<Tooltip title={`Define a more advanced ${type == "date" ? "date" : "date-time"} condition`}>
<Icon onClick={openDialog} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}}>settings</Icon>
</Tooltip>
{
isOpen &&
(
<Modal open={isOpen} className="AdvancedDateTimeFilterValues">
<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">Advanced Date Filter Condition</Typography>
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
Select the type of expression you want for your condition.<br />
Then enter values to express your condition.
</Typography>
</Grid>
</Grid>
</Box>
<RadioGroup name="expressionType" value={expressionType} onChange={handleExpressionTypeChange}>
<Box px={4} pb={4}>
<FormControlLabel value={"NowWithOffset"} control={<Radio size="small" />} label="Relative Expression" />
<Box pl={4}>
<FormControl variant="standard" sx={{verticalAlign: "bottom", width: "30%"}}>
<TextField
variant="standard"
type="number"
inputProps={{min: 0}}
autoComplete="off"
value={amount}
onChange={(event) => handleAmountChange(event)}
fullWidth
/>
</FormControl>
<FormControl variant="standard" sx={{verticalAlign: "bottom", width: "30%"}}>
<Select value={timeUnit} disabled={false} onChange={handleTimeUnitChange} label="Unit">
{type == "datetime" && <MenuItem value="SECONDS">Second{tuS}</MenuItem>}
{type == "datetime" && <MenuItem value="MINUTES">Minute{tuS}</MenuItem>}
{type == "datetime" && <MenuItem value="HOURS">Hour{tuS}</MenuItem>}
<MenuItem value="DAYS">Day{tuS}</MenuItem>
<MenuItem value="WEEKS">Week{tuS}</MenuItem>
<MenuItem value="MONTHS">Month{tuS}</MenuItem>
<MenuItem value="YEARS">Year{tuS}</MenuItem>
</Select>
</FormControl>
<FormControl variant="standard" sx={{verticalAlign: "bottom", width: "40%"}}>
<Select value={operator} disabled={false} onChange={handleOperatorChange}>
<MenuItem value="MINUS">Ago (in the past)</MenuItem>
<MenuItem value="PLUS">From now (in the future)</MenuItem>
</Select>
</FormControl>
</Box>
</Box>
</RadioGroup>
<Box p={3} pt={0}>
<Grid container pl={1} pr={1} justifyContent="right" alignItems="stretch" sx={{display: "flex-inline "}}>
<QCancelButton onClickHandler={close} iconName="cancel" disabled={false} />
<QSaveButton onClickHandler={handleSaveClicked} label="Apply" disabled={false} />
</Grid>
</Box>
</Card>
</Box>
</Box>
</Modal>
)
}
</Box>
);
}
export default AdvancedDateTimeFilterValues;

View File

@ -159,7 +159,9 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
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 on or after", value: QCriteriaOperator.GREATER_THAN_OR_EQUALS, valueMode: ValueMode.SINGLE_DATE});
operatorOptions.push({label: "is before", value: QCriteriaOperator.LESS_THAN, valueMode: ValueMode.SINGLE_DATE});
operatorOptions.push({label: "is on or before", value: QCriteriaOperator.LESS_THAN_OR_EQUALS, 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});
@ -171,9 +173,9 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
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 at 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 at 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});
@ -335,8 +337,24 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
//////////////////////////////////////////////////
// event handler for value field (of all types) //
//////////////////////////////////////////////////
const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) =>
const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any, newExpression?: any) =>
{
///////////////////////////////////////////////////////////////////////////////////////////////////////
// if an expression was passed in - put it on the criteria, removing the values. //
// else - if no expression - make sure criteria.expression is null, and do the various values logics //
///////////////////////////////////////////////////////////////////////////////////////////////////////
if(newExpression)
{
criteria.expression = newExpression;
criteria.values = null;
updateCriteria(criteria, true);
return;
}
else
{
criteria.expression = null;
}
// @ts-ignore
const value = newValue !== undefined ? newValue : event ? event.target.value : null;
@ -447,6 +465,12 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
// don't need to look at values //
//////////////////////////////////
}
else if (criteria.expression)
{
////////////////////////////////////////////////////////
// if there's an expression - let's assume it's valid //
////////////////////////////////////////////////////////
}
else if(operatorSelectedValue.valueMode == ValueMode.DOUBLE)
{
if(criteria.values.length < 2)
@ -533,7 +557,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
criteria={{id: id, ...criteria}}
field={field}
table={fieldTable}
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
valueChangeHandler={(event, valueIndex, newValue, newExpression) => handleValueChange(event, valueIndex, newValue, newExpression)}
/>
</Box>
<Box display="inline-block" pl={0.5} pr={1}>

View File

@ -23,14 +23,21 @@
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 {NowWithOffsetExpression, NowWithOffsetOperator, NowWithOffsetUnit} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression";
import {ThisOrLastPeriodExpression, ThisOrLastPeriodOperator, ThisOrLastPeriodUnit} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression";
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 Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Select, {SelectChangeEvent} from "@mui/material/Select/Select";
import TextField from "@mui/material/TextField";
import React, {SyntheticEvent, useReducer} from "react";
import Tooltip from "@mui/material/Tooltip";
import React, {ReactNode, SyntheticEvent, useReducer, useState} from "react";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import AdvancedDateTimeFilterValues from "qqq/components/query/AdvancedDateTimeFilterValues";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import FilterCriteriaPaster from "qqq/components/query/FilterCriteriaPaster";
import {OperatorOption, ValueMode} from "qqq/components/query/FilterCriteriaRow";
@ -42,7 +49,7 @@ interface Props
criteria: QFilterCriteriaWithId;
field: QFieldMetaData;
table: QTableMetaData;
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any, newExpression?: any) => void;
}
FilterCriteriaRowValues.defaultProps = {
@ -50,6 +57,8 @@ FilterCriteriaRowValues.defaultProps = {
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler}: Props): JSX.Element
{
const [relativeDateTimeMenuAnchorElement, setRelativeDateTimeMenuAnchorElement] = useState(null);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
if (!operatorOption)
@ -122,6 +131,35 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
/>;
};
const makeDateTimeExpressionTextField = (value: string, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
{
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
{
valueChangeHandler(event, index, "");
forceUpdate()
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"
InputProps={{readOnly: true, unselectable: "off", ...inputProps}}
value={value}
fullWidth
/>;
}
function saveNewPasterValues(newValues: any[])
{
if (criteria.values)
@ -145,6 +183,47 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
forceUpdate();
}
const openRelativeDateTimeMenu = (event: React.MouseEvent<HTMLElement>) =>
{
setRelativeDateTimeMenuAnchorElement(event.currentTarget);
};
const closeRelativeDateTimeMenu = () =>
{
setRelativeDateTimeMenuAnchorElement(null);
};
const setExpressionNowWithOffset = (operator: NowWithOffsetOperator, amount: number, timeUnit: NowWithOffsetUnit) =>
{
const expression = new NowWithOffsetExpression()
expression.operator = operator;
expression.amount = amount;
expression.timeUnit = timeUnit;
saveNewDateTimeExpression(expression);
closeRelativeDateTimeMenu();
};
const setExpressionThisOrLastPeriod = (operator: ThisOrLastPeriodOperator, timeUnit: ThisOrLastPeriodUnit) =>
{
const expression = new ThisOrLastPeriodExpression()
expression.operator = operator;
expression.timeUnit = timeUnit;
saveNewDateTimeExpression(expression);
closeRelativeDateTimeMenu();
};
function saveNewDateTimeExpression(expression: any)
{
criteria.expression = expression;
criteria.values = null;
valueChangeHandler(null, null, null, expression);
forceUpdate();
}
switch (operatorOption.valueMode)
{
case ValueMode.NONE:
@ -152,9 +231,87 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
case ValueMode.SINGLE:
return makeTextField();
case ValueMode.SINGLE_DATE:
return makeTextField();
return <Box display="flex" alignItems="flex-end">
{
criteria.expression == null && makeTextField()
}
{
criteria.expression != null && makeDateTimeExpressionTextField(criteria.expression.toString())
}
<Box>
<Tooltip title="Choose a common relative date-time expression" placement="top">
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}} onClick={openRelativeDateTimeMenu}>event_upcoming</Icon>
</Tooltip>
<Menu
open={relativeDateTimeMenuAnchorElement}
anchorEl={relativeDateTimeMenuAnchorElement}
transformOrigin={{horizontal: "center", vertical: "top"}}
onClose={closeRelativeDateTimeMenu}
>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 1, "DAYS")}>1 day ago</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("THIS", "DAYS")}>today</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("LAST", "DAYS")}>yesterday</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 7, "DAYS")}>7 days ago</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("THIS", "WEEKS")}>start of this week</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("LAST", "WEEKS")}>start of last week</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 14, "DAYS")}>14 days ago</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 30, "DAYS")}>30 days ago</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("THIS", "MONTHS")}>start of this month</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("LAST", "MONTHS")}>start of last month</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 90, "DAYS")}>90 days ago</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 180, "DAYS")}>180 days ago</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 1, "YEARS")}>1 year ago</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("THIS", "YEARS")}>start of this year</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("LAST", "YEARS")}>start of last year</MenuItem>
</Menu>
</Box>
<Box>
<AdvancedDateTimeFilterValues type={"date"} expression={criteria.expression} onSave={(expression: any) => saveNewDateTimeExpression(expression)} />
</Box>
</Box>;
case ValueMode.SINGLE_DATE_TIME:
return makeTextField();
return <Box display="flex" alignItems="flex-end">
{
criteria.expression == null && makeTextField()
}
{
criteria.expression != null && makeDateTimeExpressionTextField(criteria.expression.toString())
}
<Box>
<Tooltip title="Choose a common relative date-time expression" placement="top">
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}} onClick={openRelativeDateTimeMenu}>event_upcoming</Icon>
</Tooltip>
<Menu
open={relativeDateTimeMenuAnchorElement}
anchorEl={relativeDateTimeMenuAnchorElement}
transformOrigin={{horizontal: "center", vertical: "top"}}
onClose={closeRelativeDateTimeMenu}
>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 1, "HOURS")}>1 hour ago</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("THIS", "HOURS")}>start of this hour</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("LAST", "HOURS")}>start of last hour</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 12, "HOURS")}>12 hours ago</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 24, "HOURS")}>24 hours ago</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("THIS", "DAYS")}>start of today</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("LAST", "DAYS")}>start of yesterday</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 7, "DAYS")}>7 days ago</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("THIS", "WEEKS")}>start of this week</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("LAST", "WEEKS")}>start of last week</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 14, "DAYS")}>14 days ago</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 30, "DAYS")}>30 days ago</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("THIS", "MONTHS")}>start of this month</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("LAST", "MONTHS")}>start of last month</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 90, "DAYS")}>90 days ago</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 180, "DAYS")}>180 days ago</MenuItem>
<MenuItem onClick={() => setExpressionNowWithOffset("MINUS", 1, "YEARS")}>1 year ago</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("THIS", "YEARS")}>start of this year</MenuItem>
<MenuItem onClick={() => setExpressionThisOrLastPeriod("LAST", "YEARS")}>start of last year</MenuItem>
</Menu>
</Box>
<Box>
<AdvancedDateTimeFilterValues type={"datetime"} expression={criteria.expression} onSave={(expression: any) => saveNewDateTimeExpression(expression)} />
</Box>
</Box>;
case ValueMode.DOUBLE:
return <Box>
<Box width="50%" display="inline-block">