Compare commits

...

25 Commits

Author SHA1 Message Date
b1eba925fa Add idb - library for working with IndexedDB. 2024-02-27 18:19:21 -06:00
e7b5821fbd Switch to write recordIds/queryFilter to IndexedDB when launching process (instead of passing on query string). 2024-02-27 18:18:48 -06:00
aed1c9d4d0 Update to POST (as multipart form) instead of query-string - so yuge filters work. 2024-02-27 18:17:51 -06:00
88a4c17bbc CE-798 follow-up - in cleanupValuesInFilerFromQueryString, don't try to translate [null] to a list of possible values (which fetches all of them)... 2024-02-27 13:57:04 -06:00
2900cd8593 CE-798 follow-up - Prevent tab in date/date-time filter value input boxes from closing a quick-filter menu (via an onKeyDown handler) 2024-02-27 13:35:37 -06:00
8ab0f5f549 CE-798 follow-up - increase width of date-time boxes (was too small to see all the fields!) 2024-02-27 11:56:54 -06:00
8cffbbcac4 Update to cimg/openjdk:17.0.9 2024-02-22 20:30:51 -06:00
37eb280d79 Revert to previous check for disabling export-menu items - that is - totalRecords === 0, instead of !totalRecords. makes tables w/o count allowed to do exports again. 2024-02-22 12:16:27 -06:00
948aee70fd Disable 'c' hotkey for columns (broke with buiding custom columsn button instead of using data grid's) 2024-02-21 19:43:20 -06:00
f0c1af18d0 Fix last commit (shouldn't add layout to ChartSubheaderWithData) 2024-02-21 19:42:00 -06:00
fa65d6c0ad Add cursor:pointer to PieChart and StackedBarChart 2024-02-21 19:18:34 -06:00
d6c9bf79b1 Update circleci/browser-tools@1.4.7 and try to re-activate verify 2024-02-19 14:49:55 -06:00
677b93a09f Turn off broken selenium/int tests 2024-02-19 14:49:02 -06:00
314bf0fd67 Fix to clear out limit when using a select-all filter for launching processes 2024-02-19 13:47:04 -06:00
76642f13e9 HOTFIX - Change defaultRowsPerPage from 10 to 50 2024-02-16 11:23:31 -06:00
0eaf171523 Merge pull request #43 from Kingsrook/feature/CE-798-quick-filters-fixes
Feature/ce 798 quick filters fixes
2024-02-16 10:56:15 -06:00
b137b3346d CE-798 - A fix for last change here, where if criteria came from outside this component, then changing operator wouldn't take - another check of isOpen in some of the reset code. 2024-02-16 10:09:42 -06:00
63479ba282 CE-798 - Update to not change the criteria in the query (e.g., not fire a new query) until user clicks out of menu 2024-02-15 20:24:05 -06:00
967c557a58 CE-798 - fix typing 'o' bug in quick filters; add missing 'start of' in expression toString calls 2024-02-15 20:21:47 -06:00
fc45b5bed8 ES-84, ES-85 - fix boolean operators, trimming away values (don't trim if implicit values) w/ tests; fix to pre-filter-for-backend before launching processes; 2024-02-13 11:51:31 -06:00
b6e204aa7e Merge pull request #42 from Kingsrook/feature/CE-798-quick-filters
Feature/ce 798 quick filters
2024-02-12 11:05:46 -06:00
a5569900b4 CE-798 change style of on-button critetria descriptions (don't say equals or in; only show 1 value for in-lists, then +x) 2024-02-09 15:40:33 -06:00
0ca6f36bc2 CE-798 more flexibility (+1 style) in getValuesString 2024-02-09 15:39:18 -06:00
7a32d20acb CE-798 Disable if totalRecords is null too 2024-02-09 15:39:00 -06:00
886eea8e88 Update label for column-stats on date-time to state that they'll be grouped by hour (corresponding change on qqq backend side) 2024-01-31 09:57:14 -06:00
18 changed files with 656 additions and 144 deletions

View File

@ -2,12 +2,12 @@ version: 2.1
orbs:
node: circleci/node@5.1.0
browser-tools: circleci/browser-tools@1.4.6
browser-tools: circleci/browser-tools@1.4.7
executors:
java17:
docker:
- image: 'cimg/openjdk:17.0'
- image: 'cimg/openjdk:17.0.9'
commands:
install_java17:

View File

@ -33,6 +33,7 @@
"html-react-parser": "1.4.8",
"html-to-text": "^9.0.5",
"http-proxy-middleware": "2.0.6",
"idb": "8.0.0",
"jwt-decode": "3.1.2",
"rapidoc": "9.3.4",
"react": "18.0.0",

View File

@ -354,8 +354,7 @@ const CommandMenu = ({metaData}: Props) =>
<Grid container columnSpacing={5} rowSpacing={1}>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>n</span>Create a New Record</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>r</span>Refresh the Query</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>c</span>Open the Columns Panel</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>f</span>Open the Filter Panel</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>f</span>Open the Filter Builder (Advanced mode only)</Grid>
</Grid>
<Typography variant="h6" pt={3}>Record View</Typography>

View File

@ -398,7 +398,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
criteria.values = [];
}
if(newValue.valueMode)
if(newValue.valueMode && !newValue.implicitValues)
{
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
if(requiredValueCount != null && criteria.values.length > requiredValueCount)

View File

@ -94,6 +94,24 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
document.getElementById(`${idPrefix}${criteria.id}`).focus();
};
/*******************************************************************************
** Event handler for key-down events - specifically added here, to stop pressing
** 'tab' in a date or date-time from closing the quick-filter...
*******************************************************************************/
const handleKeyDown = (e: any) =>
{
if (field.type == QFieldType.DATE || field.type == QFieldType.DATE_TIME)
{
if(e.code == "Tab")
{
console.log("Tab on date or date-time - don't close me, just move to the next sub-field!...");
e.stopPropagation();
}
}
};
const inputProps: any = {};
inputProps.endAdornment = (
<InputAdornment position="end">
@ -110,6 +128,7 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
autoComplete="off"
type={type}
onChange={(event) => valueChangeHandler(event, valueIndex)}
onKeyDown={handleKeyDown}
value={value}
InputLabelProps={inputLabelProps}
InputProps={inputProps}

View File

@ -30,7 +30,7 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import TextField from "@mui/material/TextField";
import React, {SyntheticEvent, useContext, useState} from "react";
import React, {SyntheticEvent, useContext, useReducer, useState} from "react";
import QContext from "QContext";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import {getDefaultCriteriaValue, getOperatorOptions, getValueModeRequiredCount, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow";
@ -148,7 +148,10 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const [anchorEl, setAnchorEl] = useState(null);
const [isMouseOver, setIsMouseOver] = useState(false);
const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? criteriaParam as QFilterCriteriaWithId : null);
////////////////////////////////////////////////////////////////////////////////////////////////////////
// copy the criteriaParam to a new object in here - so changes won't apply until user closes the menu //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? Object.assign({}, criteriaParam) as QFilterCriteriaWithId : null);
const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId);
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator));
@ -158,6 +161,11 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const {accentColor} = useContext(QContext);
//////////////////////
// ole' faithful... //
//////////////////////
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/*******************************************************************************
**
@ -182,15 +190,30 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (criteriaParamIsCriteria(criteriaParam) && JSON.stringify(criteriaParam) !== JSON.stringify(criteria))
{
const newCriteria = criteriaParam as QFilterCriteriaWithId;
setCriteria(newCriteria);
const operatorOption = operatorOptions.filter(o => o.value == newCriteria.operator)[0];
setOperatorSelectedValue(operatorOption);
setOperatorInputValue(operatorOption.label);
if(isOpen)
{
////////////////////////////////////////////////////////////////////////////////
// this was firing too-often for case where: there was a criteria originally //
////////////////////////////////////////////////////////////////////////////////
console.log("Not handling outside change (A), because dropdown is-open");
}
else
{
////////////////////////////////////////////////////////////////////////////////////////////////////////
// copy the criteriaParam to a new object in here - so changes won't apply until user closes the menu //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const newCriteria = Object.assign({}, criteriaParam) as QFilterCriteriaWithId;
setCriteria(newCriteria);
const operatorOption = operatorOptions.filter(o => o.value == newCriteria.operator)[0];
setOperatorSelectedValue(operatorOption);
setOperatorInputValue(operatorOption.label);
}
}
/*******************************************************************************
** Test if we need to construct a new criteria object
** This is (at least for some cases) for when the criteria gets changed
** from outside of this component - e.g., a reset on the query screen
*******************************************************************************/
const criteriaNeedsReset = (): boolean =>
{
@ -199,6 +222,16 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const defaultOperatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0];
if(criteria.operator !== defaultOperatorOption?.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue()))
{
if(isOpen)
{
//////////////////////////////////////////////////////////////////////////////////
// this was firing too-often for case where: there was no criteria originally, //
// so, by adding this is-open check, we eliminated those. //
//////////////////////////////////////////////////////////////////////////////////
console.log("Not handling outside change (B), because dropdown is-open");
return (false);
}
return (true);
}
}
@ -207,7 +240,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
}
/*******************************************************************************
** Construct a new criteria object - resetting the values tied to the oprator
** Construct a new criteria object - resetting the values tied to the operator
** autocomplete at the same time.
*******************************************************************************/
const makeNewCriteria = (): QFilterCriteria =>
@ -241,6 +274,11 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
*******************************************************************************/
const closeMenu = () =>
{
//////////////////////////////////////////////////////////////////////////////////
// when closing the menu, that's when we'll update the criteria from the caller //
//////////////////////////////////////////////////////////////////////////////////
updateCriteria(criteria, false, false);
setIsOpen(false);
setAnchorEl(null);
};
@ -271,7 +309,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
criteria.values = [];
}
if(newValue.valueMode)
if(newValue.valueMode && !newValue.implicitValues)
{
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
if(requiredValueCount != null && criteria.values.length > requiredValueCount)
@ -286,7 +324,8 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
setOperatorInputValue("");
}
updateCriteria(criteria, false, false);
setCriteria(criteria);
forceUpdate();
};
/*******************************************************************************
@ -320,7 +359,8 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
criteria.values[valueIndex] = value;
}
updateCriteria(criteria, true, false);
setCriteria(criteria);
forceUpdate();
};
/*******************************************************************************
@ -400,16 +440,18 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
buttonAdditionalStyles.color = "white !important";
buttonClassName = "filterActive";
let valuesString = FilterUtils.getValuesString(fieldMetaData, criteria);
if(fieldMetaData.type == QFieldType.BOOLEAN)
let valuesString = FilterUtils.getValuesString(fieldMetaData, criteria, 1, "+N");
///////////////////////////////////////////
// don't show the Equals or In operators //
///////////////////////////////////////////
let operatorString = (<>{operatorSelectedValue.label}&nbsp;</>);
if(operatorSelectedValue.value == QCriteriaOperator.EQUALS || operatorSelectedValue.value == QCriteriaOperator.IN)
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for booleans, in here, the operator-label is "equals yes" or "equals no", so we don't want the values string //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
valuesString = "";
operatorString = (<></>)
}
buttonContent = (<><span style={{fontWeight: 700}}>{buttonContent}:</span>&nbsp;<span style={{fontWeight: 400}}>{operatorSelectedValue.label}&nbsp;{valuesString}</span></>);
buttonContent = (<><span style={{fontWeight: 700}}>{buttonContent}:</span>&nbsp;<span style={{fontWeight: 400}}>{operatorString}{valuesString}</span></>);
}
const mouseEvents =
@ -462,7 +504,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
//////////////////////////////
// return the button & menu //
//////////////////////////////
const widthAndMaxWidth = 250
const widthAndMaxWidth = fieldMetaData?.type == QFieldType.DATE_TIME ? 275 : 250
return (
<>
{button}
@ -478,7 +520,12 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
<Box display="inline-block" width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="operatorColumn">
<Autocomplete
id={"criteriaOperator"}
renderInput={(params) => (<TextField {...params} label={"Operator"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
////////////////////////////////////////////////////////////////////////////////////////////////////
// ok, so, by default, if you type an 'o' as the first letter in the FilterCriteriaRowValues box, //
// something is causing THIS element to become selected, if the first letter in its label is 'O'. //
// ... work around is to put invisible &zwnj; entity as first character in label instead... //
////////////////////////////////////////////////////////////////////////////////////////////////////
renderInput={(params) => (<TextField {...params} label={<>&zwnj;Operator</>} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
options={operatorOptions}
value={operatorSelectedValue as any}
inputValue={operatorInputValue}

View File

@ -39,65 +39,79 @@ ChartJS.register(
Legend
);
export const options = {
maintainAspectRatio: false,
responsive: true,
animation: {
duration: 0
},
elements: {
bar: {
borderRadius: 4
}
},
plugins: {
tooltip: {
// todo - some configs around this
callbacks: {
title: function(context: any)
{
return ("");
},
label: function(context: any)
{
if(context.dataset.label.startsWith(context.label))
{
return `${context.label}: ${context.formattedValue}`;
}
else
export const makeOptions = (data: DefaultChartData) =>
{
return({
maintainAspectRatio: false,
responsive: true,
animation: {
duration: 0
},
elements: {
bar: {
borderRadius: 4
}
},
onHover: function (event: any, elements: any[], chart: any)
{
if(event.type == "mousemove" && elements.length > 0 && data.urls && data.urls.length > elements[0].index && data.urls[elements[0].index])
{
chart.canvas.style.cursor = "pointer";
}
else
{
chart.canvas.style.cursor = "default";
}
},
plugins: {
tooltip: {
// todo - some configs around this
callbacks: {
title: function(context: any)
{
return ("");
},
label: function(context: any)
{
if(context.dataset.label.startsWith(context.label))
{
return `${context.label}: ${context.formattedValue}`;
}
else
{
return ("");
}
}
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
boxHeight: 6,
boxWidth: 6,
padding: 12,
font: {
size: 14
}
}
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
boxHeight: 6,
boxWidth: 6,
padding: 12,
font: {
size: 14
}
}
}
},
scales: {
x: {
stacked: true,
grid: {display: false},
ticks: {autoSkip: false, maxRotation: 90}
scales: {
x: {
stacked: true,
grid: {display: false},
ticks: {autoSkip: false, maxRotation: 90}
},
y: {
stacked: true,
position: "right",
ticks: {precision: 0}
},
},
y: {
stacked: true,
position: "right",
ticks: {precision: 0}
},
},
};
});
}
interface Props
{
@ -151,7 +165,7 @@ function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
<Box>
{chartSubheaderData && (<ChartSubheaderWithData chartSubheaderData={chartSubheaderData} />)}
<Box width="100%" height="300px">
<Bar data={data} options={options} getElementsAtEvent={handleClick} />
<Bar data={data} options={makeOptions(data)} getElementsAtEvent={handleClick} />
</Box>
</Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "200px"}} />;

View File

@ -70,7 +70,7 @@ function PieChart({description, chartData, chartSubheaderData}: Props): JSX.Elem
chartData.dataset.backgroundColors = chartColors;
}
}
const {data, options} = configs(chartData?.labels || [], chartData?.dataset || {});
const {data, options} = configs(chartData?.labels || [], chartData?.dataset || {}, chartData?.dataset?.urls);
useEffect(() =>
{

View File

@ -23,7 +23,7 @@ import colors from "qqq/assets/theme/base/colors";
const {gradients, dark} = colors;
function configs(labels: any, datasets: any)
function configs(labels: any, datasets: any, urls: string[] | undefined)
{
const backgroundColors = [];
@ -66,6 +66,17 @@ function configs(labels: any, datasets: any)
options: {
maintainAspectRatio: false,
responsive: true,
onHover: function (event: any, elements: any[], chart: any)
{
if(event.type == "mousemove" && elements.length > 0 && urls && urls.length > elements[0].index && urls[elements[0].index])
{
chart.canvas.style.cursor = "pointer";
}
else
{
chart.canvas.style.cursor = "default";
}
},
plugins: {
tooltip: {
callbacks: {

View File

@ -1089,19 +1089,17 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
(async () =>
{
const formData = new FormData();
const urlSearchParams = new URLSearchParams(location.search);
let queryStringPairsForInit = [];
if (urlSearchParams.get("recordIds"))
{
const recordIdsFromQueryString = urlSearchParams.get("recordIds").split(",");
const encodedRecordIds = recordIdsFromQueryString.map(r => encodeURIComponent(r)).join(",");
queryStringPairsForInit.push("recordsParam=recordIds");
queryStringPairsForInit.push(`recordIds=${encodedRecordIds}`);
formData.append("recordsParam", "recordIds")
formData.append("recordIds", urlSearchParams.get("recordIds"))
}
else if (urlSearchParams.get("filterJSON"))
{
queryStringPairsForInit.push("recordsParam=filterJSON");
queryStringPairsForInit.push(`filterJSON=${encodeURIComponent(urlSearchParams.get("filterJSON"))}`);
formData.append("recordsParam", "filterJSON")
formData.append("filterJSON", urlSearchParams.get("filterJSON"));
}
// todo once saved filters exist
//else if(urlSearchParams.get("filterId")) {
@ -1110,23 +1108,23 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
// }
else if (recordIds)
{
if (recordIds instanceof QQueryFilter)
// @ts-ignore - we're checking to see if recordIds is a QQueryFilter-looking object here.
if (recordIds instanceof QQueryFilter || (typeof recordIds === "object" && recordIds.criteria))
{
queryStringPairsForInit.push("recordsParam=filterJSON");
queryStringPairsForInit.push(`filterJSON=${encodeURIComponent(JSON.stringify(recordIds))}`);
formData.append("recordsParam", "filterJSON")
formData.append("filterJSON", JSON.stringify(recordIds));
}
else if (typeof recordIds === "object" && recordIds.length)
{
const encodedRecordIds = recordIds.map(r => encodeURIComponent(r)).join(",");
queryStringPairsForInit.push("recordsParam=recordIds");
queryStringPairsForInit.push(`recordIds=${encodedRecordIds}`);
formData.append("recordsParam", "recordIds")
formData.append("recordIds", recordIds.join(","))
}
}
if (tableVariantLocalStorageKey && localStorage.getItem(tableVariantLocalStorageKey))
{
let tableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey));
queryStringPairsForInit.push(`tableVariant=${encodeURIComponent(JSON.stringify(tableVariant))}`);
formData.append("tableVariant", JSON.stringify(tableVariant));
}
try
@ -1170,18 +1168,18 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{
for (let key in defaultProcessValues)
{
queryStringPairsForInit.push(`${key}=${encodeURIComponent(defaultProcessValues[key])}`);
formData.append(key, defaultProcessValues[key]);
}
}
if (tableMetaData)
{
queryStringPairsForInit.push(`tableName=${encodeURIComponent(tableMetaData.name)}`);
formData.append("tableName", tableMetaData.name);
}
try
{
const processResponse = await Client.getInstance().processInit(processName, queryStringPairsForInit.join("&"));
const processResponse = await Client.getInstance().processInit(processName, formData);
setProcessUUID(processResponse.processUUID);
setLastProcessResponse(processResponse);
}

View File

@ -21,6 +21,7 @@
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
@ -146,6 +147,11 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
const rows = DataGridUtils.makeRows(valueCounts, fakeTableMetaData);
const columns = DataGridUtils.setupGridColumns(fakeTableMetaData, null, null, "bySection");
if(fieldMetaData.type == QFieldType.DATE_TIME)
{
columns[0].headerName = fieldMetaData.label + " (grouped by hour)"
}
columns.forEach((c) =>
{
c.filterable = false;

View File

@ -44,9 +44,10 @@ import LinearProgress from "@mui/material/LinearProgress";
import MenuItem from "@mui/material/MenuItem";
import Modal from "@mui/material/Modal";
import Tooltip from "@mui/material/Tooltip";
import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnHeaderParams, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} from "@mui/x-data-grid-pro";
import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnHeaderParams, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnVisibilityModel, GridDensity, GridEventListener, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarContainer, GridToolbarDensitySelector, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} from "@mui/x-data-grid-pro";
import {GridRowModel} from "@mui/x-data-grid/models/gridRows";
import FormData from "form-data";
import {IDBPDatabase, openDB} from "idb";
import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react";
import {useLocation, useNavigate, useSearchParams} from "react-router-dom";
import QContext from "QContext";
@ -101,6 +102,13 @@ RecordQuery.defaultProps = {
///////////////////////////////////////////////////////
type PageState = "initial" | "loadingMetaData" | "loadedMetaData" | "loadingView" | "loadedView" | "preparingGrid" | "ready";
//////////////////////////////////////////
// define IndexedDB store & field names //
//////////////////////////////////////////
const recordIdsForProcessesDBName = "qqq.recordIdsForProcesses";
const recordIdsForProcessesStoreName = "recordIdsForProcesses";
const timestampIndexAndFieldName = "timestamp";
const qController = Client.getInstance();
/*******************************************************************************
@ -114,7 +122,6 @@ const getLoadingScreen = () =>
</BaseLayout>);
}
/*******************************************************************************
** QQQ Record Query Screen component.
**
@ -168,7 +175,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
// define some default values (e.g., to be used if nothing in local storage or no active view) //
/////////////////////////////////////////////////////////////////////////////////////////////////
let defaultSort = [] as GridSortItem[];
let defaultRowsPerPage = 10;
let defaultRowsPerPage = 50;
let defaultDensity = "standard" as GridDensity;
let defaultTableVariant: QTableVariant = null;
let defaultMode = "basic";
@ -610,11 +617,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
e.preventDefault()
updateTable("'r' keyboard event");
}
/*
// disable until we add a ... ref down to let us programmatically open Columns button
else if (! e.metaKey && e.key === "c")
{
e.preventDefault()
gridApiRef.current.showPreferences(GridPreferencePanelsValue.columns)
}
*/
else if (! e.metaKey && e.key === "f")
{
e.preventDefault()
@ -1380,12 +1390,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{
if (selectFullFilterState === "filter")
{
return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(queryFilter))}`;
const filterForBackend = prepQueryFilterForBackend(queryFilter);
filterForBackend.skip = 0;
filterForBackend.limit = null;
return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(filterForBackend))}`;
}
if (selectFullFilterState === "filterSubset")
{
return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(queryFilter))}`;
const filterForBackend = prepQueryFilterForBackend(queryFilter);
filterForBackend.skip = 0;
filterForBackend.limit = selectionSubsetSize;
return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(filterForBackend))}`;
}
if (selectedIds.length > 0)
@ -1398,36 +1414,142 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
/*******************************************************************************
** launch/open a modal process. Ends up navigating to the process's path w/
** records selected via query string.
** For the various functions that work with the recordIdsForProcess indexedDB,
** open that database (creating if needed).
*******************************************************************************/
const openModalProcess = (process: QProcessMetaData = null) =>
const openRecordIdsForProcessIndexedDB = async () =>
{
return await openDB(recordIdsForProcessesDBName, 1, {
upgrade(db)
{
const store = db.createObjectStore(recordIdsForProcessesStoreName, {
keyPath: "uuid"
});
store.createIndex(timestampIndexAndFieldName, timestampIndexAndFieldName);
}
});
}
/*******************************************************************************
** clean up old indexedDB records that were created to launch processes in the past.
*******************************************************************************/
const manageRecordIdsForProcessIndexedDB = async (db: IDBPDatabase) =>
{
try
{
const now = new Date().getTime();
const limit = now - (1000 * 60 * 60 * 24 * 7); // now minus 1 week
const expiredKeys = await db.getAllKeysFromIndex(recordIdsForProcessesStoreName, timestampIndexAndFieldName, IDBKeyRange.upperBound(limit, true));
for (let expiredKey of expiredKeys)
{
db.delete(recordIdsForProcessesStoreName, expiredKey);
}
}
catch(e)
{
console.log("Error managing recordIdsForProcess in indexeddb: " + e);
}
}
/*******************************************************************************
** we used to pass recordIds (that is, either an array of ids (from checkboxes)
** or a json-query-filter) on the query string... but that gets too big, so,
** instead, write it ... not to local storage (5MB limit, and we want to keep
** them around for ... some period of time, for reloading processes), so,
** write it to indexedDB instead.
*******************************************************************************/
const storeRecordIdsForProcessInIndexedDB = async (recordIds: string[] | QQueryFilter): Promise<string> =>
{
const uuid = crypto.randomUUID()
let db = await openRecordIdsForProcessIndexedDB();
await db.add(recordIdsForProcessesStoreName, {
uuid: uuid,
json: JSON.stringify(recordIds),
timestamp: new Date().getTime()
});
//////////////////////////////////////////////////////////////////////////////////////////////
// we shouldn't need to await this function - it's just good to run the cleanup "sometimes" //
//////////////////////////////////////////////////////////////////////////////////////////////
manageRecordIdsForProcessIndexedDB(db);
return (uuid);
}
/*******************************************************************************
** when launching a process, if we're to use recordIds(/filter) from indexedDB,
** then do that read (async), and open the modal process after it completes.
*******************************************************************************/
const launchModalProcessUsingRecordIdsFromIndexedDB = async (uuid: string, processMetaData: QProcessMetaData): Promise<void> =>
{
let db = await openRecordIdsForProcessIndexedDB();
const recordIds = await db.get(recordIdsForProcessesStoreName, uuid)
if(recordIds)
{
const recordIdsObject = JSON.parse(recordIds.json);
setRecordIdsForProcess(recordIdsObject);
setActiveModalProcess(processMetaData);
}
else
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// closing the process will do a navigate - so we can't just set an alert, we need it to be passed in the navigation call as state - //
// so pass it as a param to closeModalProcess, which will set it in the navigation state. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
closeModalProcess({}, "failed to start", "Could not find query filter to start this process. Please try again.");
}
}
/*******************************************************************************
** launch/open a modal process. Writes the records (ids or filter) to indexedDB,
** identified by a UUID. Then navigates to the process's path w/
** that UUID in the query string.
*******************************************************************************/
const openModalProcess = async (process: QProcessMetaData = null) =>
{
let uuid = "";
if (selectFullFilterState === "filter")
{
setRecordIdsForProcess(queryFilter);
const filterForBackend = prepQueryFilterForBackend(queryFilter);
filterForBackend.skip = 0;
filterForBackend.limit = null;
setRecordIdsForProcess(filterForBackend);
uuid = await storeRecordIdsForProcessInIndexedDB(filterForBackend);
}
else if (selectFullFilterState === "filterSubset")
{
setRecordIdsForProcess(new QQueryFilter(queryFilter.criteria, queryFilter.orderBys, queryFilter.booleanOperator, 0, selectionSubsetSize));
const filterForBackend = prepQueryFilterForBackend(queryFilter);
filterForBackend.skip = 0;
filterForBackend.limit = selectionSubsetSize;
setRecordIdsForProcess(filterForBackend);
uuid = await storeRecordIdsForProcessInIndexedDB(filterForBackend);
}
else if (selectedIds.length > 0)
{
setRecordIdsForProcess(selectedIds);
uuid = await storeRecordIdsForProcessInIndexedDB(selectedIds);
}
else
{
setRecordIdsForProcess([]);
uuid = await storeRecordIdsForProcessInIndexedDB([]);
}
navigate(`${metaData?.getTablePathByName(tableName)}/${process.name}${getRecordsQueryString()}`);
navigate(`${metaData?.getTablePathByName(tableName)}/${process.name}?recordsParam=recordsKey&recordsKey=${uuid}`);
};
/*******************************************************************************
** close callback for modal processes
*******************************************************************************/
const closeModalProcess = (event: object, reason: string) =>
const closeModalProcess = (event: object, reason: string, warning?: string) =>
{
if (reason === "backdropClick" || reason === "escapeKeyDown")
{
@ -1439,7 +1561,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
/////////////////////////////////////////////////////////////////////////
const newPath = location.pathname.split("/");
newPath.pop();
navigate(newPath.join("/"));
navigate(newPath.join("/"), warning ? {state: {warning: warning}} : undefined);
updateTable("close modal process");
};
@ -2097,20 +2219,32 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{
if(selectedIndex == 0)
{
///////////////
// this page //
///////////////
programmaticallySelectSomeOrAllRows();
setSelectFullFilterState("checked")
}
else if(selectedIndex == 1)
{
///////////////////////
// full query result //
///////////////////////
programmaticallySelectSomeOrAllRows();
setSelectFullFilterState("filter")
}
else if(selectedIndex == 2)
{
////////////////////////////
// subset of query result //
////////////////////////////
setSelectionSubsetSizePromptOpen(true);
}
else if(selectedIndex == 3)
{
/////////////////////
// clear selection //
/////////////////////
setSelectFullFilterState("n/a")
setRowSelectionModel([]);
setSelectedIds([]);
@ -2306,22 +2440,38 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
/////////////////////////////////////////////////////////////////
if (pathParts[pathParts.length - 2] === tableName)
{
////////////////////////////////////////
// try to find a process by this name //
////////////////////////////////////////
const processName = pathParts[pathParts.length - 1];
const processList = allTableProcesses.filter(p => p.name == processName);
if (processList.length > 0)
let process = processList.length > 0 ? processList[0] : null;
if(!process)
{
setActiveModalProcess(processList[0]);
process = metaData?.processes.get(processName)
}
else if (metaData?.processes.has(processName))
if(process)
{
///////////////////////////////////////////////////////////////////////////////////////
// check for generic processes - should this be a specific attribute on the process? //
///////////////////////////////////////////////////////////////////////////////////////
setActiveModalProcess(metaData?.processes.get(processName));
//////////////////////////////////////////////////////////////////////////////
// check if a recordsKey UUID (e.g., from indexedDB) is in the query string //
//////////////////////////////////////////////////////////////////////////////
const urlSearchParams = new URLSearchParams(location.search);
const uuid = urlSearchParams.get("recordsKey")
if(uuid)
{
await launchModalProcessUsingRecordIdsFromIndexedDB(uuid, processList[0]);
}
else
{
setActiveModalProcess(processList[0]);
}
}
else
{
console.log(`Couldn't find process named ${processName}`);
setWarningAlert(`Couldn't find process named ${processName}`)
}
}
}

View File

@ -64,7 +64,6 @@ class FilterUtils
let rs = [];
for (let i = 0; i < param.length; i++)
{
console.log(param[i]);
if (param[i] && param[i].id && param[i].label)
{
/////////////////////////////////////////////////////////////
@ -115,8 +114,11 @@ class FilterUtils
// e.g., ...values=[1]... //
// but we need them to be possibleValue objects (w/ id & label) so the label //
// can be shown in the filter dropdown. So, make backend call to look them up. //
// also, there are cases where we can get a null or "" as the only value in the //
// values array - avoid sending that to the backend, as it comes back w/ all //
// possible values, and a general "bad time" //
//////////////////////////////////////////////////////////////////////////////////
if (values && values.length > 0)
if (values && values.length > 0 && values[0] !== null && values[0] !== undefined && values[0] !== "")
{
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values);
}
@ -319,7 +321,7 @@ class FilterUtils
** get the values associated with a criteria as a string, e.g., for showing
** in a tooltip.
*******************************************************************************/
public static getValuesString(fieldMetaData: QFieldMetaData, criteria: QFilterCriteria, maxValuesToShow: number = 3): string
public static getValuesString(fieldMetaData: QFieldMetaData, criteria: QFilterCriteria, maxValuesToShow: number = 3, andMoreFormat: "andNOther" | "+N" = "andNOther"): string
{
let valuesString = "";
@ -340,6 +342,10 @@ class FilterUtils
{
maxLoops = maxValuesToShow;
}
else if(maxValuesToShow == 1 && criteria.values.length > 1)
{
maxLoops = 1;
}
for (let i = 0; i < maxLoops; i++)
{
@ -357,7 +363,12 @@ class FilterUtils
else if (value.type == "ThisOrLastPeriod")
{
const expression = new ThisOrLastPeriodExpression(value);
labels.push(expression.toString());
let startOfPrefix = "";
if(fieldMetaData.type == QFieldType.DATE_TIME || expression.timeUnit != "DAYS")
{
startOfPrefix = "start of ";
}
labels.push(`${startOfPrefix}${expression.toString()}`);
}
else if(fieldMetaData.type == QFieldType.BOOLEAN)
{
@ -383,7 +394,16 @@ class FilterUtils
if (maxLoops < criteria.values.length)
{
labels.push(" and " + (criteria.values.length - maxLoops) + " other values.");
const n = criteria.values.length - maxLoops;
switch (andMoreFormat)
{
case "andNOther":
labels.push(` and ${n} other value${n == 1 ? "" : "s"}.`);
break;
case "+N":
labels[labels.length-1] += ` +${n}`;
break;
}
}
valuesString = (labels.join(", "));

View File

@ -29,6 +29,7 @@ import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import com.kingsrook.qqq.backend.core.utils.SleepUtils;
import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.LogManager;
@ -504,7 +505,33 @@ public class QSeleniumLib
*******************************************************************************/
public WebElement waitForSelectorContaining(String cssSelector, String textContains)
{
LOG.debug("Waiting for element matching selector [" + cssSelector + "] containing text [" + textContains + "].");
return (waitForSelectorMatchingPredicate(cssSelector, "containing text [" + textContains + "]", (WebElement element) ->
{
return (element.getText() != null && element.getText().toLowerCase().contains(textContains.toLowerCase()));
}));
}
/*******************************************************************************
**
*******************************************************************************/
public WebElement waitForSelectorContainingTextMatchingRegex(String cssSelector, String regExp)
{
return (waitForSelectorMatchingPredicate(cssSelector, "matching regexp [" + regExp + "]", (WebElement element) ->
{
return (element.getText() != null && element.getText().matches(regExp));
}));
}
/*******************************************************************************
**
*******************************************************************************/
private WebElement waitForSelectorMatchingPredicate(String cssSelector, String description, Function<WebElement, Boolean> predicate)
{
LOG.debug("Waiting for element matching selector [" + cssSelector + "] " + description);
long start = System.currentTimeMillis();
do
@ -514,9 +541,9 @@ public class QSeleniumLib
{
try
{
if(element.getText() != null && element.getText().toLowerCase().contains(textContains.toLowerCase()))
if(predicate.apply(element))
{
LOG.debug("Found element matching selector [" + cssSelector + "] containing text [" + textContains + "].");
LOG.debug("Found element matching selector [" + cssSelector + "] " + description);
Actions actions = new Actions(driver);
actions.moveToElement(element);
conditionallyAutoHighlight(element);
@ -537,7 +564,7 @@ public class QSeleniumLib
}
while(start + (1000 * WAIT_SECONDS) > System.currentTimeMillis());
fail("Failed to find element matching selector [" + cssSelector + "] containing text [" + textContains + "] after [" + WAIT_SECONDS + "] seconds.");
fail("Failed to find element matching selector [" + cssSelector + "] " + description + " after [" + WAIT_SECONDS + "] seconds.");
return (null);
}

View File

@ -22,6 +22,9 @@
package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib;
import java.util.List;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import org.openqa.selenium.By;
import org.openqa.selenium.Keys;
import org.openqa.selenium.WebElement;
@ -103,7 +106,7 @@ public class QueryScreenLib
/*******************************************************************************
**
*******************************************************************************/
public void clickFilterButton()
public void clickFilterBuilderButton()
{
qSeleniumLib.waitForSelectorContaining("BUTTON", "FILTER BUILDER").click();
}
@ -181,10 +184,11 @@ public class QueryScreenLib
}
/*******************************************************************************
**
*******************************************************************************/
public void addAdvancedQueryFilterInput(QSeleniumLib qSeleniumLib, int index, String fieldLabel, String operator, String value, String booleanOperator)
public void addAdvancedQueryFilterInput(int index, String fieldLabel, String operator, String value, String booleanOperator)
{
if(index > 0)
{
@ -221,10 +225,91 @@ public class QueryScreenLib
operatorInput.sendKeys("\n");
qSeleniumLib.waitForMillis(100);
WebElement valueInput = subFormForField.findElement(By.cssSelector(".filterValuesColumn INPUT"));
valueInput.click();
valueInput.sendKeys(value);
qSeleniumLib.waitForMillis(100);
if(StringUtils.hasContent(value))
{
WebElement valueInput = subFormForField.findElement(By.cssSelector(".filterValuesColumn INPUT"));
valueInput.click();
valueInput.sendKeys(value);
qSeleniumLib.waitForMillis(100);
}
}
/*******************************************************************************
**
*******************************************************************************/
public void addBasicFilter(String fieldLabel)
{
qSeleniumLib.waitForSelectorContaining("BUTTON", "Add Filter").click();
qSeleniumLib.waitForSelectorContaining(".fieldListMenuBody-addQuickFilter LI", fieldLabel).click();
qSeleniumLib.clickBackdrop();
}
/*******************************************************************************
**
*******************************************************************************/
public void setBasicFilter(String fieldLabel, String operatorLabel, String value)
{
qSeleniumLib.waitForSelectorContaining("BUTTON", fieldLabel).click();
qSeleniumLib.waitForMillis(250);
qSeleniumLib.waitForSelector("#criteriaOperator").click();
qSeleniumLib.waitForSelectorContaining("LI", operatorLabel).click();
if(StringUtils.hasContent(value))
{
qSeleniumLib.waitForSelector(".filterValuesColumn INPUT").click();
// todo - no, not in a listbox/LI here...
qSeleniumLib.waitForSelectorContaining(".MuiAutocomplete-listbox LI", value).click();
System.out.println(value);
}
qSeleniumLib.clickBackdrop();
}
/*******************************************************************************
**
*******************************************************************************/
public void setBasicFilterPossibleValues(String fieldLabel, String operatorLabel, List<String> values)
{
qSeleniumLib.waitForSelectorContaining("BUTTON", fieldLabel).click();
qSeleniumLib.waitForMillis(250);
qSeleniumLib.waitForSelector("#criteriaOperator").click();
qSeleniumLib.waitForSelectorContaining("LI", operatorLabel).click();
if(CollectionUtils.nullSafeHasContents(values))
{
qSeleniumLib.waitForSelector(".filterValuesColumn INPUT").click();
for(String value : values)
{
qSeleniumLib.waitForSelectorContaining(".MuiAutocomplete-listbox LI", value).click();
}
}
qSeleniumLib.clickBackdrop();
}
/*******************************************************************************
**
*******************************************************************************/
public void waitForAdvancedQueryStringMatchingRegex(String regEx)
{
qSeleniumLib.waitForSelectorContainingTextMatchingRegex(".advancedQueryString", regEx);
}
/*******************************************************************************
**
*******************************************************************************/
public void waitForBasicFilterButtonMatchingRegex(String regEx)
{
qSeleniumLib.waitForSelectorContainingTextMatchingRegex("BUTTON", regEx);
}
}

View File

@ -27,6 +27,7 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib;
import io.javalin.Javalin;
import org.apache.logging.log4j.LogManager;
@ -284,7 +285,6 @@ public class QSeleniumJavalin
do
{
// LOG.debug(" captured paths: " + captured.stream().map(CapturedContext::getPath).collect(Collectors.joining(",")));
for(CapturedContext context : captured)
{
if(context.getPath().equals(path))
@ -301,6 +301,7 @@ public class QSeleniumJavalin
}
while(start + (1000 * WAIT_SECONDS) > System.currentTimeMillis());
LOG.debug(" captured paths: \n " + captured.stream().map(cc -> cc.getPath() + "[" + cc.getBody() + "]").collect(Collectors.joining("\n ")));
fail("Failed to capture a request for path [" + path + "] with body containing [" + bodyContaining + "] after [" + WAIT_SECONDS + "] seconds.");
return (null);
}

View File

@ -82,7 +82,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
qSeleniumLib.waitForSelector("input[value=\"is not empty\"]");
///////////////////////////////
@ -93,7 +93,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
qSeleniumLib.waitForSelector("input[value=\"is between\"]");
qSeleniumLib.waitForSelector("input[value=\"1701\"]");
qSeleniumLib.waitForSelector("input[value=\"74656\"]");
@ -116,7 +116,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
qSeleniumLib.waitForSelector("input[value=\"is any of\"]");
qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "St. Louis");
qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "Chesterfield");
@ -129,7 +129,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
qSeleniumLib.waitForSelector("input[value=\"is after\"]");
qSeleniumLib.waitForSelector("input[value=\"5 days ago\"]");
@ -142,7 +142,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(2);
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
qSeleniumLib.waitForSelector("input[value=\"is at or before\"]");
qSeleniumLib.waitForSelector("input[value=\"start of this year\"]");
qSeleniumLib.waitForSelector("input[value=\"starts with\"]");
@ -165,7 +165,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
qSeleniumLib.waitForSelector("input[value=\"does not equal\"]");
qSeleniumLib.waitForSelector("input[value=\"St. Louis\"]");

View File

@ -22,6 +22,7 @@
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query;
import java.util.List;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib;
@ -48,6 +49,7 @@ public class QueryScreenTest extends QBaseSeleniumTest
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/person/query", "data/person/index.json")
.withRouteToFile("/data/person/variants", "data/person/variants.json")
.withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json")
.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json");
}
@ -64,13 +66,13 @@ public class QueryScreenTest extends QBaseSeleniumTest
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.gotoAdvancedMode();
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
/////////////////////////////////////////////////////////////////////
// open the filter window, enter a value, wait for query to re-run //
/////////////////////////////////////////////////////////////////////
qSeleniumJavalin.beginCapture();
queryScreenLib.addAdvancedQueryFilterInput(qSeleniumLib, 0, "Id", "equals", "1", null);
queryScreenLib.addAdvancedQueryFilterInput(0, "Id", "equals", "1", null);
///////////////////////////////////////////////////////////////////
// assert that query & count both have the expected filter value //
@ -117,11 +119,11 @@ public class QueryScreenTest extends QBaseSeleniumTest
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.gotoAdvancedMode();
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
qSeleniumJavalin.beginCapture();
queryScreenLib.addAdvancedQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or");
queryScreenLib.addAdvancedQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or");
queryScreenLib.addAdvancedQueryFilterInput(0, "First Name", "contains", "Dar", "Or");
queryScreenLib.addAdvancedQueryFilterInput(1, "First Name", "contains", "Jam", "Or");
String expectedFilterContents0 = """
{"fieldName":"firstName","operator":"CONTAINS","values":["Dar"]}""";
@ -137,6 +139,138 @@ public class QueryScreenTest extends QBaseSeleniumTest
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testBasicBooleanOperators()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.addBasicFilter("Is Employed");
testBasicCriteria(queryScreenLib, "Is Employed", "equals yes", null, "(?s).*Is Employed:.*yes.*", """
{"fieldName":"isEmployed","operator":"EQUALS","values":[true]}""");
testBasicCriteria(queryScreenLib, "Is Employed", "equals no", null, "(?s).*Is Employed:.*no.*", """
{"fieldName":"isEmployed","operator":"EQUALS","values":[false]}""");
testBasicCriteria(queryScreenLib, "Is Employed", "is empty", null, "(?s).*Is Employed:.*is empty.*", """
{"fieldName":"isEmployed","operator":"IS_BLANK","values":[]}""");
testBasicCriteria(queryScreenLib, "Is Employed", "is not empty", null, "(?s).*Is Employed:.*is not empty.*", """
{"fieldName":"isEmployed","operator":"IS_NOT_BLANK","values":[]}""");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testBasicPossibleValues()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
String field = "Home City";
queryScreenLib.addBasicFilter(field);
testBasicCriteriaPossibleValues(queryScreenLib, field, "is any of", List.of("St. Louis", "Chesterfield"), "(?s).*" + field + ":.*St. Louis.*\\+1.*", """
{"fieldName":"homeCityId","operator":"IN","values":[1,2]}""");
testBasicCriteriaPossibleValues(queryScreenLib, field, "equals", List.of("Chesterfield"), "(?s).*" + field + ":.*Chesterfield.*", """
{"fieldName":"homeCityId","operator":"EQUALS","values":[2]}""");
testBasicCriteriaPossibleValues(queryScreenLib, field, "is empty", null, "(?s).*" + field + ":.*is empty.*", """
{"fieldName":"homeCityId","operator":"IS_BLANK","values":[]}""");
testBasicCriteriaPossibleValues(queryScreenLib, field, "does not equal", List.of("St. Louis"), "(?s).*" + field + ":.*does not equal.*St. Louis.*", """
{"fieldName":"homeCityId","operator":"NOT_EQUALS_OR_IS_NULL","values":[1]}""");
testBasicCriteriaPossibleValues(queryScreenLib, field, "is none of", List.of("Chesterfield"), "(?s).*" + field + ":.*is none of.*St. Louis.*\\+1", """
{"fieldName":"homeCityId","operator":"NOT_IN","values":[1,2]}""");
}
/*******************************************************************************
**
*******************************************************************************/
private void testBasicCriteria(QueryScreenLib queryScreenLib, String fieldLabel, String operatorLabel, String value, String expectButtonStringRegex, String expectFilterJsonContains)
{
qSeleniumJavalin.beginCapture();
queryScreenLib.setBasicFilter(fieldLabel, operatorLabel, value);
queryScreenLib.waitForBasicFilterButtonMatchingRegex(expectButtonStringRegex);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectFilterJsonContains);
qSeleniumJavalin.endCapture();
}
/*******************************************************************************
**
*******************************************************************************/
private void testBasicCriteriaPossibleValues(QueryScreenLib queryScreenLib, String fieldLabel, String operatorLabel, List<String> values, String expectButtonStringRegex, String expectFilterJsonContains)
{
qSeleniumJavalin.beginCapture();
queryScreenLib.setBasicFilterPossibleValues(fieldLabel, operatorLabel, values);
queryScreenLib.waitForBasicFilterButtonMatchingRegex(expectButtonStringRegex);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectFilterJsonContains);
qSeleniumJavalin.endCapture();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testAdvancedBooleanOperators()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.gotoAdvancedMode();
testAdvancedCriteria(queryScreenLib, "Is Employed", "equals yes", null, "(?s).*Is Employed.*equals yes.*", """
{"fieldName":"isEmployed","operator":"EQUALS","values":[true]}""");
testAdvancedCriteria(queryScreenLib, "Is Employed", "equals no", null, "(?s).*Is Employed.*equals no.*", """
{"fieldName":"isEmployed","operator":"EQUALS","values":[false]}""");
testAdvancedCriteria(queryScreenLib, "Is Employed", "is empty", null, "(?s).*Is Employed.*is empty.*", """
{"fieldName":"isEmployed","operator":"IS_BLANK","values":[]}""");
testAdvancedCriteria(queryScreenLib, "Is Employed", "is not empty", null, "(?s).*Is Employed.*is not empty.*", """
{"fieldName":"isEmployed","operator":"IS_NOT_BLANK","values":[]}""");
}
// todo - table requires variant - prompt for it, choose it, see query; change variant, change on-screen, re-query
/*******************************************************************************
**
*******************************************************************************/
private void testAdvancedCriteria(QueryScreenLib queryScreenLib, String fieldLabel, String operatorLabel, String value, String expectQueryStringRegex, String expectFilterJsonContains)
{
qSeleniumJavalin.beginCapture();
queryScreenLib.clickFilterBuilderButton();
queryScreenLib.addAdvancedQueryFilterInput(0, fieldLabel, operatorLabel, value, null);
qSeleniumLib.clickBackdrop();
queryScreenLib.waitForAdvancedQueryStringMatchingRegex(expectQueryStringRegex);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectFilterJsonContains);
qSeleniumJavalin.endCapture();
queryScreenLib.clickAdvancedFilterClearIcon();
}
}