Compare commits

..

36 Commits

Author SHA1 Message Date
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
3f3b188a9d CE-798 Post-demo style updates; add concept of reconciling current table definition w/ view (e.g., add/delete columns from tables); test updates 2024-02-08 20:09:30 -06:00
fc238127a7 CE-798 z-index fun to fix sticky-header bleed-through 2024-02-07 09:28:33 -06:00
26f9e19222 CE-798 Get seleniums passing 2024-02-06 20:24:20 -06:00
6d44bab49b CE-798 turn off fixed navbar; move pageHeader from Breadcrumbs to NavBar
CE-798 turn off fixed navbar; move pageHeader from Breadcrumbs to NavBar
2024-02-06 19:54:00 -06:00
f7c3bef3e8 CE-798 Redesign of query screen controls - moving columns & sort & export controls out of grid; css from Paul 2024-02-06 19:53:59 -06:00
bda07066b4 CE-798 add accentColorLight to context 2024-02-06 19:40:19 -06:00
585294c06d CE-798 fix launching processes, because somehow that broke in here... 2024-02-01 21:05:35 -06:00
ac97ac016d CE-798 bug fixes after qa 2024-02-01 18:33:26 -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
e96a189721 CE-793 - More cleanup from initial pre-qa qa (human words, values, expressions) 2024-01-31 09:50:38 -06:00
3858b40c0f CE-793 - Cleanup from initial pre-qa qa 2024-01-30 19:55:30 -06:00
3ce1e8179c CE-793 - more Fixes for failed selnium tests 2024-01-30 12:33:02 -06:00
550006586a CE-793 - Fixes for failed selnium tests 2024-01-30 12:03:58 -06:00
d4d13d06fe CE-793 - Add disabled prop to delete button 2024-01-30 09:57:53 -06:00
dc7aeef6bf CE-793 - cleanup pre-code-review; fix alerts; add disabled-state upon-button, etc. 2024-01-30 09:57:09 -06:00
6c75ce281e CE-793 - pre-code-review cleanups 2024-01-30 09:56:31 -06:00
e7995c98cc CE-793 - Significant rewrite. Primarily, move from savedFilters to savedViews. But also then, move from storing each thing in the 'view' in local storage, to all be under one big view object; re-do the "initialization" of the page; remove DataGrid's filter model. method header comments; yeah. 2024-01-29 20:17:32 -06:00
bf802dd7cb CE-793 - Refactored components out of RecordQuery.tsx 2024-01-29 20:17:32 -06:00
b7f34dee21 CE-793 - Rename as .tsx; remove functions that worked with GridFilter; add some functions for human-strings & JSON processing; 2024-01-29 20:17:32 -06:00
4d5beea607 CE-793 - Add return type to validateCriteria; switch to work on criteria.operator, not operatorSelectedValue (enum) 2024-01-29 20:17:32 -06:00
f485a8c90e CE-793 - Always show table's default quick-filters; move some state up to parent (part of saved-views) 2024-01-29 20:17:32 -06:00
4fd72f9c77 CE-793 - Initial UI/UX from designer applied 2024-01-29 20:17:32 -06:00
fa680c5a80 CE-793 - Initial add of models for savedViews rewrite of RecordQuery 2024-01-29 20:17:32 -06:00
4d5040e29d CE-793 - Add method getFieldFullLabel 2024-01-29 20:17:32 -06:00
d901404d25 CE-793 - Redo SavedFilters component as SavedViews 2024-01-29 20:17:32 -06:00
db2cdc3603 CE-793 - Fix grid pinned column height; new style for toggle buttons 2024-01-29 20:17:32 -06:00
5d479ad04a CE-793 - Add callback for going to slow-loading; call setters in constructor 2024-01-29 20:17:32 -06:00
c5c756d84f CE-793 - Replace/rename savedFilter as savedView 2024-01-29 20:17:32 -06:00
50 changed files with 6391 additions and 2962 deletions

View File

@ -355,7 +355,7 @@ export default function App()
routeList.push({
name: `${app.label}`,
key: app.name,
route: `${path}/savedFilter/:id`,
route: `${path}/savedView/:id`,
component: <RecordQuery table={table} key={table.name} />,
});
@ -656,6 +656,7 @@ export default function App()
const [pageHeader, setPageHeader] = useState("" as string | JSX.Element);
const [accentColor, setAccentColor] = useState("#0062FF");
const [accentColorLight, setAccentColorLight] = useState("#C0D6F7")
const [tableMetaData, setTableMetaData] = useState(null);
const [tableProcesses, setTableProcesses] = useState(null);
const [dotMenuOpen, setDotMenuOpen] = useState(false);
@ -668,6 +669,7 @@ export default function App()
<QContext.Provider value={{
pageHeader: pageHeader,
accentColor: accentColor,
accentColorLight: accentColorLight,
tableMetaData: tableMetaData,
tableProcesses: tableProcesses,
dotMenuOpen: dotMenuOpen,
@ -675,6 +677,7 @@ export default function App()
helpHelpActive: helpHelpActive,
setPageHeader: (header: string | JSX.Element) => setPageHeader(header),
setAccentColor: (accentColor: string) => setAccentColor(accentColor),
setAccentColorLight: (accentColorLight: string) => setAccentColorLight(accentColorLight),
setTableMetaData: (tableMetaData: QTableMetaData) => setTableMetaData(tableMetaData),
setTableProcesses: (tableProcesses: QProcessMetaData[]) => setTableProcesses(tableProcesses),
setDotMenuOpen: (dotMenuOpent: boolean) => setDotMenuOpen(dotMenuOpent),

View File

@ -19,9 +19,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QAppMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppMetaData";
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {createContext} from "react";
@ -32,7 +30,10 @@ interface QContext
setPageHeader?: (header: string | JSX.Element) => void;
accentColor: string;
setAccentColor?: (header: string) => void;
setAccentColor?: (color: string) => void;
accentColorLight: string;
setAccentColorLight?: (color: string) => void;
dotMenuOpen: boolean;
setDotMenuOpen?: (dotMenuOpen: boolean) => void;
@ -57,6 +58,7 @@ interface QContext
const defaultState = {
pageHeader: "",
accentColor: "#0062FF",
accentColorLight: "#C0D6F7",
dotMenuOpen: false,
keyboardHelpOpen: false,
pathToLabelMap: {},

View File

@ -37,7 +37,7 @@ interface QCreateNewButtonProps
export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element
{
return (
<Box ml={3} mr={0} width={standardWidth}>
<Box display="inline-block" ml={3} mr={0} width={standardWidth}>
<Link to={`${tablePath}/create`}>
<MDButton variant="gradient" color="info" fullWidth startIcon={<Icon>add</Icon>}>
Create New
@ -73,13 +73,17 @@ export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveBu
interface QDeleteButtonProps
{
onClickHandler: any
disabled?: boolean
}
export function QDeleteButton({onClickHandler}: QDeleteButtonProps): JSX.Element
QDeleteButton.defaultProps = {
disabled: false
};
export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): JSX.Element
{
return (
<Box ml={3} width={standardWidth}>
<MDButton variant="gradient" color="primary" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>delete</Icon>}>
<MDButton variant="gradient" color="primary" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>delete</Icon>} disabled={disabled}>
Delete
</MDButton>
</Box>
@ -123,24 +127,6 @@ export function QActionsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonP
);
}
export function QSavedFiltersMenuButton({isOpen, onClickHandler}: QActionsMenuButtonProps): JSX.Element
{
return (
<Box width={standardWidth} ml={1}>
<MDButton
variant={isOpen ? "contained" : "outlined"}
color="dark"
onClick={onClickHandler}
fullWidth
startIcon={<Icon>filter_alt</Icon>}
>
saved&nbsp;filters&nbsp;
<Icon>keyboard_arrow_down</Icon>
</MDButton>
</Box>
);
}
interface QCancelButtonProps
{
onClickHandler: any;

View File

@ -321,9 +321,14 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
{
setOpen(false);
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
isOptionEqualToValue={(option, value) => value !== null && value !== undefined && option.id === value.id}
getOptionLabel={(option) =>
{
if(option === null || option === undefined)
{
return ("");
}
// @ts-ignore
if(option && option.length)
{

View File

@ -70,7 +70,7 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
}
const routes: string[] | any = route.slice(0, -1);
const {pageHeader, pathToLabelMap, branding} = useContext(QContext);
const {pathToLabelMap, branding} = useContext(QContext);
const fullPathToLabel = (fullPath: string, route: string): string =>
{
@ -92,7 +92,18 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
let accumulatedPath = "";
for (let i = 0; i < routes.length; i++)
{
if(routes[i] === "savedFilter")
////////////////////////////////////////////////////////
// avoid showing "saved view" as a breadcrumb element //
////////////////////////////////////////////////////////
if(routes[i] === "savedView")
{
continue;
}
///////////////////////////////////////////////////////////////////////
// avoid showing the table name if it's the element before savedView //
///////////////////////////////////////////////////////////////////////
if(i < routes.length - 1 && routes[i+1] == "savedView")
{
continue;
}
@ -138,15 +149,6 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
</Link>
))}
</MuiBreadcrumbs>
<MDTypography
pt={1}
textTransform="capitalize"
variant="h3"
color={light ? "white" : "dark"}
noWrap
>
{pageHeader}
</MDTypography>
</Box>
);
}

View File

@ -22,12 +22,10 @@
import {Popper, InputAdornment} from "@mui/material";
import AppBar from "@mui/material/AppBar";
import Autocomplete from "@mui/material/Autocomplete";
import Badge from "@mui/material/Badge";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import TextField from "@mui/material/TextField";
import Toolbar from "@mui/material/Toolbar";
import React, {useContext, useEffect, useState} from "react";
@ -35,6 +33,7 @@ import {useLocation, useNavigate} from "react-router-dom";
import QContext from "QContext";
import QBreadcrumbs, {routeToLabel} from "qqq/components/horseshoe/Breadcrumbs";
import {navbar, navbarContainer, navbarRow, navbarMobileMenu, recentlyViewedMenu,} from "qqq/components/horseshoe/Styles";
import MDTypography from "qqq/components/legacy/MDTypography";
import {setTransparentNavbar, useMaterialUIController, setMiniSidenav} from "qqq/context";
import HistoryUtils from "qqq/utils/HistoryUtils";
@ -65,6 +64,8 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const route = useLocation().pathname.split("/").slice(1);
const navigate = useNavigate();
const {pageHeader} = useContext(QContext);
useEffect(() =>
{
// Setting the navbar type
@ -234,25 +235,27 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
>
<Toolbar sx={navbarContainer}>
<Box color="inherit" mb={{xs: 1, md: 0}} sx={(theme) => navbarRow(theme, {isMini})}>
<IconButton
size="small"
disableRipple
color="inherit"
sx={navbarMobileMenu}
onClick={handleMiniSidenav}
>
<IconButton size="small" disableRipple color="inherit" sx={navbarMobileMenu} onClick={handleMiniSidenav}>
<Icon sx={iconsStyle} fontSize="large">menu</Icon>
</IconButton>
<QBreadcrumbs icon="home" title={breadcrumbTitle} route={route} light={light} />
</Box>
{isMini ? null : (
<Box sx={(theme) => navbarRow(theme, {isMini})}>
<Box pr={0} mr={-2} mt={-4}>
<Box pr={0} mr={-2}>
{renderHistory()}
</Box>
</Box>
)}
</Toolbar>
{
pageHeader &&
<Box display="flex" justifyContent="space-between">
<MDTypography pb="0.5rem" textTransform="capitalize" variant="h3" color={light ? "white" : "dark"} noWrap>
{pageHeader}
</MDTypography>
</Box>
}
</AppBar>
);
}

View File

@ -66,12 +66,12 @@ function navbar(theme: Theme | any, ownerState: any)
return color;
},
top: absolute ? 0 : pxToRem(12),
minHeight: pxToRem(75),
minHeight: "auto",
display: "grid",
alignItems: "center",
borderRadius: borderRadius.xl,
paddingTop: pxToRem(8),
paddingBottom: pxToRem(8),
paddingTop: pxToRem(0),
paddingBottom: pxToRem(0),
paddingRight: absolute ? pxToRem(8) : 0,
paddingLeft: absolute ? pxToRem(16) : 0,
@ -85,7 +85,7 @@ function navbar(theme: Theme | any, ownerState: any)
"& .MuiToolbar-root": {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
alignItems: "flex-start",
[breakpoints.up("sm")]: {
minHeight: "auto",
@ -99,10 +99,10 @@ const navbarContainer = ({breakpoints}: Theme): any => ({
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "0 !important",
[breakpoints.up("md")]: {
flexDirection: "row",
alignItems: "center",
paddingTop: "0",
paddingBottom: "0",
},
@ -152,6 +152,7 @@ const navbarDesktopMenu = ({breakpoints}: Theme) => ({
});
const recentlyViewedMenu = ({breakpoints}: Theme) => ({
marginTop: "-0.5rem",
"& .MuiInputLabel-root": {
color: colors.gray.main,
fontWeight: "500",

View File

@ -41,7 +41,7 @@ interface Props
QRecordSidebar.defaultProps = {
light: false,
stickyTop: "110px",
stickyTop: "1rem",
};
interface SidebarEntry
@ -76,7 +76,7 @@ function QRecordSidebar({tableSections, widgetMetaDataList, light, stickyTop}: P
return (
<Card sx={{borderRadius: "0.75rem", position: "sticky", top: stickyTop, overflow: "auto", maxHeight: "calc(100vh - 200px)"}}>
<Card sx={{borderRadius: "0.75rem", position: "sticky", top: stickyTop, overflow: "auto", maxHeight: "calc(100vh - 2rem)"}}>
<Box component="ul" display="flex" flexDirection="column" p={2} m={0} sx={{listStyle: "none"}}>
{
sidebarEntries ? sidebarEntries.map((entry: SidebarEntry, key: number) => (

View File

@ -1,511 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {FiberManualRecord} from "@mui/icons-material";
import {Alert} from "@mui/material";
import Box from "@mui/material/Box";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import {GridFilterModel, GridSortItem} from "@mui/x-data-grid-pro";
import FormData from "form-data";
import React, {useEffect, useRef, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
import {QCancelButton, QDeleteButton, QSaveButton, QSavedFiltersMenuButton} from "qqq/components/buttons/DefaultButtons";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
interface Props
{
qController: QController;
metaData: QInstance;
tableMetaData: QTableMetaData;
currentSavedFilter: QRecord;
filterModel?: GridFilterModel;
columnSortModel?: GridSortItem[];
filterOnChangeCallback?: (selectedSavedFilterId: number) => void;
}
function SavedFilters({qController, metaData, tableMetaData, currentSavedFilter, filterModel, columnSortModel, filterOnChangeCallback}: Props): JSX.Element
{
const navigate = useNavigate();
const [savedFilters, setSavedFilters] = useState([] as QRecord[]);
const [savedFiltersMenu, setSavedFiltersMenu] = useState(null);
const [savedFiltersHaveLoaded, setSavedFiltersHaveLoaded] = useState(false);
const [filterIsModified, setFilterIsModified] = useState(false);
const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false);
const [isSaveFilterAs, setIsSaveFilterAs] = useState(false);
const [isRenameFilter, setIsRenameFilter] = useState(false);
const [isDeleteFilter, setIsDeleteFilter] = useState(false);
const [savedFilterNameInputValue, setSavedFilterNameInputValue] = useState(null as string);
const [popupAlertContent, setPopupAlertContent] = useState("");
const anchorRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
const SAVE_OPTION = "Save...";
const DUPLICATE_OPTION = "Duplicate...";
const RENAME_OPTION = "Rename...";
const DELETE_OPTION = "Delete...";
const CLEAR_OPTION = "Clear Current Filter";
const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION];
const openSavedFiltersMenu = (event: any) => setSavedFiltersMenu(event.currentTarget);
const closeSavedFiltersMenu = () => setSavedFiltersMenu(null);
//////////////////////////////////////////////////////////////////////////
// load filters on first run, then monitor location or metadata changes //
//////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
loadSavedFilters()
.then(() =>
{
if (currentSavedFilter != null)
{
let qFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel);
setFilterIsModified(JSON.stringify(qFilter) !== currentSavedFilter.values.get("filterJson"));
}
setSavedFiltersHaveLoaded(true);
});
}, [location , tableMetaData, currentSavedFilter, filterModel, columnSortModel])
/*******************************************************************************
** make request to load all saved filters from backend
*******************************************************************************/
async function loadSavedFilters()
{
if (! tableMetaData)
{
return;
}
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
let savedFilters = await makeSavedFilterRequest("querySavedFilter", formData);
setSavedFilters(savedFilters);
}
/*******************************************************************************
** fired when a saved record is clicked from the dropdown
*******************************************************************************/
const handleSavedFilterRecordOnClick = async (record: QRecord) =>
{
setSaveFilterPopupOpen(false);
closeSavedFiltersMenu();
filterOnChangeCallback(record.values.get("id"));
navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedFilter/${record.values.get("id")}`);
};
/*******************************************************************************
** fired when a save option is selected from the save... button/dropdown combo
*******************************************************************************/
const handleDropdownOptionClick = (optionName: string) =>
{
setSaveOptionsOpen(false);
setPopupAlertContent(null);
closeSavedFiltersMenu();
setSaveFilterPopupOpen(true);
setIsSaveFilterAs(false);
setIsRenameFilter(false);
setIsDeleteFilter(false)
switch(optionName)
{
case SAVE_OPTION:
break;
case DUPLICATE_OPTION:
setIsSaveFilterAs(true);
break;
case CLEAR_OPTION:
setSaveFilterPopupOpen(false)
filterOnChangeCallback(null);
navigate(metaData.getTablePathByName(tableMetaData.name));
break;
case RENAME_OPTION:
if(currentSavedFilter != null)
{
setSavedFilterNameInputValue(currentSavedFilter.values.get("label"));
}
setIsRenameFilter(true);
break;
case DELETE_OPTION:
setIsDeleteFilter(true)
break;
}
}
/*******************************************************************************
** fired when save or delete button saved on confirmation dialogs
*******************************************************************************/
async function handleFilterDialogButtonOnClick()
{
try
{
const formData = new FormData();
if (isDeleteFilter)
{
formData.append("id", currentSavedFilter.values.get("id"));
await makeSavedFilterRequest("deleteSavedFilter", formData);
await(async() =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
}
else
{
formData.append("tableName", tableMetaData.name);
formData.append("filterJson", JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel))));
if (isSaveFilterAs || isRenameFilter || currentSavedFilter == null)
{
formData.append("label", savedFilterNameInputValue);
if(currentSavedFilter != null && isRenameFilter)
{
formData.append("id", currentSavedFilter.values.get("id"));
}
}
else
{
formData.append("id", currentSavedFilter.values.get("id"));
formData.append("label", currentSavedFilter?.values.get("label"));
}
const recordList = await makeSavedFilterRequest("storeSavedFilter", formData);
await(async() =>
{
if (recordList && recordList.length > 0)
{
setSavedFiltersHaveLoaded(false);
loadSavedFilters();
handleSavedFilterRecordOnClick(recordList[0]);
}
})();
}
}
catch (e: any)
{
setPopupAlertContent(JSON.stringify(e.message));
}
}
/*******************************************************************************
** hides/shows the save options
*******************************************************************************/
const handleToggleSaveOptions = () =>
{
setSaveOptionsOpen((prevOpen) => !prevOpen);
};
/*******************************************************************************
** closes save options menu (on clickaway)
*******************************************************************************/
const handleSaveOptionsMenuClose = (event: Event) =>
{
if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement))
{
return;
}
setSaveOptionsOpen(false);
};
/*******************************************************************************
** stores the current dialog input text to state
*******************************************************************************/
const handleSaveFilterInputChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
{
setSavedFilterNameInputValue(event.target.value);
};
/*******************************************************************************
** closes current dialog
*******************************************************************************/
const handleSaveFilterPopupClose = () =>
{
setSaveFilterPopupOpen(false);
};
/*******************************************************************************
** make a request to the backend for various savedFilter processes
*******************************************************************************/
async function makeSavedFilterRequest(processName: string, formData: FormData): Promise<QRecord[]>
{
/////////////////////////
// fetch saved filters //
/////////////////////////
let savedFilters = [] as QRecord[]
try
{
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000);
const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders());
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
throw(jobError.error);
}
else
{
const result = processResult as QJobComplete;
if(result.values.savedFilterList)
{
for (let i = 0; i < result.values.savedFilterList.length; i++)
{
const qRecord = new QRecord(result.values.savedFilterList[i]);
savedFilters.push(qRecord);
}
}
}
}
catch (e)
{
throw(e);
}
return (savedFilters);
}
const hasStorePermission = metaData?.processes.has("storeSavedFilter");
const hasDeletePermission = metaData?.processes.has("deleteSavedFilter");
const hasQueryPermission = metaData?.processes.has("querySavedFilter");
const renderSavedFiltersMenu = tableMetaData && (
<Menu
anchorEl={savedFiltersMenu}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
open={Boolean(savedFiltersMenu)}
onClose={closeSavedFiltersMenu}
keepMounted
>
<MenuItem sx={{width: "300px"}} disabled style={{"opacity": "initial"}}><b>Filter Actions</b></MenuItem>
{
hasStorePermission &&
<MenuItem onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>
<ListItemIcon><Icon>save</Icon></ListItemIcon>
Save...
</MenuItem>
}
{
hasStorePermission &&
<MenuItem disabled={currentSavedFilter === null} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
Rename...
</MenuItem>
}
{
hasStorePermission &&
<MenuItem disabled={currentSavedFilter === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
Duplicate...
</MenuItem>
}
{
hasDeletePermission &&
<MenuItem disabled={currentSavedFilter === null} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
Delete...
</MenuItem>
}
{
<MenuItem disabled={currentSavedFilter === null} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>clear</Icon></ListItemIcon>
Clear Current Filter
</MenuItem>
}
<Divider/>
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Filters</b></MenuItem>
{
savedFilters && savedFilters.length > 0 ? (
savedFilters.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedFilterRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
): (
<MenuItem >
<i>No filters have been saved for this table.</i>
</MenuItem>
)
}
</Menu>
);
return (
hasQueryPermission && tableMetaData ? (
<Box display="flex" flexGrow={1}>
<QSavedFiltersMenuButton isOpen={savedFiltersMenu} onClickHandler={openSavedFiltersMenu} />
{renderSavedFiltersMenu}
<Box display="flex" justifyContent="center" flexDirection="column">
<Box pl={2} pr={2} sx={{display: "flex", alignItems: "center"}}>
{
savedFiltersHaveLoaded && currentSavedFilter && (
<Typography mr={2} variant="h6">Current Filter:&nbsp;
<span style={{fontWeight: "initial"}}>
{currentSavedFilter.values.get("label")}
{
filterIsModified && (
<Tooltip sx={{cursor: "pointer"}} title={"The current filter has been modified. Click \"Save...\" to save the changes."}>
<FiberManualRecord sx={{color: "orange", paddingLeft: "2px", paddingTop: "4px"}} />
</Tooltip>
)
}
</span>
</Typography>
)
}
</Box>
</Box>
{
<Dialog
open={saveFilterPopupOpen}
onClose={handleSaveFilterPopupClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
onKeyPress={(e) =>
{
if (e.key == "Enter")
{
handleFilterDialogButtonOnClick();
}
}}
>
{
currentSavedFilter ? (
isDeleteFilter ? (
<DialogTitle id="alert-dialog-title">Delete Filter</DialogTitle>
) : (
isSaveFilterAs ? (
<DialogTitle id="alert-dialog-title">Save Filter As</DialogTitle>
):(
isRenameFilter ? (
<DialogTitle id="alert-dialog-title">Rename Filter</DialogTitle>
):(
<DialogTitle id="alert-dialog-title">Update Existing Filter</DialogTitle>
)
)
)
):(
<DialogTitle id="alert-dialog-title">Save New Filter</DialogTitle>
)
}
<DialogContent sx={{width: "500px"}}>
{
(! currentSavedFilter || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? (
<Box>
{
isSaveFilterAs ? (
<Box mb={3}>Enter a name for this new saved filter.</Box>
):(
<Box mb={3}>Enter a new name for this saved filter.</Box>
)
}
<TextField
autoFocus
name="custom-delimiter-value"
placeholder="Filter Name"
label="Filter Name"
inputProps={{width: "100%", maxLength: 100}}
value={savedFilterNameInputValue}
sx={{width: "100%"}}
onChange={handleSaveFilterInputChange}
onFocus={event =>
{
event.target.select();
}}
/>
</Box>
):(
isDeleteFilter ? (
<Box>Are you sure you want to delete the filter {`'${currentSavedFilter?.values.get("label")}'`}?</Box>
):(
<Box>Are you sure you want to update the filter {`'${currentSavedFilter?.values.get("label")}'`} with the current filter criteria?</Box>
)
)
}
{popupAlertContent ? (
<Box m={1}>
<Alert severity="error">{popupAlertContent}</Alert>
</Box>
) : ("")}
</DialogContent>
<DialogActions>
<QCancelButton onClickHandler={handleSaveFilterPopupClose} disabled={false} />
{
isDeleteFilter ?
<QDeleteButton onClickHandler={handleFilterDialogButtonOnClick} />
:
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={(isSaveFilterAs || currentSavedFilter == null) && savedFilterNameInputValue == null}/>
}
</DialogActions>
</Dialog>
}
</Box>
) : null
);
}
export default SavedFilters;

View File

@ -0,0 +1,682 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert, Button, Link} from "@mui/material";
import Box from "@mui/material/Box";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import {TooltipProps} from "@mui/material/Tooltip/Tooltip";
import FormData from "form-data";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import RecordQueryView from "qqq/models/query/RecordQueryView";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils";
interface Props
{
qController: QController;
metaData: QInstance;
tableMetaData: QTableMetaData;
currentSavedView: QRecord;
tableDefaultView: RecordQueryView;
view?: RecordQueryView;
viewAsJson?: string;
viewOnChangeCallback?: (selectedSavedViewId: number) => void;
loadingSavedView: boolean
}
function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element
{
const navigate = useNavigate();
const [savedViews, setSavedViews] = useState([] as QRecord[]);
const [savedViewsMenu, setSavedViewsMenu] = useState(null);
const [savedViewsHaveLoaded, setSavedViewsHaveLoaded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false);
const [isSaveFilterAs, setIsSaveFilterAs] = useState(false);
const [isRenameFilter, setIsRenameFilter] = useState(false);
const [isDeleteFilter, setIsDeleteFilter] = useState(false);
const [savedViewNameInputValue, setSavedViewNameInputValue] = useState(null as string);
const [popupAlertContent, setPopupAlertContent] = useState("");
const anchorRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
const SAVE_OPTION = "Save...";
const DUPLICATE_OPTION = "Duplicate...";
const RENAME_OPTION = "Rename...";
const DELETE_OPTION = "Delete...";
const CLEAR_OPTION = "New View";
const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION];
const {accentColor, accentColorLight} = useContext(QContext);
const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget);
const closeSavedViewsMenu = () => setSavedViewsMenu(null);
//////////////////////////////////////////////////////////////////////////
// load filters on first run, then monitor location or metadata changes //
//////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
loadSavedViews()
.then(() =>
{
setSavedViewsHaveLoaded(true);
});
}, [location, tableMetaData])
const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView;
const viewDiffs = SavedViewUtils.diffViews(tableMetaData, baseView, view);
let viewIsModified = false;
if(viewDiffs.length > 0)
{
viewIsModified = true;
}
/*******************************************************************************
** make request to load all saved filters from backend
*******************************************************************************/
async function loadSavedViews()
{
if (! tableMetaData)
{
return;
}
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
let savedViews = await makeSavedViewRequest("querySavedView", formData);
setSavedViews(savedViews);
}
/*******************************************************************************
** fired when a saved record is clicked from the dropdown
*******************************************************************************/
const handleSavedViewRecordOnClick = async (record: QRecord) =>
{
setSaveFilterPopupOpen(false);
closeSavedViewsMenu();
viewOnChangeCallback(record.values.get("id"));
navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`);
};
/*******************************************************************************
** fired when a save option is selected from the save... button/dropdown combo
*******************************************************************************/
const handleDropdownOptionClick = (optionName: string) =>
{
setSaveOptionsOpen(false);
setPopupAlertContent("");
closeSavedViewsMenu();
setSaveFilterPopupOpen(true);
setIsSaveFilterAs(false);
setIsRenameFilter(false);
setIsDeleteFilter(false)
switch(optionName)
{
case SAVE_OPTION:
if(currentSavedView == null)
{
setSavedViewNameInputValue("");
}
break;
case DUPLICATE_OPTION:
setSavedViewNameInputValue("");
setIsSaveFilterAs(true);
break;
case CLEAR_OPTION:
setSaveFilterPopupOpen(false)
viewOnChangeCallback(null);
navigate(metaData.getTablePathByName(tableMetaData.name));
break;
case RENAME_OPTION:
if(currentSavedView != null)
{
setSavedViewNameInputValue(currentSavedView.values.get("label"));
}
setIsRenameFilter(true);
break;
case DELETE_OPTION:
setIsDeleteFilter(true)
break;
}
}
/*******************************************************************************
** fired when save or delete button saved on confirmation dialogs
*******************************************************************************/
async function handleFilterDialogButtonOnClick()
{
try
{
setPopupAlertContent("");
setIsSubmitting(true);
const formData = new FormData();
if (isDeleteFilter)
{
formData.append("id", currentSavedView.values.get("id"));
await makeSavedViewRequest("deleteSavedView", formData);
setSaveFilterPopupOpen(false);
setSaveOptionsOpen(false);
await(async() =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
}
else
{
formData.append("tableName", tableMetaData.name);
/////////////////////////////////////////////////////////////////////////////////////////////////
// clone view via json serialization/deserialization //
// then replace the viewJson in it with a copy that has had its possible values changed to ids //
// then stringify that for the backend //
/////////////////////////////////////////////////////////////////////////////////////////////////
const viewObject = JSON.parse(JSON.stringify(view));
viewObject.queryFilter = JSON.parse(JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(viewObject.queryFilter)));
formData.append("viewJson", JSON.stringify(viewObject));
if (isSaveFilterAs || isRenameFilter || currentSavedView == null)
{
formData.append("label", savedViewNameInputValue);
if(currentSavedView != null && isRenameFilter)
{
formData.append("id", currentSavedView.values.get("id"));
}
}
else
{
formData.append("id", currentSavedView.values.get("id"));
formData.append("label", currentSavedView?.values.get("label"));
}
const recordList = await makeSavedViewRequest("storeSavedView", formData);
await(async() =>
{
if (recordList && recordList.length > 0)
{
setSavedViewsHaveLoaded(false);
loadSavedViews();
handleSavedViewRecordOnClick(recordList[0]);
}
})();
}
setSaveFilterPopupOpen(false);
setSaveOptionsOpen(false);
}
catch (e: any)
{
let message = JSON.stringify(e);
if(typeof e == "string")
{
message = e;
}
else if(typeof e == "object" && e.message)
{
message = e.message;
}
setPopupAlertContent(message);
console.log(`Setting error: ${message}`);
}
finally
{
setIsSubmitting(false);
}
}
/*******************************************************************************
** hides/shows the save options
*******************************************************************************/
const handleToggleSaveOptions = () =>
{
setSaveOptionsOpen((prevOpen) => !prevOpen);
};
/*******************************************************************************
** closes save options menu (on clickaway)
*******************************************************************************/
const handleSaveOptionsMenuClose = (event: Event) =>
{
if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement))
{
return;
}
setSaveOptionsOpen(false);
};
/*******************************************************************************
** stores the current dialog input text to state
*******************************************************************************/
const handleSaveFilterInputChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
{
setSavedViewNameInputValue(event.target.value);
};
/*******************************************************************************
** closes current dialog
*******************************************************************************/
const handleSaveFilterPopupClose = () =>
{
setSaveFilterPopupOpen(false);
};
/*******************************************************************************
** make a request to the backend for various savedView processes
*******************************************************************************/
async function makeSavedViewRequest(processName: string, formData: FormData): Promise<QRecord[]>
{
/////////////////////////
// fetch saved filters //
/////////////////////////
let savedViews = [] as QRecord[]
try
{
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000);
const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders());
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
throw(jobError.error);
}
else
{
const result = processResult as QJobComplete;
if(result.values.savedViewList)
{
for (let i = 0; i < result.values.savedViewList.length; i++)
{
const qRecord = new QRecord(result.values.savedViewList[i]);
savedViews.push(qRecord);
}
}
}
}
catch (e)
{
throw(e);
}
return (savedViews);
}
const hasStorePermission = metaData?.processes.has("storeSavedView");
const hasDeletePermission = metaData?.processes.has("deleteSavedView");
const hasQueryPermission = metaData?.processes.has("querySavedView");
const tooltipMaxWidth = (maxWidth: string) =>
{
return ({slotProps: {
tooltip: {
sx: {
maxWidth: maxWidth
}
}
}})
}
const menuTooltipAttribs = {...tooltipMaxWidth("250px"), placement: "left", enterDelay: 1000} as TooltipProps;
const renderSavedViewsMenu = tableMetaData && (
<Menu
anchorEl={savedViewsMenu}
anchorOrigin={{vertical: "bottom", horizontal: "left",}}
transformOrigin={{vertical: "top", horizontal: "left",}}
open={Boolean(savedViewsMenu)}
onClose={closeSavedViewsMenu}
keepMounted
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minHeight: "200px"}}}
>
<MenuItem sx={{width: "300px"}} disabled style={{"opacity": "initial"}}><b>View Actions</b></MenuItem>
{
hasStorePermission &&
<Tooltip {...menuTooltipAttribs} title={<>Save your current filters, columns and settings, for quick re-use at a later time.<br /><br />You will be prompted to enter a name if you choose this option.</>}>
<MenuItem onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>
<ListItemIcon><Icon>save</Icon></ListItemIcon>
{currentSavedView ? "Save..." : "Save As..."}
</MenuItem>
</Tooltip>
}
{
hasStorePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title="Change the name for this saved view.">
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
Rename...
</MenuItem>
</Tooltip>
}
{
hasStorePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title="Save a new copy this view, with a different name, separate from the original.">
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
Save As...
</MenuItem>
</Tooltip>
}
{
hasStorePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title="Delete this saved view.">
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
Delete...
</MenuItem>
</Tooltip>
}
{
<Tooltip {...menuTooltipAttribs} title="Create a new view of this table, resetting the filters and columns to their defaults.">
<MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>monitor</Icon></ListItemIcon>
New View
</MenuItem>
</Tooltip>
}
<Divider/>
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Views</b></MenuItem>
{
savedViews && savedViews.length > 0 ? (
savedViews.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedViewRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
): (
<MenuItem>
<i>You do not have any saved views for this table.</i>
</MenuItem>
)
}
</Menu>
);
let buttonText = "Views";
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
if(currentSavedView)
{
if (viewIsModified)
{
buttonBackground = accentColorLight;
buttonBorder = buttonBackground;
buttonColor = accentColor;
}
else
{
buttonBackground = accentColor;
buttonBorder = buttonBackground;
buttonColor = "#FFFFFF";
}
}
const buttonStyles = {
border: `1px solid ${buttonBorder}`,
backgroundColor: buttonBackground,
color: buttonColor,
"&:focus:not(:hover)": {
color: buttonColor,
backgroundColor: buttonBackground,
},
"&:hover": {
color: buttonColor,
backgroundColor: buttonBackground,
}
}
/*******************************************************************************
**
*******************************************************************************/
function isSaveButtonDisabled(): boolean
{
if(isSubmitting)
{
return (true);
}
const haveInputText = (savedViewNameInputValue != null && savedViewNameInputValue.trim() != "")
if(isSaveFilterAs || isRenameFilter || currentSavedView == null)
{
if(!haveInputText)
{
return (true);
}
}
return (false);
}
const linkButtonStyle = {
minWidth: "unset",
textTransform: "none",
fontSize: "0.875rem",
fontWeight: "500",
padding: "0.5rem"
};
return (
hasQueryPermission && tableMetaData ? (
<>
<Box order="1" mr={"0.5rem"}>
<Button
onClick={openSavedViewsMenu}
sx={{
borderRadius: "0.75rem",
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
p: "0.5rem",
... buttonStyles
}}
>
<Icon sx={{mr: "0.5rem"}}>save</Icon>
{buttonText}
<Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon>
</Button>
{renderSavedViewsMenu}
</Box>
<Box order="3" display="flex" justifyContent="center" flexDirection="column">
<Box pl={2} pr={2} sx={{display: "flex", alignItems: "center"}}>
{
!currentSavedView && viewIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul>
</>}>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save View As&hellip;</Button>
</Tooltip>
{/* vertical rule */}
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset All Changes</Button>
</>
}
{
currentSavedView && viewIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul></>}>
<Box display="inline" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>{viewDiffs.length} Unsaved Change{viewDiffs.length == 1 ? "" : "s"}</Box>
</Tooltip>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save&hellip;</Button>
{/* vertical rule */}
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset All Changes</Button>
</>
}
</Box>
</Box>
{
<Dialog
open={saveFilterPopupOpen}
onClose={handleSaveFilterPopupClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
onKeyPress={(e) =>
{
////////////////////////////////////////////////////
// make user actually hit delete button //
// but for other modes, let Enter submit the form //
////////////////////////////////////////////////////
if (e.key == "Enter" && !isDeleteFilter)
{
handleFilterDialogButtonOnClick();
}
}}
>
{
currentSavedView ? (
isDeleteFilter ? (
<DialogTitle id="alert-dialog-title">Delete View</DialogTitle>
) : (
isSaveFilterAs ? (
<DialogTitle id="alert-dialog-title">Save View As</DialogTitle>
):(
isRenameFilter ? (
<DialogTitle id="alert-dialog-title">Rename View</DialogTitle>
):(
<DialogTitle id="alert-dialog-title">Update Existing View</DialogTitle>
)
)
)
):(
<DialogTitle id="alert-dialog-title">Save New View</DialogTitle>
)
}
<DialogContent sx={{width: "500px"}}>
{popupAlertContent ? (
<Box mb={1}>
<Alert severity="error" onClose={() => setPopupAlertContent("")}>{popupAlertContent}</Alert>
</Box>
) : ("")}
{
(! currentSavedView || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? (
<Box>
{
isSaveFilterAs ? (
<Box mb={3}>Enter a name for this new saved view.</Box>
):(
<Box mb={3}>Enter a new name for this saved view.</Box>
)
}
<TextField
autoFocus
name="custom-delimiter-value"
placeholder="View Name"
inputProps={{width: "100%", maxLength: 100}}
value={savedViewNameInputValue}
sx={{width: "100%"}}
onChange={handleSaveFilterInputChange}
onFocus={event =>
{
event.target.select();
}}
/>
</Box>
):(
isDeleteFilter ? (
<Box>Are you sure you want to delete the view {`'${currentSavedView?.values.get("label")}'`}?</Box>
):(
<Box>Are you sure you want to update the view {`'${currentSavedView?.values.get("label")}'`}?</Box>
)
)
}
</DialogContent>
<DialogActions>
<QCancelButton onClickHandler={handleSaveFilterPopupClose} disabled={false} />
{
isDeleteFilter ?
<QDeleteButton onClickHandler={handleFilterDialogButtonOnClick} disabled={isSubmitting} />
:
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={isSaveButtonDisabled()}/>
}
</DialogActions>
</Dialog>
}
</>
) : null
);
}
export default SavedViews;

View File

@ -27,8 +27,9 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Badge, ToggleButton, ToggleButtonGroup, Typography} from "@mui/material";
import {Badge, ToggleButton, ToggleButtonGroup} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
@ -37,15 +38,17 @@ import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import Icon from "@mui/material/Icon";
import Menu from "@mui/material/Menu";
import Tooltip from "@mui/material/Tooltip";
import {GridFilterModel} from "@mui/x-data-grid-pro";
import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro";
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react";
import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import QuickFilter from "qqq/components/query/QuickFilter";
import FieldListMenu from "qqq/components/query/FieldListMenu";
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter";
import XIcon from "qqq/components/query/XIcon";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
@ -53,11 +56,17 @@ interface BasicAndAdvancedQueryControlsProps
{
metaData: QInstance;
tableMetaData: QTableMetaData;
queryFilter: QQueryFilter;
gridApiRef: React.MutableRefObject<GridApiPro>
savedViewsComponent: JSX.Element;
columnMenuComponent: JSX.Element;
quickFilterFieldNames: string[];
setQuickFilterFieldNames: (names: string[]) => void;
queryFilter: QQueryFilter;
setQueryFilter: (queryFilter: QQueryFilter) => void;
handleFilterChange: (filterModel: GridFilterModel, doSetQueryFilter?: boolean, isChangeFromDataGrid?: boolean) => void;
gridApiRef: React.MutableRefObject<GridApiPro>;
/////////////////////////////////////////////////////////////////////////////////////////////
// this prop is used as a way to recognize changes in the query filter internal structure, //
@ -73,35 +82,28 @@ let debounceTimeout: string | number | NodeJS.Timeout;
/*******************************************************************************
** Component to provide the basic & advanced query-filter controls for the
** RecordQuery screen.
** RecordQueryOrig screen.
**
** Done as a forwardRef, so RecordQuery can call some functions, e.g., when user
** Done as a forwardRef, so RecordQueryOrig can call some functions, e.g., when user
** does things on that screen, that we need to know about in here.
*******************************************************************************/
const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) =>
{
const {metaData, tableMetaData, queryFilter, gridApiRef, setQueryFilter, handleFilterChange, queryFilterJSON, mode, setMode} = props
/////////////////////////////////////////////////////////
// get the quick-filter-field-names from local storage //
/////////////////////////////////////////////////////////
const QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT = "qqq.quickFilterFieldNames";
const quickFilterFieldNamesLocalStorageKey = `${QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT}.${tableMetaData.name}`;
let defaultQuickFilterFieldNames: Set<string> = new Set<string>();
if (localStorage.getItem(quickFilterFieldNamesLocalStorageKey))
{
defaultQuickFilterFieldNames = new Set<string>(JSON.parse(localStorage.getItem(quickFilterFieldNamesLocalStorageKey)));
}
const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props
/////////////////////
// state variables //
/////////////////////
const [quickFilterFieldNames, setQuickFilterFieldNames] = useState(defaultQuickFilterFieldNames);
const [defaultQuickFilterFieldNames, setDefaultQuickFilterFieldNames] = useState(getDefaultQuickFilterFieldNames(tableMetaData));
const [defaultQuickFilterFieldNameMap, setDefaultQuickFilterFieldNameMap] = useState(Object.fromEntries(defaultQuickFilterFieldNames.map(k => [k, true])));
const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null)
const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0);
const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false);
const [mouseOverElement, setMouseOverElement] = useState(null as string);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const {accentColor} = useContext(QContext);
//////////////////////////////////////////////////////////////////////////////////
// make some functions available to our parent - so it can tell us to do things //
//////////////////////////////////////////////////////////////////////////////////
@ -115,11 +117,33 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
addField(fieldName: string)
{
addQuickFilterField({fieldName: fieldName}, "columnMenu");
},
getCurrentMode()
{
return (mode);
}
}
});
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOverElement(name: string)
{
setMouseOverElement(name);
}
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOutElement()
{
setMouseOverElement(null);
}
/*******************************************************************************
** for a given field, set its default operator for quick-filter dropdowns.
*******************************************************************************/
@ -167,8 +191,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
{
queryFilter.criteria.splice(foundIndex, 1);
setQueryFilter(queryFilter);
const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter);
handleFilterChange(gridFilterModel, false);
}
return;
}
@ -189,8 +211,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
debounceTimeout = setTimeout(() =>
{
setQueryFilter(queryFilter);
const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter);
handleFilterChange(gridFilterModel, false);
}, needDebounce ? 500 : 1);
forceUpdate();
@ -228,23 +248,14 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
};
/*******************************************************************************
** set the quick-filter field names state variable and local-storage
*******************************************************************************/
const storeQuickFilterFieldNames = () =>
{
setQuickFilterFieldNames(new Set<string>([...quickFilterFieldNames.values()]));
localStorage.setItem(quickFilterFieldNamesLocalStorageKey, JSON.stringify([...quickFilterFieldNames.values()]));
}
/*******************************************************************************
** Event handler for QuickFilter component, to remove a quick filter field from
** the screen.
*******************************************************************************/
const handleRemoveQuickFilterField = (fieldName: string): void =>
{
if(quickFilterFieldNames.has(fieldName))
const index = quickFilterFieldNames.indexOf(fieldName)
if(index >= 0)
{
//////////////////////////////////////
// remove this field from the query //
@ -252,8 +263,8 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
const criteria = new QFilterCriteria(fieldName, null, []);
updateQuickCriteria(criteria, false, true);
quickFilterFieldNames.delete(fieldName);
storeQuickFilterFieldNames();
quickFilterFieldNames.splice(index, 1);
setQuickFilterFieldNames(quickFilterFieldNames);
}
};
@ -281,7 +292,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
** Add a quick-filter field to the screen, from either the user selecting one,
** or from a new query being activated, etc.
*******************************************************************************/
const addQuickFilterField = (newValue: any, reason: "blur" | "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | "columnMenu" | string) =>
const addQuickFilterField = (newValue: any, reason: "blur" | "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | "columnMenu" | "activatedView" | string) =>
{
console.log(`Adding quick filter field as: ${JSON.stringify(newValue)}`);
if (reason == "blur")
@ -295,18 +306,23 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
const fieldName = newValue ? newValue.fieldName : null;
if (fieldName)
{
if (!quickFilterFieldNames.has(fieldName))
if(defaultQuickFilterFieldNameMap[fieldName])
{
return;
}
if (quickFilterFieldNames.indexOf(fieldName) == -1)
{
/////////////////////////////////
// add the field if we need to //
/////////////////////////////////
quickFilterFieldNames.add(fieldName);
storeQuickFilterFieldNames();
quickFilterFieldNames.push(fieldName);
setQuickFilterFieldNames(quickFilterFieldNames);
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// only do this when user has added the field (e.g., not when adding it because of a selected view or filter-in-url) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected")
if(reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected" && reason != "activatedView")
{
setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5);
}
@ -325,7 +341,22 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
/*******************************************************************************
** event handler for the Filter Buidler button - e.g., opens the parent's grid's
**
*******************************************************************************/
const handleFieldListMenuSelection = (field: QFieldMetaData, table: QTableMetaData): void =>
{
let fullFieldName = field.name;
if(table && table.name != tableMetaData.name)
{
fullFieldName = `${table.name}.${field.name}`;
}
addQuickFilterField({fieldName: fullFieldName}, "selectedFromAddFilterMenu");
}
/*******************************************************************************
** event handler for the Filter Builder button - e.g., opens the parent's grid's
** filter panel
*******************************************************************************/
const openFilterBuilder = (e: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>) =>
@ -342,11 +373,21 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
if (isYesButton || event.key == "Enter")
{
setShowClearFiltersWarning(false);
handleFilterChange({items: []} as GridFilterModel);
setQueryFilter(new QQueryFilter([], queryFilter.orderBys));
}
};
/*******************************************************************************
**
*******************************************************************************/
const removeCriteriaByIndex = (index: number) =>
{
queryFilter.criteria.splice(index, 1);
setQueryFilter(queryFilter);
}
/*******************************************************************************
** format the current query as a string for showing on-screen as a preview.
*******************************************************************************/
@ -360,19 +401,19 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
let counter = 0;
return (
<span>
<Box display="flex" flexWrap="wrap" fontSize="0.875rem">
{queryFilter.criteria.map((criteria, i) =>
{
if(criteria && criteria.fieldName && criteria.operator)
const {criteriaIsValid} = validateCriteria(criteria, null);
if(criteriaIsValid)
{
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName);
const valuesString = FilterUtils.getValuesString(field, criteria);
counter++;
return (
<span key={i}>
{counter > 1 ? <span>{queryFilter.booleanOperator}&nbsp;</span> : <span/>}
<b>{field.label}</b> {criteria.operator} <span style={{color: "blue"}}>{valuesString}</span>&nbsp;
<span key={i} style={{marginBottom: "0.125rem"}} onMouseOver={() => handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}>
{counter > 1 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{queryFilter.booleanOperator}&nbsp;</span> : <span/>}
{FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)}
{mouseOverElement == `queryPreview-${i}` && <span className={`advancedQueryPreviewX-${counter - 1}`}><XIcon position="forAdvancedQueryPreview" onClick={() => removeCriteriaByIndex(i)} /></span>}
</span>
);
}
@ -381,7 +422,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
return (<span />);
}
})}
</span>
</Box>
);
};
@ -411,7 +452,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
////////////////////////////////////////////////////////////////////////////////////////////////
if (queryFilter && queryFilter.criteria)
{
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "modeToggleClicked");
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "modeToggleClicked", "basic");
}
}
@ -427,7 +468,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
** make sure that all fields in the current query are on-screen as quick-filters
** (that is, if the query can be basic)
*******************************************************************************/
const ensureAllFilterCriteriaAreActiveQuickFilters = (tableMetaData: QTableMetaData, queryFilter: QQueryFilter, reason: "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | string) =>
const ensureAllFilterCriteriaAreActiveQuickFilters = (tableMetaData: QTableMetaData, queryFilter: QQueryFilter, reason: "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | string, newMode?: string) =>
{
if(!tableMetaData || !queryFilter)
{
@ -443,71 +484,100 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
return;
}
for (let i = 0; i < queryFilter?.criteria?.length; i++)
const modeToUse = newMode ?? mode;
if(modeToUse == "basic")
{
const criteria = queryFilter.criteria[i];
if (criteria && criteria.fieldName)
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
addQuickFilterField(criteria, reason);
const criteria = queryFilter.criteria[i];
if (criteria && criteria.fieldName)
{
addQuickFilterField(criteria, reason);
}
}
}
}
//////////////////////////////////////////////////////////////////////////////
// if there aren't any quick-filters turned on, get defaults from the table //
// only run this block upon a first-render //
//////////////////////////////////////////////////////////////////////////////
const [firstRender, setFirstRender] = useState(true);
if(firstRender)
/*******************************************************************************
** count how many valid criteria are in the query - for showing badge
*******************************************************************************/
const countValidCriteria = (queryFilter: QQueryFilter): number =>
{
setFirstRender(false);
if (defaultQuickFilterFieldNames == null || defaultQuickFilterFieldNames.size == 0)
let count = 0;
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
defaultQuickFilterFieldNames = new Set<string>();
//////////////////////////////////////////////////////////////////////////////////////////////////
// check if there's materialDashboard tableMetaData, and if it has defaultQuickFilterFieldNames //
//////////////////////////////////////////////////////////////////////////////////////////////////
const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard");
if (mdbMetaData)
const {criteriaIsValid} = validateCriteria(queryFilter.criteria[i], null);
if(criteriaIsValid)
{
if (mdbMetaData?.defaultQuickFilterFieldNames?.length)
{
for (let i = 0; i < mdbMetaData.defaultQuickFilterFieldNames.length; i++)
{
defaultQuickFilterFieldNames.add(mdbMetaData.defaultQuickFilterFieldNames[i]);
}
}
count++;
}
/////////////////////////////////////////////
// if still none, then look for T1 section //
/////////////////////////////////////////////
if (defaultQuickFilterFieldNames.size == 0)
{
if (tableMetaData.sections)
{
const t1Sections = tableMetaData.sections.filter((s: QTableSection) => s.tier == "T1");
if (t1Sections.length)
{
for (let i = 0; i < t1Sections.length; i++)
{
if (t1Sections[i].fieldNames)
{
for (let j = 0; j < t1Sections[i].fieldNames.length; j++)
{
defaultQuickFilterFieldNames.add(t1Sections[i].fieldNames[j]);
}
}
}
}
}
}
setQuickFilterFieldNames(defaultQuickFilterFieldNames);
}
return count;
}
/*******************************************************************************
** Event handler for setting the sort from that menu
*******************************************************************************/
const handleSetSort = (field: QFieldMetaData, table: QTableMetaData, isAscending: boolean = true): void =>
{
const fullFieldName = table && table.name != tableMetaData.name ? `${table.name}.${field.name}` : field.name;
queryFilter.orderBys = [new QFilterOrderBy(fullFieldName, isAscending)]
setQueryFilter(queryFilter);
forceUpdate();
}
/*******************************************************************************
** event handler for a click on a field's up or down arrow in the sort menu
*******************************************************************************/
const handleSetSortArrowClick = (field: QFieldMetaData, table: QTableMetaData, event: any): void =>
{
event.stopPropagation();
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// make sure this is an event handler for one of our icons (not something else in the dom here in our end-adornments) //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const isAscending = event.target.innerHTML == "arrow_upward";
const isDescending = event.target.innerHTML == "arrow_downward";
if(isAscending || isDescending)
{
handleSetSort(field, table, isAscending);
}
}
/*******************************************************************************
** event handler for clicking the current sort up/down arrow, to toggle direction.
*******************************************************************************/
function toggleSortDirection(event: React.MouseEvent<HTMLSpanElement, MouseEvent>): void
{
event.stopPropagation();
try
{
queryFilter.orderBys[0].isAscending = !queryFilter.orderBys[0].isAscending;
setQueryFilter(queryFilter);
forceUpdate();
}
catch(e)
{
console.log(`Error toggling sort: ${e}`)
}
}
/////////////////////////////////
// set up the sort menu button //
/////////////////////////////////
let sortButtonContents = <>Sort...</>
if(queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0)
{
const orderBy = queryFilter.orderBys[0];
const orderByFieldName = orderBy.fieldName;
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName);
const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`;
sortButtonContents = <>Sort: {fieldLabel} <Icon onClick={toggleSortDirection} sx={{ml: "0.5rem"}}>{orderBy.isAscending ? "arrow_upward" : "arrow_downward"}</Icon></>
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -523,137 +593,194 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
///////////////////////////////////////////////////
// set some status flags based on current filter //
///////////////////////////////////////////////////
const hasValidFilters = queryFilter && queryFilter.criteria && queryFilter.criteria.length > 0; // todo - should be better (e.g., see if operator & values are set)
const hasValidFilters = queryFilter && countValidCriteria(queryFilter) > 0;
const {canFilterWorkAsBasic, reasonsWhyItCannot} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
let reasonWhyBasicIsDisabled = null;
if(reasonsWhyItCannot && reasonsWhyItCannot.length > 0)
{
reasonWhyBasicIsDisabled = <>
Your current Filter cannot be managed using BASIC mode because:
Your current Filter cannot be managed using Basic mode because:
<ul style={{marginLeft: "1rem"}}>
{reasonsWhyItCannot.map((reason, i) => <li key={i}>{reason}</li>)}
</ul>
</>
}
return (
<Box display="flex" alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" position="relative" top={"-0.5rem"} left={"0.5rem"} minHeight="2.5rem">
<Box display="flex" alignItems="center" flexShrink={1} flexGrow={1}>
{
mode == "basic" &&
<Box width="100px" flexShrink={1} flexGrow={1}>
{
tableMetaData &&
[...quickFilterFieldNames.values()].map((fieldName) =>
{
const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let defaultOperator = getDefaultOperatorForField(field);
const borderGray = colors.grayLines.main;
return (
field && <QuickFilter
key={fieldName}
fullFieldName={fieldName}
tableMetaData={tableMetaData}
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
defaultOperator={defaultOperator}
handleRemoveQuickFilterField={handleRemoveQuickFilterField} />
);
})
}
{
tableMetaData &&
const sortMenuComponent = (
<FieldListMenu
idPrefix="sort"
tableMetaData={tableMetaData}
placeholder="Search Fields"
buttonProps={{disableRipple: true, sx: {textTransform: "none", color: colors.gray.main, paddingRight: 0}}}
buttonChildren={sortButtonContents}
isModeSelectOne={true}
handleSelectedField={handleSetSort}
fieldEndAdornment={<Box whiteSpace="nowrap"><Icon>arrow_upward</Icon><Icon>arrow_downward</Icon></Box>}
handleAdornmentClick={handleSetSortArrowClick}
/>);
const filterBuilderMouseEvents =
{
onMouseOver: () => handleMouseOverElement("filterBuilderButton"),
onMouseOut: () => handleMouseOutElement()
};
return (
<Box pb={mode == "advanced" ? "0.25rem" : "0"}>
{/* First row: Saved Views button (with Columns button in the middle of it), then space-between, then basic|advanced toggle */}
<Box display="flex" justifyContent="space-between" pt={"0.5rem"} pb={"0.5rem"}>
<Box display="flex">
{savedViewsComponent}
{columnMenuComponent}
</Box>
<Box>
<Tooltip title={reasonWhyBasicIsDisabled}>
<ToggleButtonGroup
value={mode}
exclusive
onChange={(event, newValue) => modeToggleClicked(newValue)}
size="small"
sx={{pl: 0.5, width: "10rem"}}
>
<ToggleButton value="basic" disabled={!canFilterWorkAsBasic}>Basic</ToggleButton>
<ToggleButton value="advanced">Advanced</ToggleButton>
</ToggleButtonGroup>
</Tooltip>
</Box>
</Box>
{/* Second row: Basic or advanced mode - with sort-by control on the right (of each) */}
<Box pb={"0.25rem"}>
{
///////////////////////////////////////////////////////////////////////////////////
// basic mode - wrapping-list of fields & add-field button, then sort-by control //
///////////////////////////////////////////////////////////////////////////////////
mode == "basic" &&
<Box display="flex" alignItems="flex-start" flexShrink={1} flexGrow={1}>
<Box width="100px" flexShrink={1} flexGrow={1}>
<>
<Tooltip enterDelay={500} title="Add a Quick Filter field" placement="top">
<Button onClick={(e) => openAddQuickFilterMenu(e)} startIcon={<Icon>add_circle_outline</Icon>} sx={{border: "1px solid gray", whiteSpace: "nowrap", minWidth: "120px"}}>
Add Field
</Button>
</Tooltip>
<Menu
anchorEl={addQuickFilterMenu}
anchorOrigin={{vertical: "bottom", horizontal: "left"}}
transformOrigin={{vertical: "top", horizontal: "left"}}
transitionDuration={0}
open={Boolean(addQuickFilterMenu)}
onClose={closeAddQuickFilterMenu}
keepMounted
>
<Box width="250px">
<FieldAutoComplete
key={addQuickFilterOpenCounter} // use a unique key each time we open it, because we don't want the user's last selection to stick.
id={"add-quick-filter-field"}
metaData={metaData}
{
tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) =>
{
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let defaultOperator = getDefaultOperatorForField(field);
return (<QuickFilter
key={fieldName}
fullFieldName={fieldName}
tableMetaData={tableMetaData}
defaultValue={null}
handleFieldChange={(e, newValue, reason) => addQuickFilterField(newValue, reason)}
autoFocus={true}
forceOpen={Boolean(addQuickFilterMenu)}
hiddenFieldNames={[...quickFilterFieldNames.values()]}
/>
</Box>
</Menu>
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
defaultOperator={defaultOperator}
handleRemoveQuickFilterField={null} />);
})
}
{/* vertical rule */}
<Box display="inline-block" borderLeft={`1px solid ${borderGray}`} height="1.75rem" width="1px" marginRight="0.5rem" position="relative" top="0.5rem" />
{
tableMetaData && quickFilterFieldNames?.map((fieldName) =>
{
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let defaultOperator = getDefaultOperatorForField(field);
return (defaultQuickFilterFieldNameMap[fieldName] ? null : <QuickFilter
key={fieldName}
fullFieldName={fieldName}
tableMetaData={tableMetaData}
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
defaultOperator={defaultOperator}
handleRemoveQuickFilterField={handleRemoveQuickFilterField} />);
})
}
{
tableMetaData && <FieldListMenu
key={JSON.stringify(quickFilterFieldNames)} // use a unique key each time we open it, because we don't want the user's last selection to stick.
idPrefix="addQuickFilter"
tableMetaData={tableMetaData}
fieldNamesToHide={[...(defaultQuickFilterFieldNames ?? []), ...(quickFilterFieldNames ?? [])]}
placeholder="Search Fields"
buttonProps={{sx: quickFilterButtonStyles, startIcon: (<Icon>add</Icon>)}}
buttonChildren={"Add Filter"}
isModeSelectOne={true}
handleSelectedField={handleFieldListMenuSelection}
/>
}
</>
}
</Box>
<Box>
{sortMenuComponent}
</Box>
</Box>
}
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// advanced mode - 2 rows - one for Filter Builder button & sort control, 2nd row for the filter-detail box //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
metaData && tableMetaData && mode == "advanced" &&
<>
<Tooltip enterDelay={500} title="Build an advanced Filter" placement="top">
<Button onClick={(e) => openFilterBuilder(e)} startIcon={<Badge badgeContent={queryFilter?.criteria?.length} color="warning" sx={{"& .MuiBadge-badge": {color: "#FFFFFF"}}} anchorOrigin={{vertical: "top", horizontal: "left"}}><Icon>filter_list</Icon></Badge>} sx={{width: "180px", minWidth: "180px", border: "1px solid gray"}}>
Filter Builder
</Button>
</Tooltip>
<div id="clearFiltersButton" style={{display: "inline-block", position: "relative", top: "2px", left: "-1.5rem", width: "1rem"}}>
{
hasValidFilters && (
<Box borderRadius="0.75rem" border={`1px solid ${borderGray}`}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box p="0.5rem">
<Tooltip enterDelay={500} title="Build an advanced Filter" placement="top">
<>
<Tooltip title="Clear Filter">
<Icon sx={{cursor: "pointer"}} onClick={() => setShowClearFiltersWarning(true)}>clear</Icon>
</Tooltip>
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(true)} onKeyPress={(e) => handleClearFiltersAction(e)}>
<DialogTitle id="alert-dialog-title">Confirm</DialogTitle>
<DialogContent>
<DialogContentText>Are you sure you want to remove all conditions from the current filter?</DialogContentText>
</DialogContent>
<DialogActions>
<QCancelButton label="No" disabled={false} onClickHandler={() => setShowClearFiltersWarning(true)} />
<QSaveButton label="Yes" iconName="check" disabled={false} onClickHandler={() => handleClearFiltersAction(null, true)} />
</DialogActions>
</Dialog>
<Button
className="filterBuilderButton"
onClick={(e) => openFilterBuilder(e)}
{... filterBuilderMouseEvents}
startIcon={<Icon>build</Icon>}
sx={{borderRadius: "0.75rem", padding: "0.5rem", pl: "1rem", fontSize: "0.875rem", fontWeight: 500, border: `1px solid ${accentColor}`, textTransform: "none"}}
>
Filter Builder
{
countValidCriteria(queryFilter) > 0 &&
<Box {... filterBuilderMouseEvents} sx={{backgroundColor: accentColor, marginLeft: "0.25rem", minWidth: "1rem", fontSize: "0.75rem"}} borderRadius="50%" color="#FFFFFF" position="relative" top="-2px" className="filterBuilderCountBadge">
{countValidCriteria(queryFilter) }
</Box>
}
</Button>
{
hasValidFilters && mouseOverElement == "filterBuilderButton" && <span {... filterBuilderMouseEvents} className="filterBuilderXIcon"><XIcon shade="accent" position="default" onClick={() => setShowClearFiltersWarning(true)} /></span>
}
</>
)
}
</div>
<Box sx={{fontSize: "1rem"}} whiteSpace="nowrap" display="flex" ml={0.25} flexShrink={1} flexGrow={1} alignItems="center">
Current Filter:
</Tooltip>
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(false)} onKeyPress={(e) => handleClearFiltersAction(e)}>
<DialogTitle id="alert-dialog-title">Confirm</DialogTitle>
<DialogContent>
<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={() => handleClearFiltersAction(null, true)} />
</DialogActions>
</Dialog>
</Box>
<Box pr={"0.5rem"}>
{sortMenuComponent}
</Box>
</Box>
<Box whiteSpace="nowrap" display="flex" flexShrink={1} flexGrow={1} alignItems="center">
{
<Box display="inline-block" border="1px solid gray" borderRadius="0.5rem" whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis" width="100px" flexShrink={1} flexGrow={1} sx={{fontSize: "1rem"}} minHeight={"2rem"} p={0.25} ml={0.5}>
<Box
className="advancedQueryString"
display="inline-block"
borderTop={`1px solid ${borderGray}`}
borderRadius="0 0 0.75rem 0.75rem"
width="100%"
sx={{fontSize: "1rem", background: "#FFFFFF"}}
minHeight={"2.375rem"}
p={"0.5rem"}
pb={"0.125rem"}
boxShadow={"inset 0px 0px 4px 2px #EFEFED"}
>
{queryToAdvancedString()}
</Box>
}
</Box>
</>
}
</Box>
<Box display="flex" alignItems="center">
{
metaData && tableMetaData &&
<Box px={1} display="flex" alignItems="center">
<Typography display="inline" sx={{fontSize: "1rem"}}>Mode:</Typography>
<Tooltip title={reasonWhyBasicIsDisabled}>
<ToggleButtonGroup
value={mode}
exclusive
onChange={(event, newValue) => modeToggleClicked(newValue)}
size="small"
sx={{pl: 0.5}}
>
<ToggleButton value="basic" disabled={!canFilterWorkAsBasic}>Basic</ToggleButton>
<ToggleButton value="advanced">Advanced</ToggleButton>
</ToggleButtonGroup>
</Tooltip>
</Box>
}
</Box>
@ -661,4 +788,50 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
);
});
export default BasicAndAdvancedQueryControls;
export function getDefaultQuickFilterFieldNames(table: QTableMetaData): string[]
{
const defaultQuickFilterFieldNames: string[] = [];
//////////////////////////////////////////////////////////////////////////////////////////////////
// check if there's materialDashboard tableMetaData, and if it has defaultQuickFilterFieldNames //
//////////////////////////////////////////////////////////////////////////////////////////////////
const mdbMetaData = table?.supplementalTableMetaData?.get("materialDashboard");
if (mdbMetaData)
{
if (mdbMetaData?.defaultQuickFilterFieldNames?.length)
{
for (let i = 0; i < mdbMetaData.defaultQuickFilterFieldNames.length; i++)
{
defaultQuickFilterFieldNames.push(mdbMetaData.defaultQuickFilterFieldNames[i]);
}
}
}
/////////////////////////////////////////////
// if still none, then look for T1 section //
/////////////////////////////////////////////
if (defaultQuickFilterFieldNames.length == 0)
{
if (table.sections)
{
const t1Sections = table.sections.filter((s: QTableSection) => s.tier == "T1");
if (t1Sections.length)
{
for (let i = 0; i < t1Sections.length; i++)
{
if (t1Sections[i].fieldNames)
{
for (let j = 0; j < t1Sections[i].fieldNames.length; j++)
{
defaultQuickFilterFieldNames.push(t1Sections[i].fieldNames[j]);
}
}
}
}
}
}
return (defaultQuickFilterFieldNames);
}
export default BasicAndAdvancedQueryControls;

View File

@ -0,0 +1,122 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {TablePagination} from "@mui/material";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import {GridRowsProp} from "@mui/x-data-grid-pro";
import React from "react";
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface CustomPaginationProps
{
tableMetaData: QTableMetaData;
rows: GridRowsProp[];
totalRecords: number;
distinctRecords: number;
pageNumber: number;
rowsPerPage: number;
loading: boolean;
isJoinMany: boolean;
handlePageChange: (value: number) => void;
handleRowsPerPageChange: (value: number) => void;
}
/*******************************************************************************
** DataGrid custom component - for pagination!
*******************************************************************************/
export default function CustomPaginationComponent({tableMetaData, rows, totalRecords, distinctRecords, pageNumber, rowsPerPage, loading, isJoinMany, handlePageChange, handleRowsPerPageChange}: CustomPaginationProps): JSX.Element
{
// @ts-ignore
const defaultLabelDisplayedRows = ({from, to, count}) =>
{
const tooltipHTML = <>
The number of rows shown on this screen may be greater than the number of {tableMetaData?.label} records
that match your query, because you have included fields from other tables which may have
more than one record associated with each {tableMetaData?.label}.
</>
let distinctPart = isJoinMany ? (<Box display="inline" component="span" textAlign="right">
&nbsp;({ValueUtils.safeToLocaleString(distinctRecords)} distinct<CustomWidthTooltip title={tooltipHTML}>
<IconButton sx={{p: 0, pl: 0.25, mb: 0.25}}><Icon fontSize="small" sx={{fontSize: "1.125rem !important", color: "#9f9f9f"}}>info_outlined</Icon></IconButton>
</CustomWidthTooltip>
)
</Box>) : <></>;
if (tableMetaData && !tableMetaData.capabilities.has(Capability.TABLE_COUNT))
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// to avoid a non-countable table showing (this is what data-grid did) 91-100 even if there were only 95 records, //
// we'll do this... not quite good enough, but better than the original //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (rows.length > 0 && rows.length < to - from)
{
to = from + rows.length;
}
return (`Showing ${from.toLocaleString()} to ${to.toLocaleString()}`);
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// treat -1 as the sentinel that it's set as below -- remember, we did that so that 'to' would have a value in here when there's no count. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (count !== null && count !== undefined && count !== -1)
{
if (count === 0)
{
return (loading ? "Counting..." : "No rows");
}
return <span>
Showing {from.toLocaleString()} to {to.toLocaleString()} of
{
count == -1 ?
<>more than {to.toLocaleString()}</>
: <> {count.toLocaleString()}{distinctPart}</>
}
</span>;
}
else
{
return ("Counting...");
}
};
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}
page={pageNumber}
rowsPerPageOptions={[10, 25, 50, 100, 250]}
rowsPerPage={rowsPerPage}
onPageChange={(event, value) => handlePageChange(value)}
onRowsPerPageChange={(event) => handleRowsPerPageChange(Number(event.target.value))}
labelDisplayedRows={defaultLabelDisplayedRows}
/>
);
}

View File

@ -45,7 +45,7 @@ export default function ExportMenuItem(props: QExportMenuItemProps)
return (
<MenuItem
disabled={totalRecords === 0}
disabled={!totalRecords}
onClick={() =>
{
///////////////////////////////////////////////////////////////////////////////

View File

@ -0,0 +1,736 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import FormControlLabel from "@mui/material/FormControlLabel";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List/List";
import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem";
import Menu from "@mui/material/Menu";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import React, {useState} from "react";
interface FieldListMenuProps
{
idPrefix: string;
heading?: string;
placeholder?: string;
tableMetaData: QTableMetaData;
showTableHeaderEvenIfNoExposedJoins: boolean;
fieldNamesToHide?: string[];
buttonProps: any;
buttonChildren: JSX.Element | string;
isModeSelectOne?: boolean;
handleSelectedField?: (field: QFieldMetaData, table: QTableMetaData) => void;
isModeToggle?: boolean;
toggleStates?: {[fieldName: string]: boolean};
handleToggleField?: (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) => void;
fieldEndAdornment?: JSX.Element
handleAdornmentClick?: (field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent<any>) => void;
}
FieldListMenu.defaultProps = {
showTableHeaderEvenIfNoExposedJoins: false,
isModeSelectOne: false,
isModeToggle: false,
};
interface TableWithFields
{
table?: QTableMetaData;
fields: QFieldMetaData[];
}
/*******************************************************************************
** Component to render a list of fields from a table (and its join tables)
** which can be interacted with...
*******************************************************************************/
export default function FieldListMenu({idPrefix, heading, placeholder, tableMetaData, showTableHeaderEvenIfNoExposedJoins, buttonProps, buttonChildren, isModeSelectOne, fieldNamesToHide, handleSelectedField, isModeToggle, toggleStates, handleToggleField, fieldEndAdornment, handleAdornmentClick}: FieldListMenuProps): JSX.Element
{
const [menuAnchorElement, setMenuAnchorElement] = useState(null);
const [searchText, setSearchText] = useState("");
const [focusedIndex, setFocusedIndex] = useState(null as number);
const [fieldsByTable, setFieldsByTable] = useState([] as TableWithFields[]);
const [collapsedTables, setCollapsedTables] = useState({} as {[tableName: string]: boolean});
const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0});
const [timeOfLastArrow, setTimeOfLastArrow] = useState(0)
//////////////////
// check usages //
//////////////////
if(isModeSelectOne)
{
if(!handleSelectedField)
{
throw("In FieldListMenu, if isModeSelectOne=true, then a callback for handleSelectedField must be provided.");
}
}
if(isModeToggle)
{
if(!toggleStates)
{
throw("In FieldListMenu, if isModeToggle=true, then a model for toggleStates must be provided.");
}
if(!handleToggleField)
{
throw("In FieldListMenu, if isModeToggle=true, then a callback for handleToggleField must be provided.");
}
}
/////////////////////
// init some stuff //
/////////////////////
if (fieldsByTable.length == 0)
{
collapsedTables[tableMetaData.name] = false;
if (tableMetaData.exposedJoins?.length > 0)
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
fieldsByTable.push({table: tableMetaData, fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
for (let i = 0; i < tableMetaData.exposedJoins?.length; i++)
{
const joinTable = tableMetaData.exposedJoins[i].joinTable;
fieldsByTable.push({table: joinTable, fields: getTableFieldsAsAlphabeticalArray(joinTable)});
collapsedTables[joinTable.name] = false;
}
}
else
{
///////////////////////////////////////////////////////////
// no exposed joins - just the table (w/o its meta-data) //
///////////////////////////////////////////////////////////
fieldsByTable.push({fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
}
setFieldsByTable(fieldsByTable);
setCollapsedTables(collapsedTables);
}
/*******************************************************************************
**
*******************************************************************************/
function getTableFieldsAsAlphabeticalArray(table: QTableMetaData): QFieldMetaData[]
{
const fields: QFieldMetaData[] = [];
table.fields.forEach(field =>
{
let fullFieldName = field.name;
if(table.name != tableMetaData.name)
{
fullFieldName = `${table.name}.${field.name}`;
}
if(fieldNamesToHide && fieldNamesToHide.indexOf(fullFieldName) > -1)
{
return;
}
fields.push(field)
});
fields.sort((a, b) => a.label.localeCompare(b.label));
return (fields);
}
const fieldsByTableToShow: TableWithFields[] = [];
let maxFieldIndex = 0;
fieldsByTable.forEach((tableWithFields) =>
{
let fieldsToShowForThisTable = tableWithFields.fields.filter(doesFieldMatchSearchText);
if (fieldsToShowForThisTable.length > 0)
{
fieldsByTableToShow.push({table: tableWithFields.table, fields: fieldsToShowForThisTable});
maxFieldIndex += fieldsToShowForThisTable.length;
}
});
/*******************************************************************************
**
*******************************************************************************/
function getShownFieldAndTableByIndex(targetIndex: number): {field: QFieldMetaData, table: QTableMetaData}
{
let index = -1;
for (let i = 0; i < fieldsByTableToShow.length; i++)
{
const tableWithField = fieldsByTableToShow[i];
for (let j = 0; j < tableWithField.fields.length; j++)
{
index++;
if(index == targetIndex)
{
return {field: tableWithField.fields[j], table: tableWithField.table}
}
}
}
return (null);
}
/*******************************************************************************
** event handler for keys presses
*******************************************************************************/
function keyDown(event: any)
{
// console.log(`Event key: ${event.key}`);
setTimeout(() => document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus());
if(isModeSelectOne && event.key == "Enter" && focusedIndex != null)
{
setTimeout(() =>
{
event.stopPropagation();
closeMenu();
const {field, table} = getShownFieldAndTableByIndex(focusedIndex);
if (field)
{
handleSelectedField(field, table ?? tableMetaData);
}
});
return;
}
const keyOffsetMap: { [key: string]: number } = {
"End": 10000,
"Home": -10000,
"ArrowDown": 1,
"ArrowUp": -1,
"PageDown": 5,
"PageUp": -5,
};
const offset = keyOffsetMap[event.key];
if (offset)
{
event.stopPropagation();
setTimeOfLastArrow(new Date().getTime());
if (isModeSelectOne)
{
let startIndex = focusedIndex;
if (offset > 0)
{
/////////////////
// a down move //
/////////////////
if(startIndex == null)
{
startIndex = -1;
}
let goalIndex = startIndex + offset;
if(goalIndex > maxFieldIndex - 1)
{
goalIndex = maxFieldIndex - 1;
}
doSetFocusedIndex(goalIndex, true);
}
else
{
////////////////
// an up move //
////////////////
let goalIndex = startIndex + offset;
if(goalIndex < 0)
{
goalIndex = 0;
}
doSetFocusedIndex(goalIndex, true);
}
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function doSetFocusedIndex(i: number, tryToScrollIntoView: boolean): void
{
if (isModeSelectOne)
{
setFocusedIndex(i);
console.log(`Setting index to ${i}`);
if (tryToScrollIntoView)
{
const element = document.getElementById(`field-list-dropdown-${idPrefix}-${i}`);
element?.scrollIntoView({block: "center"});
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function setFocusedField(field: QFieldMetaData, table: QTableMetaData, tryToScrollIntoView: boolean)
{
let index = -1;
for (let i = 0; i < fieldsByTableToShow.length; i++)
{
const tableWithField = fieldsByTableToShow[i];
for (let j = 0; j < tableWithField.fields.length; j++)
{
const loopField = tableWithField.fields[j];
index++;
const tableMatches = (table == null || table.name == tableWithField.table.name);
if (tableMatches && field.name == loopField.name)
{
doSetFocusedIndex(index, tryToScrollIntoView);
return;
}
}
}
}
/*******************************************************************************
** event handler for mouse-over the menu
*******************************************************************************/
function handleMouseOver(event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLDivElement> | React.MouseEvent<HTMLLIElement>, field: QFieldMetaData, table: QTableMetaData)
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// so we're trying to fix the case where, if you put your mouse over an element, but then press up/down arrows, //
// where the mouse will become over a different element after the scroll, and the focus will follow the mouse instead of keyboard. //
// the last x/y isn't really useful, because the mouse generally isn't left exactly where it was when the mouse-over happened (edge of the element) //
// but the keyboard last-arrow time that we capture, that's what's actually being useful in here //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(event.clientX == lastMouseOverXY.x && event.clientY == lastMouseOverXY.y)
{
// console.log("mouse didn't move, so, doesn't count");
return;
}
const now = new Date().getTime();
// console.log(`Compare now [${now}] to last arrow [${timeOfLastArrow}] (diff: [${now - timeOfLastArrow}])`);
if(now < timeOfLastArrow + 300)
{
// console.log("An arrow event happened less than 300 mills ago, so doesn't count.");
return;
}
// console.log("yay, mouse over...");
setFocusedField(field, table, false);
setLastMouseOverXY({x: event.clientX, y: event.clientY});
}
/*******************************************************************************
** event handler for text input changes
*******************************************************************************/
function updateSearch(event: React.ChangeEvent<HTMLInputElement>)
{
setSearchText(event?.target?.value ?? "");
doSetFocusedIndex(0, true);
}
/*******************************************************************************
**
*******************************************************************************/
function doesFieldMatchSearchText(field: QFieldMetaData): boolean
{
if (searchText == "")
{
return (true);
}
const columnLabelMinusTable = field.label.replace(/.*: /, "");
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
try
{
////////////////////////////////////////////////////////////
// try to match word-boundary followed by the filter text //
// e.g., "name" would match "First Name" or "Last Name" //
////////////////////////////////////////////////////////////
const re = new RegExp("\\b" + searchText.toLowerCase());
if (columnLabelMinusTable.toLowerCase().match(re))
{
return (true);
}
}
catch (e)
{
//////////////////////////////////////////////////////////////////////////////////
// in case text is an invalid regex... well, at least do a starts-with match... //
//////////////////////////////////////////////////////////////////////////////////
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
}
const tableLabel = field.label.replace(/:.*/, "");
if (tableLabel)
{
try
{
////////////////////////////////////////////////////////////
// try to match word-boundary followed by the filter text //
// e.g., "name" would match "First Name" or "Last Name" //
////////////////////////////////////////////////////////////
const re = new RegExp("\\b" + searchText.toLowerCase());
if (tableLabel.toLowerCase().match(re))
{
return (true);
}
}
catch (e)
{
//////////////////////////////////////////////////////////////////////////////////
// in case text is an invalid regex... well, at least do a starts-with match... //
//////////////////////////////////////////////////////////////////////////////////
if (tableLabel.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
}
}
return (false);
}
/*******************************************************************************
**
*******************************************************************************/
function openMenu(event: any)
{
setFocusedIndex(null);
setMenuAnchorElement(event.currentTarget);
setTimeout(() =>
{
document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus();
doSetFocusedIndex(0, true);
});
}
/*******************************************************************************
**
*******************************************************************************/
function closeMenu()
{
setMenuAnchorElement(null);
}
/*******************************************************************************
** Event handler for toggling a field in toggle mode
*******************************************************************************/
function handleFieldToggle(event: React.ChangeEvent<HTMLInputElement>, field: QFieldMetaData, table: QTableMetaData)
{
event.stopPropagation();
handleToggleField(field, table, event.target.checked);
}
/*******************************************************************************
** Event handler for toggling a table in toggle mode
*******************************************************************************/
function handleTableToggle(event: React.ChangeEvent<HTMLInputElement>, table: QTableMetaData)
{
event.stopPropagation();
const fieldsList = [...table.fields.values()];
for (let i = 0; i < fieldsList.length; i++)
{
const field = fieldsList[i];
if(doesFieldMatchSearchText(field))
{
handleToggleField(field, table, event.target.checked);
}
}
}
/////////////////////////////////////////////////////////
// compute the table-level toggle state & count values //
/////////////////////////////////////////////////////////
const tableToggleStates: {[tableName: string]: boolean} = {};
const tableToggleCounts: {[tableName: string]: number} = {};
if(isModeToggle)
{
const {allOn, count} = getTableToggleState(tableMetaData, true);
tableToggleStates[tableMetaData.name] = allOn;
tableToggleCounts[tableMetaData.name] = count;
for (let i = 0; i < tableMetaData.exposedJoins?.length; i++)
{
const join = tableMetaData.exposedJoins[i];
const {allOn, count} = getTableToggleState(join.joinTable, false);
tableToggleStates[join.joinTable.name] = allOn;
tableToggleCounts[join.joinTable.name] = count;
}
}
/*******************************************************************************
**
*******************************************************************************/
function getTableToggleState(table: QTableMetaData, isMainTable: boolean): {allOn: boolean, count: number}
{
const fieldsList = [...table.fields.values()];
let allOn = true;
let count = 0;
for (let i = 0; i < fieldsList.length; i++)
{
const field = fieldsList[i];
const name = isMainTable ? field.name : `${table.name}.${field.name}`;
if(!toggleStates[name])
{
allOn = false;
}
else
{
count++;
}
}
return ({allOn: allOn, count: count});
}
/*******************************************************************************
**
*******************************************************************************/
function toggleCollapsedTable(tableName: string)
{
collapsedTables[tableName] = !collapsedTables[tableName]
setCollapsedTables(Object.assign({}, collapsedTables));
}
/*******************************************************************************
**
*******************************************************************************/
function doHandleAdornmentClick(field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent<any>)
{
console.log("In doHandleAdornmentClick");
closeMenu();
handleAdornmentClick(field, table, event);
}
let index = -1;
const textFieldId = `field-list-dropdown-${idPrefix}-textField`;
let listItemPadding = isModeToggle ? "0.125rem": "0.5rem";
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for z-indexes, we set each table header to i+1, then the fields in that table to i (so they go behind it) //
// then we increment i by 2 for the next table (so the next header goes above the previous header) //
// this fixes a thing where, if one table's name wrapped to 2 lines, then when the next table below it would //
// come up, if it was only 1 line, then the second line from the previous one would bleed through. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
let zIndex = 1;
return (
<>
<Button onClick={openMenu} {...buttonProps}>
{buttonChildren}
</Button>
<Menu
anchorEl={menuAnchorElement}
anchorOrigin={{vertical: "bottom", horizontal: "left"}}
transformOrigin={{vertical: "top", horizontal: "left"}}
open={menuAnchorElement != null}
onClose={closeMenu}
onKeyDown={keyDown} // this is added here so arrow-key-up/down events don't make the whole menu become "focused" (blue outline). it works.
keepMounted
>
<Box width={isModeToggle ? "305px" : "265px"} borderRadius={2} className={`fieldListMenuBody fieldListMenuBody-${idPrefix}`}>
{
heading &&
<Box px={1} py={0.5} fontWeight={"700"}>
{heading}
</Box>
}
<Box p={1} pt={0.5}>
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}}} />
{
searchText != "" && <IconButton sx={{position: "absolute", right: "0.5rem", top: "0.5rem"}} onClick={() =>
{
updateSearch(null);
document.getElementById(textFieldId).focus();
}}><Icon fontSize="small">close</Icon></IconButton>
}
</Box>
<Box maxHeight={"445px"} overflow="auto" mr={"-0.5rem"} sx={{scrollbarGutter: "stable"}}>
<List sx={{px: "0.5rem", cursor: "default"}}>
{
fieldsByTableToShow.map((tableWithFields) =>
{
let headerContents = null;
const headerTable = tableWithFields.table || tableMetaData;
if(tableWithFields.table || showTableHeaderEvenIfNoExposedJoins)
{
headerContents = (<b>{headerTable.label} Fields</b>);
}
if(isModeToggle)
{
headerContents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", fontWeight: "500 !important"}}}
control={<Switch
size="small"
sx={{top: "1px"}}
checked={tableToggleStates[headerTable.name]}
onChange={(event) => handleTableToggle(event, headerTable)}
/>}
label={<span style={{marginTop: "0.25rem", display: "inline-block"}}><b>{headerTable.label} Fields</b>&nbsp;<span style={{fontWeight: 400}}>({tableToggleCounts[headerTable.name]})</span></span>} />)
}
if(isModeToggle)
{
headerContents = (
<>
<IconButton
onClick={() => toggleCollapsedTable(headerTable.name)}
sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}}
disableRipple={true}
>
<Icon sx={{fontSize: "1.5rem !important", position: "relative", top: "2px"}}>{collapsedTables[headerTable.name] ? "expand_less" : "expand_more"}</Icon>
</IconButton>
{headerContents}
</>
)
}
let marginLeft = "unset";
if(isModeToggle)
{
marginLeft = "-1rem";
}
zIndex += 2;
return (
<React.Fragment key={tableWithFields.table?.name ?? "theTable"}>
<>
{headerContents && <ListItem sx={{position: "sticky", top: -1, zIndex: zIndex+1, padding: listItemPadding, ml: marginLeft, display: "flex", alignItems: "flex-start", backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 90%, rgba(255,255,255,0))"}}>{headerContents}</ListItem>}
{
tableWithFields.fields.map((field) =>
{
index++;
const key = `${tableWithFields.table?.name}-${field.name}`
if(collapsedTables[headerTable.name])
{
return (<React.Fragment key={key} />);
}
let style = {};
if (index == focusedIndex)
{
style = {backgroundColor: "#EFEFEF"};
}
const onClick: ListItemProps = {};
if (isModeSelectOne)
{
onClick.onClick = () =>
{
closeMenu();
handleSelectedField(field, tableWithFields.table ?? tableMetaData);
}
}
let label: JSX.Element | string = field.label;
const fullFieldName = tableWithFields.table && tableWithFields.table.name != tableMetaData.name ? `${tableWithFields.table.name}.${field.name}` : field.name;
if(fieldEndAdornment)
{
label = <Box width="100%" display="inline-flex" justifyContent="space-between">
{label}
<Box onClick={(event) => doHandleAdornmentClick(field, tableWithFields.table, event)}>
{fieldEndAdornment}
</Box>
</Box>;
}
let contents = <>{label}</>;
let paddingLeft = "0.5rem";
if (isModeToggle)
{
contents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", color: "#606060", fontWeight: "500 !important"}}}
control={<Switch
size="small"
sx={{top: "-3px"}}
checked={toggleStates[fullFieldName]}
onChange={(event) => handleFieldToggle(event, field, tableWithFields.table)}
/>}
label={label} />);
paddingLeft = "2.5rem";
}
return <ListItem
key={key}
id={`field-list-dropdown-${idPrefix}-${index}`}
sx={{color: "#757575", p: 1, borderRadius: ".5rem", padding: listItemPadding, pl: paddingLeft, scrollMarginTop: "3rem", zIndex: zIndex, background: "#FFFFFF", ...style}}
onMouseOver={(event) => handleMouseOver(event, field, tableWithFields.table)}
{...onClick}
>{contents}</ListItem>;
})
}
</>
</React.Fragment>
);
})
}
{
index == -1 && <ListItem sx={{p: "0.5rem"}}><i>No fields found.</i></ListItem>
}
</List>
</Box>
</Box>
</Menu>
</>
);
}

View File

@ -24,7 +24,7 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import FormControl from "@mui/material/FormControl/FormControl";
import Icon from "@mui/material/Icon/Icon";
@ -53,6 +53,27 @@ export enum ValueMode
PVS_MULTI = "PVS_MULTI",
}
export const getValueModeRequiredCount = (valueMode: ValueMode): number =>
{
switch (valueMode)
{
case ValueMode.NONE:
return (0);
case ValueMode.SINGLE:
case ValueMode.SINGLE_DATE:
case ValueMode.SINGLE_DATE_TIME:
case ValueMode.PVS_SINGLE:
return (1);
case ValueMode.DOUBLE:
case ValueMode.DOUBLE_DATE:
case ValueMode.DOUBLE_DATE_TIME:
return (2);
case ValueMode.MULTI:
case ValueMode.PVS_MULTI:
return (null);
}
}
export interface OperatorOption
{
label: string;
@ -182,7 +203,7 @@ FilterCriteriaRow.defaultProps =
{
};
export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue: OperatorOption)
export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue?: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string}
{
let criteriaIsValid = true;
let criteriaStatusTooltip = "This condition is fully defined and is part of your filter.";
@ -211,37 +232,34 @@ export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValu
}
else
{
if (operatorSelectedValue)
if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK)
{
if (operatorSelectedValue.valueMode == ValueMode.NONE || operatorSelectedValue.implicitValues)
//////////////////////////////////
// don't need to look at values //
//////////////////////////////////
}
else if (criteria.operator == QCriteriaOperator.BETWEEN || criteria.operator == QCriteriaOperator.NOT_BETWEEN)
{
if (criteria.values.length < 2 || isNotSet(criteria.values[0]) || isNotSet(criteria.values[1]))
{
//////////////////////////////////
// don't need to look at values //
//////////////////////////////////
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter two values to complete the definition of this condition.";
}
else if (operatorSelectedValue.valueMode == ValueMode.DOUBLE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE_TIME)
}
else if (criteria.operator == QCriteriaOperator.IN || criteria.operator == QCriteriaOperator.NOT_IN)
{
if (criteria.values.length < 1 || isNotSet(criteria.values[0]))
{
if (criteria.values.length < 2 || isNotSet(criteria.values[0]) || isNotSet(criteria.values[1]))
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter two values to complete the definition of this condition.";
}
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition.";
}
else if (operatorSelectedValue.valueMode == ValueMode.MULTI || operatorSelectedValue.valueMode == ValueMode.PVS_MULTI)
}
else
{
if (!criteria.values || isNotSet(criteria.values[0]))
{
if (criteria.values.length < 1 || isNotSet(criteria.values[0]))
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition.";
}
}
else
{
if (!criteria.values || isNotSet(criteria.values[0]))
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter a value to complete the definition of this condition.";
}
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter a value to complete the definition of this condition.";
}
}
}
@ -370,6 +388,24 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
{
criteria.values = newValue.implicitValues;
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// we've seen cases where switching operators can sometimes put a null in as the first value... //
// that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. //
//////////////////////////////////////////////////////////////////////////////////////////////////
if(criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
{
criteria.values = [];
}
if(newValue.valueMode && !newValue.implicitValues)
{
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
if(requiredValueCount != null && criteria.values.length > requiredValueCount)
{
criteria.values.splice(requiredValueCount);
}
}
}
else
{

View File

@ -0,0 +1,134 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
import {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons";
interface QueryScreenActionMenuProps
{
metaData: QInstance;
tableMetaData: QTableMetaData;
tableProcesses: QProcessMetaData[];
bulkLoadClicked: () => void;
bulkEditClicked: () => void;
bulkDeleteClicked: () => void;
processClicked: (process: QProcessMetaData) => void;
}
QueryScreenActionMenu.defaultProps = {
};
export default function QueryScreenActionMenu({metaData, tableMetaData, tableProcesses, bulkLoadClicked, bulkEditClicked, bulkDeleteClicked, processClicked}: QueryScreenActionMenuProps): JSX.Element
{
const [anchorElement, setAnchorElement] = useState(null)
const navigate = useNavigate();
const openActionsMenu = (event: any) =>
{
setAnchorElement(event.currentTarget);
}
const closeActionsMenu = () =>
{
setAnchorElement(null);
}
const pushDividerIfNeeded = (menuItems: JSX.Element[]) =>
{
if (menuItems.length > 0)
{
menuItems.push(<Divider key="divider" />);
}
};
const runSomething = (handler: () => void) =>
{
closeActionsMenu();
handler();
}
const menuItems: JSX.Element[] = [];
if (tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission)
{
menuItems.push(<MenuItem key="bulkLoad" onClick={() => runSomething(bulkLoadClicked)}><ListItemIcon><Icon>library_add</Icon></ListItemIcon>Bulk Load</MenuItem>);
}
if (tableMetaData.capabilities.has(Capability.TABLE_UPDATE) && tableMetaData.editPermission)
{
menuItems.push(<MenuItem key="bulkEdit" onClick={() => runSomething(bulkEditClicked)}><ListItemIcon><Icon>edit</Icon></ListItemIcon>Bulk Edit</MenuItem>);
}
if (tableMetaData.capabilities.has(Capability.TABLE_DELETE) && tableMetaData.deletePermission)
{
menuItems.push(<MenuItem key="bulkDelete" onClick={() => runSomething(bulkDeleteClicked)}><ListItemIcon><Icon>delete</Icon></ListItemIcon>Bulk Delete</MenuItem>);
}
const runRecordScriptProcess = metaData?.processes.get("runRecordScript");
if (runRecordScriptProcess)
{
const process = runRecordScriptProcess;
menuItems.push(<MenuItem key={process.name} onClick={() => runSomething(() => processClicked(process))}><ListItemIcon><Icon>{process.iconName ?? "arrow_forward"}</Icon></ListItemIcon>{process.label}</MenuItem>);
}
menuItems.push(<MenuItem key="developerMode" onClick={() => navigate(`${metaData.getTablePathByName(tableMetaData.name)}/dev`)}><ListItemIcon><Icon>code</Icon></ListItemIcon>Developer Mode</MenuItem>);
if (tableProcesses && tableProcesses.length)
{
pushDividerIfNeeded(menuItems);
}
tableProcesses.sort((a, b) => a.label.localeCompare(b.label));
tableProcesses.map((process) =>
{
menuItems.push(<MenuItem key={process.name} onClick={() => runSomething(() => processClicked(process))}><ListItemIcon><Icon>{process.iconName ?? "arrow_forward"}</Icon></ListItemIcon>{process.label}</MenuItem>);
});
if (menuItems.length === 0)
{
menuItems.push(<MenuItem key="notAvaialableNow" disabled><ListItemIcon><Icon>block</Icon></ListItemIcon><i>No actions available</i></MenuItem>);
}
return (
<>
<QActionsMenuButton isOpen={anchorElement} onClickHandler={openActionsMenu} />
<Menu
anchorEl={anchorElement}
anchorOrigin={{vertical: "bottom", horizontal: "right",}}
transformOrigin={{vertical: "top", horizontal: "right",}}
open={anchorElement != null}
onClose={closeActionsMenu}
keepMounted
>
{menuItems}
</Menu>
</>
)
}

View File

@ -24,18 +24,18 @@ import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QField
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {Badge, Tooltip} from "@mui/material";
import {Tooltip} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import TextField from "@mui/material/TextField";
import React, {SyntheticEvent, 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, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import {getDefaultCriteriaValue, getOperatorOptions, getValueModeRequiredCount, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
import XIcon from "qqq/components/query/XIcon";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
@ -60,6 +60,19 @@ QuickFilter.defaultProps =
let seedId = new Date().getTime() % 173237;
export const quickFilterButtonStyles = {
fontSize: "0.75rem",
fontWeight: 600,
color: "#757575",
textTransform: "none",
borderRadius: "2rem",
border: "1px solid #757575",
minWidth: "3.5rem",
minHeight: "auto",
padding: "0.375rem 0.625rem", whiteSpace: "nowrap",
marginBottom: "0.5rem"
}
/*******************************************************************************
** Test if a CriteriaParamType represents an actual query criteria - or, if it's
** null or the "tooComplex" placeholder.
@ -133,32 +146,74 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const [isOpen, setIsOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
const [isMouseOver, setIsMouseOver] = useState(false);
const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? criteriaParam as QFilterCriteriaWithId : null);
////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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));
const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label);
const [startIconName, setStartIconName] = useState("filter_alt");
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
const {accentColor} = useContext(QContext);
//////////////////////
// ole' faithful... //
//////////////////////
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOverElement()
{
setIsMouseOver(true);
}
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOutElement()
{
setIsMouseOver(false);
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// handle a change to the criteria from outside this component (e.g., the prop isn't the same as the state) //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
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];
console.log(`B: setOperatorSelectedValue [${JSON.stringify(operatorOption)}]`);
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 =>
{
@ -167,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);
}
}
@ -175,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 =>
@ -183,7 +248,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const operatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0];
const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption?.value, getDefaultCriteriaValue());
criteria.id = id;
console.log(`C: setOperatorSelectedValue [${JSON.stringify(operatorOption)}]`);
setOperatorSelectedValue(operatorOption);
setOperatorInputValue(operatorOption?.label);
setCriteria(criteria);
@ -197,6 +261,12 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
{
setIsOpen(!isOpen);
setAnchorEl(event.currentTarget);
setTimeout(() =>
{
const element = document.getElementById("value-" + criteria.id);
element?.focus();
})
};
/*******************************************************************************
@ -204,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);
};
@ -217,7 +292,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
if (newValue)
{
console.log(`D: setOperatorSelectedValue [${JSON.stringify(newValue)}]`);
setOperatorSelectedValue(newValue);
setOperatorInputValue(newValue.label);
@ -225,15 +299,33 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
{
criteria.values = newValue.implicitValues;
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// we've seen cases where switching operators can sometimes put a null in as the first value... //
// that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. //
//////////////////////////////////////////////////////////////////////////////////////////////////
if(criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
{
criteria.values = [];
}
if(newValue.valueMode && !newValue.implicitValues)
{
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
if(requiredValueCount != null && criteria.values.length > requiredValueCount)
{
criteria.values.splice(requiredValueCount);
}
}
}
else
{
console.log("E: setOperatorSelectedValue [null]");
setOperatorSelectedValue(null);
setOperatorInputValue("");
}
updateCriteria(criteria, false, false);
setCriteria(criteria);
forceUpdate();
};
/*******************************************************************************
@ -267,7 +359,8 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
criteria.values[valueIndex] = value;
}
updateCriteria(criteria, true, false);
setCriteria(criteria);
forceUpdate();
};
/*******************************************************************************
@ -288,38 +381,20 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
e.stopPropagation();
const newCriteria = makeNewCriteria();
updateCriteria(newCriteria, false, true);
setStartIconName("filter_alt");
}
}
/*******************************************************************************
** event handler for mouse-over on the filter icon - that changes to an 'x'
** if there's a valid criteria in the quick-filter
*******************************************************************************/
const startIconMouseOver = () =>
{
if(criteriaIsValid)
{
setStartIconName("clear");
}
}
/*******************************************************************************
** event handler for mouse-out on the filter icon - always resets it.
*******************************************************************************/
const startIconMouseOut = () =>
{
setStartIconName("filter_alt");
}
/*******************************************************************************
** event handler for clicking the (x) icon that turns off this quick filter field.
** hands off control to the function that was passed in (e.g., from RecordQuery).
** hands off control to the function that was passed in (e.g., from RecordQueryOrig).
*******************************************************************************/
const handleTurningOffQuickFilterField = () =>
{
closeMenu()
handleRemoveQuickFilterField(criteria?.fieldName);
if(handleRemoveQuickFilterField)
{
handleRemoveQuickFilterField(criteria?.fieldName);
}
}
////////////////////////////////////////////////////////////////////////////////////
@ -337,7 +412,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator);
if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue))
{
console.log(`A: setOperatorSelectedValue [${JSON.stringify(maybeNewOperatorSelectedValue)}]`);
setOperatorSelectedValue(maybeNewOperatorSelectedValue)
setOperatorInputValue(maybeNewOperatorSelectedValue?.label)
}
@ -355,26 +429,42 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
/////////////////////////
const tooComplex = criteriaParam == "tooComplex";
const tooltipEnterDelay = 500;
let startIcon = <Badge badgeContent={criteriaIsValid && !tooComplex ? 1 : 0} color="warning" variant="dot" onMouseOver={startIconMouseOver} onMouseOut={startIconMouseOut} onClick={resetCriteria}><Icon>{startIconName}</Icon></Badge>
if(criteriaIsValid)
{
startIcon = <Tooltip title={"Remove this condition from your filter"} enterDelay={tooltipEnterDelay}>{startIcon}</Tooltip>
}
let buttonAdditionalStyles: any = {};
let buttonContent = <span>{tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label}</span>
let buttonClassName = "filterNotActive";
if (criteriaIsValid)
{
buttonContent = (
<Tooltip title={`${operatorSelectedValue.label} ${FilterUtils.getValuesString(fieldMetaData, criteria)}`} enterDelay={tooltipEnterDelay}>
{buttonContent}
</Tooltip>
);
buttonAdditionalStyles.backgroundColor = accentColor + " !important";
buttonAdditionalStyles.borderColor = accentColor + " !important";
buttonAdditionalStyles.color = "white !important";
buttonClassName = "filterActive";
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)
{
operatorString = (<></>)
}
buttonContent = (<><span style={{fontWeight: 700}}>{buttonContent}:</span>&nbsp;<span style={{fontWeight: 400}}>{operatorString}{valuesString}</span></>);
}
const mouseEvents =
{
onMouseOver: () => handleMouseOverElement(),
onMouseOut: () => handleMouseOutElement()
};
let button = fieldMetaData && <Button
id={`quickFilter.${fullFieldName}`}
sx={{mr: "0.25rem", px: "1rem", border: isOpen ? "1px solid gray" : "1px solid transparent"}}
startIcon={startIcon}
className={buttonClassName}
{...mouseEvents}
sx={{...quickFilterButtonStyles, ...buttonAdditionalStyles, mr: "0.5rem"}}
onClick={tooComplex ? noop : handleOpenMenu}
disabled={tooComplex}
>{buttonContent}</Button>;
@ -395,6 +485,22 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
);
}
/*******************************************************************************
** event handler for 'x' button - either resets the criteria or turns off the field.
*******************************************************************************/
const xClicked = (e: React.MouseEvent<HTMLSpanElement>) =>
{
e.stopPropagation();
if(criteriaIsValid)
{
resetCriteria(e);
}
else
{
handleTurningOffQuickFilterField();
}
}
//////////////////////////////
// return the button & menu //
//////////////////////////////
@ -402,18 +508,24 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
return (
<>
{button}
{
/////////////////////////////////////////////////////////////////////////////////////
// only show the 'x' if it's to clear out a valid criteria on the field, //
// or if we were given a callback to remove the quick-filter field from the screen //
/////////////////////////////////////////////////////////////////////////////////////
(criteriaIsValid || handleRemoveQuickFilterField) && isMouseOver && <span {...mouseEvents}><XIcon shade={criteriaIsValid ? "accent" : "default"} position="forQuickFilter" onClick={xClicked} /></span>
}
{
isOpen && <Menu open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={closeMenu} sx={{overflow: "visible"}}>
{
handleRemoveQuickFilterField &&
<Tooltip title={"Remove this field from Quick Filters"} placement="right">
<IconButton size="small" sx={{position: "absolute", top: "-8px", right: "-8px", zIndex: 1}} onClick={handleTurningOffQuickFilterField}><Icon color="secondary">highlight_off</Icon></IconButton>
</Tooltip>
}
<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}
@ -432,7 +544,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
operatorOption={operatorSelectedValue}
criteria={criteria}
field={fieldMetaData}
table={tableMetaData} // todo - joins?
table={tableForField}
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
initiallyOpenMultiValuePvs={true} // todo - maybe not?
/>

View File

@ -0,0 +1,92 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import React, {useContext} from "react";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
interface XIconProps
{
onClick: (e: React.MouseEvent<HTMLSpanElement>) => void;
position: "forQuickFilter" | "forAdvancedQueryPreview" | "default";
shade: "default" | "accent" | "accentLight"
}
XIcon.defaultProps = {
position: "default",
shade: "default"
};
export default function XIcon({onClick, position, shade}: XIconProps): JSX.Element
{
const {accentColor, accentColorLight} = useContext(QContext)
//////////////////////////
// for default position //
//////////////////////////
let rest: any = {
top: "-0.75rem",
left: "-0.5rem",
}
if(position == "forQuickFilter")
{
rest = {
left: "-1.125rem",
}
}
else if(position == "forAdvancedQueryPreview")
{
rest = {
top: "-0.5rem",
left: "-0.75rem",
}
}
let color;
switch (shade)
{
case "default":
color = colors.gray.main;
break;
case "accent":
color = accentColor;
break;
case "accentLight":
color = accentColorLight;
break;
}
return (
<span style={{position: "relative"}}><IconButton sx={{
fontSize: "0.75rem",
border: `1px solid ${color}`,
color: color,
padding: "0",
background: "#FFFFFF !important",
position: "absolute",
... rest
}} onClick={onClick}><Icon>close</Icon></IconButton></span>
)
}

View File

@ -116,7 +116,7 @@ function MaterialUIControllerProvider({children}: { children: ReactNode }): JSX.
whiteSidenav: false,
sidenavColor: "info",
transparentNavbar: true,
fixedNavbar: true,
fixedNavbar: false,
openConfigurator: false,
direction: "ltr",
layout: "dashboard",

View File

@ -41,25 +41,45 @@
** {myLoadingState.isNotLoading() && myData && <Box>...
** - In your template, before your "slow loading" view, check for `myLoadingState.isLoadingSlow()`, e.g.
** {myLoadingState.isLoadingSlow() && <Spinner />}
**
** In addition, you can also supply a callback to run "upon slow" (e.g., when
** moving into the slow state).
*******************************************************************************/
export class LoadingState
{
private state: "notLoading" | "loading" | "slow"
private slowTimeout: any;
private forceUpdate: () => void
private forceUpdate: () => void;
private uponSlowCallback: () => void;
constructor(forceUpdate: () => void, initialState: "notLoading" | "loading" | "slow" = "notLoading")
{
this.forceUpdate = forceUpdate;
this.state = initialState;
if(initialState == "loading")
{
this.setLoading();
}
else if(initialState == "notLoading")
{
this.setNotLoading();
}
}
public setLoading()
{
clearTimeout(this.slowTimeout);
this.state = "loading";
this.slowTimeout = setTimeout(() =>
{
this.state = "slow";
if(this.uponSlowCallback)
{
this.uponSlowCallback();
}
this.forceUpdate();
}, 1000);
}
@ -85,4 +105,14 @@ export class LoadingState
return (this.state == "notLoading");
}
public getState(): string
{
return (this.state);
}
public setUponSlowCallback(value: any)
{
this.uponSlowCallback = value;
}
}

View File

@ -0,0 +1,406 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {GridPinnedColumns} from "@mui/x-data-grid-pro";
import quickSightChart from "qqq/components/widgets/misc/QuickSightChart";
import DataGridUtils from "qqq/utils/DataGridUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
/*******************************************************************************
** member object
*******************************************************************************/
interface Column
{
name: string;
isVisible: boolean;
width: number;
pinned?: "left" | "right";
}
/*******************************************************************************
** Model for all info we'll store about columns on a query screen.
*******************************************************************************/
export default class QQueryColumns
{
columns: Column[] = [];
/*******************************************************************************
** factory function - build a QQueryColumns object from JSON (string or parsed object).
**
** input json is must look like if you JSON.stringify this class - that is:
** {columns: [{name:"",isVisible:true,width:0,pinned:"left"},{}...]}
*******************************************************************************/
public static buildFromJSON = (json: string | any): QQueryColumns =>
{
const queryColumns = new QQueryColumns();
if (typeof json == "string")
{
json = JSON.parse(json);
}
queryColumns.columns = json.columns;
return (queryColumns);
};
/*******************************************************************************
** factory function - build a default QQueryColumns object for a table
**
*******************************************************************************/
public static buildDefaultForTable = (table: QTableMetaData): QQueryColumns =>
{
const queryColumns = new QQueryColumns();
queryColumns.columns = [];
queryColumns.columns.push({name: "__check__", isVisible: true, width: 100, pinned: "left"});
const fields = this.getSortedFieldsFromTable(table);
fields.forEach((field) =>
{
const column: Column = {name: field.name, isVisible: true, width: DataGridUtils.getColumnWidthForField(field, table)};
queryColumns.columns.push(column);
if (field.name == table.primaryKeyField)
{
column.pinned = "left";
}
});
table.exposedJoins?.forEach((exposedJoin) =>
{
const joinFields = this.getSortedFieldsFromTable(exposedJoin.joinTable);
joinFields.forEach((field) =>
{
const column: Column = {name: `${exposedJoin.joinTable.name}.${field.name}`, isVisible: false, width: DataGridUtils.getColumnWidthForField(field, null)};
queryColumns.columns.push(column);
});
});
return (queryColumns);
};
/*******************************************************************************
**
*******************************************************************************/
public addColumnForNewField = (table: QTableMetaData, fieldName: string, defaultVisibilityIfInMainTable: boolean): void =>
{
const [field, tableForField] = TableUtils.getFieldAndTable(table, fieldName)
let column: Column;
if(tableForField.name == table.name)
{
column = {name: field.name, isVisible: defaultVisibilityIfInMainTable, width: DataGridUtils.getColumnWidthForField(field, table)};
}
else
{
column = {name: `${tableForField.name}.${field.name}`, isVisible: false, width: DataGridUtils.getColumnWidthForField(field, null)};
}
this.columns.push(column);
}
/*******************************************************************************
**
*******************************************************************************/
public deleteColumnForOldField = (table: QTableMetaData, fieldName: string): void =>
{
for (let i = 0; i < this.columns.length; i++)
{
if(this.columns[i].name == fieldName)
{
this.columns.splice(i, 1);
return;
}
}
console.log(`Couldn't find column to be deleted, for name [${fieldName}]`);
}
/*******************************************************************************
**
*******************************************************************************/
private static getSortedFieldsFromTable(table: QTableMetaData)
{
const fields = [...table.fields.values()];
fields.sort((a: QFieldMetaData, b: QFieldMetaData) =>
{
return a.name.localeCompare(b.name);
});
return fields;
}
/*******************************************************************************
**
*******************************************************************************/
public getVisibleColumnCount(): number
{
let rs = 0;
for (let i = 0; i < this.columns.length; i++)
{
if(this.columns[i].name == "__check__")
{
continue;
}
if(this.columns[i].isVisible)
{
rs++;
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
public getVisibilityToggleStates(): { [name: string]: boolean }
{
const rs: {[name: string]: boolean} = {};
for (let i = 0; i < this.columns.length; i++)
{
rs[this.columns[i].name] = this.columns[i].isVisible;
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
public setIsVisible(name: string, isVisible: boolean)
{
for (let i = 0; i < this.columns.length; i++)
{
if(this.columns[i].name == name)
{
this.columns[i].isVisible = isVisible;
break;
}
}
}
/*******************************************************************************
**
*******************************************************************************/
public updateVisibility = (visibilityModel: { [name: string]: boolean }): void =>
{
for (let i = 0; i < this.columns.length; i++)
{
const name = this.columns[i].name;
this.columns[i].isVisible = visibilityModel[name];
}
};
/*******************************************************************************
**
*******************************************************************************/
public updateColumnOrder = (names: string[]): void =>
{
const newColumns: Column[] = [];
const rest: Column[] = [];
for (let i = 0; i < this.columns.length; i++)
{
const column = this.columns[i];
const index = names.indexOf(column.name);
if (index > -1)
{
newColumns[index] = column;
}
else
{
rest.push(column);
}
}
this.columns = [...newColumns, ...rest];
};
/*******************************************************************************
**
*******************************************************************************/
public updateColumnWidth = (name: string, width: number): void =>
{
for (let i = 0; i < this.columns.length; i++)
{
if (this.columns[i].name == name)
{
this.columns[i].width = width;
}
}
};
/*******************************************************************************
**
*******************************************************************************/
public setPinnedLeftColumns = (names: string[]): void =>
{
const leftPins: Column[] = [];
const rest: Column[] = [];
for (let i = 0; i < this.columns.length; i++)
{
const column = this.columns[i];
const pinIndex = names ? names.indexOf(column.name) : -1;
if (pinIndex > -1)
{
column.pinned = "left";
leftPins[pinIndex] = column;
}
else
{
if (column.pinned == "left")
{
column.pinned = undefined;
}
rest.push(column);
}
}
this.columns = [...leftPins, ...rest];
};
/*******************************************************************************
**
*******************************************************************************/
public setPinnedRightColumns = (names: string[]): void =>
{
const rightPins: Column[] = [];
const rest: Column[] = [];
for (let i = 0; i < this.columns.length; i++)
{
const column = this.columns[i];
const pinIndex = names ? names.indexOf(column.name) : -1;
if (pinIndex > -1)
{
column.pinned = "right";
rightPins[pinIndex] = column;
}
else
{
if (column.pinned == "right")
{
column.pinned = undefined;
}
rest.push(column);
}
}
this.columns = [...rest, ...rightPins];
};
/*******************************************************************************
**
*******************************************************************************/
public getColumnSortValues = (): { [name: string]: number } =>
{
const sortValues: { [name: string]: number } = {};
for (let i = 0; i < this.columns.length; i++)
{
sortValues[this.columns[i].name] = i;
}
return sortValues;
};
/*******************************************************************************
**
*******************************************************************************/
public getColumnWidths = (): { [name: string]: number } =>
{
const widths: { [name: string]: number } = {};
for (let i = 0; i < this.columns.length; i++)
{
const column = this.columns[i];
widths[column.name] = column.width;
}
return widths;
};
/*******************************************************************************
**
*******************************************************************************/
public toGridPinnedColumns = (): GridPinnedColumns =>
{
const gridPinnedColumns: GridPinnedColumns = {left: [], right: []};
for (let i = 0; i < this.columns.length; i++)
{
const column = this.columns[i];
if (column.pinned == "left")
{
gridPinnedColumns.left.push(column.name);
}
else if (column.pinned == "right")
{
gridPinnedColumns.right.push(column.name);
}
}
return gridPinnedColumns;
};
/*******************************************************************************
**
*******************************************************************************/
public toColumnVisibilityModel = (): { [index: string]: boolean } =>
{
const columnVisibilityModel: { [index: string]: boolean } = {};
for (let i = 0; i < this.columns.length; i++)
{
const column = this.columns[i];
columnVisibilityModel[column.name] = column.isVisible;
}
return columnVisibilityModel;
};
}
/*******************************************************************************
** subclass of QQueryColumns - used as a marker, to indicate that the table
** isn't yet loaded, so it just a placeholder.
*******************************************************************************/
export class PreLoadQueryColumns extends QQueryColumns
{
}

View File

@ -0,0 +1,100 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import QQueryColumns, {PreLoadQueryColumns} from "qqq/models/query/QQueryColumns";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
/*******************************************************************************
** Model to represent the full "view" that is active on the RecordQuery screen
** (and accordingly, can be saved as a saved view).
*******************************************************************************/
export default class RecordQueryView
{
queryFilter: QQueryFilter; // contains orderBys
queryColumns: QQueryColumns; // contains on/off, sequence, widths, and pins
viewIdentity: string; // url vs. saved vs. ad-hoc, plus "noncey" stuff? not very used...
rowsPerPage: number;
quickFilterFieldNames: string[];
mode: string;
// variant?
/*******************************************************************************
**
*******************************************************************************/
constructor()
{
}
/*******************************************************************************
** factory function - build a RecordQueryView object from JSON (string or parsed object).
**
** input json is must look like if you JSON.stringify this class - that is:
** {queryFilter: {}, queryColumns: {}, etc...}
*******************************************************************************/
public static buildFromJSON = (json: string | any): RecordQueryView =>
{
const view = new RecordQueryView();
if (typeof json == "string")
{
json = JSON.parse(json);
}
view.queryFilter = json.queryFilter as QQueryFilter;
//////////////////////////////////////////////////////////////////////////////////////////
// it's important that some criteria values exist as expression objects - so - do that. //
//////////////////////////////////////////////////////////////////////////////////////////
for (let i = 0; i < view.queryFilter?.criteria?.length; i++)
{
const criteria = view.queryFilter.criteria[i]
for (let j = 0; j < criteria?.values?.length; j++)
{
const value = criteria.values[j];
const expression = FilterUtils.gridCriteriaValueToExpression(value);
if(expression)
{
criteria.values[j] = expression;
}
}
}
if(json.queryColumns)
{
view.queryColumns = QQueryColumns.buildFromJSON(json.queryColumns);
}
else
{
view.queryColumns = new PreLoadQueryColumns();
}
view.viewIdentity = json.viewIdentity;
view.rowsPerPage = json.rowsPerPage;
view.quickFilterFieldNames = json.quickFilterFieldNames;
view.mode = json.mode;
return (view);
};
}

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;

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,13 @@
min-height: calc(100vh - 450px) !important;
}
/* we want to leave columns w/ the sortable attribute (so they have it in the column menu),
but we've turned off the click-to-sort function, so remove hand cursor */
.recordQuery .MuiDataGrid-columnHeader--sortable
{
cursor: default !important;
}
/* Disable red outlines on clicked cells */
.MuiDataGrid-cell:focus,
.MuiDataGrid-columnHeader:focus,
@ -402,7 +409,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
margin-right: 8px;
}
.custom-columns-panel .MuiSwitch-thumb
.custom-columns-panel .MuiSwitch-thumb,
.fieldListMenuBody .MuiSwitch-thumb
{
width: 15px !important;
height: 15px !important;
@ -427,7 +435,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
.MuiDataGrid-panel:has(.customFilterPanel)
{
/* overwrite what the grid tries to do here, where it changes based on density... we always want the same. */
transform: translate(274px, 305px) !important;
/* transform: translate(274px, 305px) !important; */
transform: translate(274px, 264px) !important;
}
/* within the data-grid, the filter-panel's container has a max-height. mirror that, and set an overflow-y */
@ -614,4 +623,38 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
.dataGridHeaderTooltip
{
top: -1.25rem;
}
}
/* when grid contents weren't filling the height of the screen, the gray panel for pinned columns
was stretching to most of the grid height, but it wasn't the full height and so looked a little
broken. just turing off this min height changes to not try to stretch at all, and is not broken. */
.MuiDataGrid-pinnedColumns
{
min-height: unset !important;
}
/* new style for toggle buttons */
.MuiToggleButtonGroup-root
{
padding: 0.25rem;
border: 1px solid #BDBDBD;
border-radius: 0.5rem !important;
}
.MuiToggleButtonGroup-root .MuiButtonBase-root
{
text-transform: none;
font-size: 0.75rem;
color: black;
font-weight: 600;
border-radius: 0.375rem !important; /* overriding left/right edge overrides for first/last */
border: none;
flex: 1 1 0px;
}
.MuiToggleButtonGroup-root .MuiButtonBase-root.Mui-selected
{
background: rgba(117, 117, 117, 0.20);
}
.MuiToggleButtonGroup-root .MuiButtonBase-root.Mui-disabled
{
border: none;
}

View File

@ -1,849 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {NowExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowExpression";
import {NowWithOffsetExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression";
import {GridFilterItem, GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId";
/*******************************************************************************
** Utility class for working with QQQ Filters
**
*******************************************************************************/
class FilterUtils
{
/*******************************************************************************
** Convert a grid operator to a QQQ Criteria Operator.
*******************************************************************************/
public static gridCriteriaOperatorToQQQ = (operator: string): QCriteriaOperator =>
{
switch (operator)
{
case "contains":
return QCriteriaOperator.CONTAINS;
case "notContains":
return QCriteriaOperator.NOT_CONTAINS;
case "startsWith":
return QCriteriaOperator.STARTS_WITH;
case "notStartsWith":
return QCriteriaOperator.NOT_STARTS_WITH;
case "endsWith":
return QCriteriaOperator.ENDS_WITH;
case "notEndsWith":
return QCriteriaOperator.NOT_ENDS_WITH;
case "is":
case "equals":
case "=":
case "isTrue":
case "isFalse":
return QCriteriaOperator.EQUALS;
case "isNot":
case "!=":
return QCriteriaOperator.NOT_EQUALS_OR_IS_NULL;
case "after":
case ">":
return QCriteriaOperator.GREATER_THAN;
case "onOrAfter":
case ">=":
return QCriteriaOperator.GREATER_THAN_OR_EQUALS;
case "before":
case "<":
return QCriteriaOperator.LESS_THAN;
case "onOrBefore":
case "<=":
return QCriteriaOperator.LESS_THAN_OR_EQUALS;
case "isEmpty":
return QCriteriaOperator.IS_BLANK;
case "isNotEmpty":
return QCriteriaOperator.IS_NOT_BLANK;
case "isAnyOf":
return QCriteriaOperator.IN;
case "isNone":
return QCriteriaOperator.NOT_IN;
case "between":
return QCriteriaOperator.BETWEEN;
case "notBetween":
return QCriteriaOperator.NOT_BETWEEN;
default:
return QCriteriaOperator.EQUALS;
}
};
/*******************************************************************************
** Convert a qqq criteria operator to one expected by the grid.
*******************************************************************************/
public static qqqCriteriaOperatorToGrid = (operator: QCriteriaOperator, field: QFieldMetaData, criteriaValues: any[]): string =>
{
const fieldType = field.type;
switch (operator)
{
case QCriteriaOperator.EQUALS:
if (field.possibleValueSourceName)
{
return ("is");
}
switch (fieldType)
{
case QFieldType.INTEGER:
case QFieldType.DECIMAL:
return ("=");
case QFieldType.DATE:
case QFieldType.TIME:
case QFieldType.DATE_TIME:
case QFieldType.STRING:
case QFieldType.TEXT:
case QFieldType.HTML:
case QFieldType.PASSWORD:
case QFieldType.BLOB:
return ("equals");
case QFieldType.BOOLEAN:
if (criteriaValues && criteriaValues[0] === true)
{
return ("isTrue");
}
else if (criteriaValues && criteriaValues[0] === false)
{
return ("isFalse");
}
return ("is");
default:
return ("is");
}
case QCriteriaOperator.NOT_EQUALS:
case QCriteriaOperator.NOT_EQUALS_OR_IS_NULL:
if (field.possibleValueSourceName)
{
return ("isNot");
}
switch (fieldType)
{
case QFieldType.INTEGER:
case QFieldType.DECIMAL:
return ("!=");
case QFieldType.DATE:
case QFieldType.TIME:
case QFieldType.DATE_TIME:
case QFieldType.BOOLEAN:
case QFieldType.STRING:
case QFieldType.TEXT:
case QFieldType.HTML:
case QFieldType.PASSWORD:
case QFieldType.BLOB:
default:
return ("isNot");
}
case QCriteriaOperator.IN:
return ("isAnyOf");
case QCriteriaOperator.NOT_IN:
return ("isNone");
case QCriteriaOperator.STARTS_WITH:
return ("startsWith");
case QCriteriaOperator.ENDS_WITH:
return ("endsWith");
case QCriteriaOperator.CONTAINS:
return ("contains");
case QCriteriaOperator.NOT_STARTS_WITH:
return ("notStartsWith");
case QCriteriaOperator.NOT_ENDS_WITH:
return ("notEndsWith");
case QCriteriaOperator.NOT_CONTAINS:
return ("notContains");
case QCriteriaOperator.LESS_THAN:
switch (fieldType)
{
case QFieldType.DATE:
case QFieldType.TIME:
case QFieldType.DATE_TIME:
return ("before");
default:
return ("<");
}
case QCriteriaOperator.LESS_THAN_OR_EQUALS:
switch (fieldType)
{
case QFieldType.DATE:
case QFieldType.TIME:
case QFieldType.DATE_TIME:
return ("onOrBefore");
default:
return ("<=");
}
case QCriteriaOperator.GREATER_THAN:
switch (fieldType)
{
case QFieldType.DATE:
case QFieldType.TIME:
case QFieldType.DATE_TIME:
return ("after");
default:
return (">");
}
case QCriteriaOperator.GREATER_THAN_OR_EQUALS:
switch (fieldType)
{
case QFieldType.DATE:
case QFieldType.TIME:
case QFieldType.DATE_TIME:
return ("onOrAfter");
default:
return (">=");
}
case QCriteriaOperator.IS_BLANK:
return ("isEmpty");
case QCriteriaOperator.IS_NOT_BLANK:
return ("isNotEmpty");
case QCriteriaOperator.BETWEEN:
return ("between");
case QCriteriaOperator.NOT_BETWEEN:
return ("notBetween");
default:
console.warn(`Unhandled criteria operator: ${operator}`);
return ("=");
}
};
/*******************************************************************************
** the values object needs handled differently based on cardinality of the operator.
** that is - qqq always wants a list, but the grid provides it differently per-operator.
** for single-values (the default), we must wrap it in an array.
** for non-values (e.g., blank), set it to null.
** for list-values, it's already in an array, so don't wrap it.
*******************************************************************************/
public static gridCriteriaValueToQQQ = (operator: QCriteriaOperator, value: any, gridOperatorValue: string, fieldMetaData: QFieldMetaData): any[] =>
{
if (gridOperatorValue === "isTrue")
{
return [true];
}
else if (gridOperatorValue === "isFalse")
{
return [false];
}
if (operator === QCriteriaOperator.IS_BLANK || operator === QCriteriaOperator.IS_NOT_BLANK)
{
return (null);
}
else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN)
{
if ((value == null || value.length < 2) && (operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN))
{
/////////////////////////////////////////////////////////////////////////////////////////////////
// if we send back null, we get a 500 - bad look every time you try to set up a BETWEEN filter //
// but array of 2 nulls? comes up sunshine. //
/////////////////////////////////////////////////////////////////////////////////////////////////
return ([null, null]);
}
return (FilterUtils.cleanseCriteriaValueForQQQ(value, fieldMetaData));
}
return (FilterUtils.cleanseCriteriaValueForQQQ([value], fieldMetaData));
};
/*******************************************************************************
** Helper method - take a list of values, which may be possible values, and
** either return the original list, or a new list that is just the ids of the
** possible values (if it was a list of possible values).
**
** Or, if the values are date-times, convert them to UTC.
*******************************************************************************/
private static cleanseCriteriaValueForQQQ = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] =>
{
if (param === null || param === undefined)
{
return (param);
}
if (FilterUtils.gridCriteriaValueToExpression(param))
{
return (param);
}
let rs = [];
for (let i = 0; i < param.length; i++)
{
console.log(param[i]);
if (param[i] && param[i].id && param[i].label)
{
//////////////////////////////////////////////////////////////////////////////////////////
// if the param looks like a possible value, return its id //
// during build of new custom filter panel, this ended up causing us //
// problems (because we wanted the full PV object in the filter model for the frontend) //
// so, we can keep the PV as-is here, and see calls to convertFilterPossibleValuesToIds //
// to do what this used to do. //
//////////////////////////////////////////////////////////////////////////////////////////
// rs.push(param[i].id);
rs.push(param[i]);
}
else
{
if (fieldMetaData?.type == QFieldType.DATE_TIME)
{
try
{
let toPush = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(param[i]);
rs.push(toPush);
}
catch (e)
{
console.log("Error converting date-time to UTC: ", e);
rs.push(param[i]);
}
}
else
{
rs.push(param[i]);
}
}
}
return (rs);
};
/*******************************************************************************
** Convert a filter field's value from the style that qqq uses, to the style that
** the grid uses.
*******************************************************************************/
public static qqqCriteriaValuesToGrid = (operator: QCriteriaOperator, values: any[], field: QFieldMetaData): any | any[] =>
{
const fieldType = field.type;
if (operator === QCriteriaOperator.IS_BLANK || operator === QCriteriaOperator.IS_NOT_BLANK)
{
return null;
}
else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN)
{
return (values);
}
if (values && values.length > 0)
{
////////////////////////////////////////////////////////////////////////////////////////////////
// make sure dates are formatted for the grid the way it expects - not the way we pass it in. //
////////////////////////////////////////////////////////////////////////////////////////////////
if (fieldType === QFieldType.DATE_TIME)
{
for (let i = 0; i < values.length; i++)
{
if (!values[i].type)
{
values[i] = ValueUtils.formatDateTimeValueForForm(values[i]);
}
}
}
}
return (values ? values[0] : "");
};
/*******************************************************************************
** Get the default filter to use on the page - either from given filter string, query string param, or
** local storage, or a default (empty).
*******************************************************************************/
public static async determineFilterAndSortModels(qController: QController, tableMetaData: QTableMetaData, filterString: string, searchParams: URLSearchParams, filterLocalStorageKey: string, sortLocalStorageKey: string): Promise<{ filter: GridFilterModel, sort: GridSortItem[], warning: string }>
{
let defaultFilter = {items: []} as GridFilterModel;
let defaultSort = [] as GridSortItem[];
let warningParts = [] as string[];
if (tableMetaData && tableMetaData.fields !== undefined)
{
if (filterString != null || (searchParams && searchParams.has("filter")))
{
try
{
const filterJSON = (filterString !== null) ? JSON.parse(filterString) : JSON.parse(searchParams.get("filter"));
const qQueryFilter = filterJSON as QQueryFilter;
//////////////////////////////////////////////////////////////////
// translate from a qqq-style filter to one that the grid wants //
//////////////////////////////////////////////////////////////////
let id = 1;
for (let i = 0; i < qQueryFilter?.criteria?.length; i++)
{
const criteria = qQueryFilter.criteria[i];
let [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName);
if (field == null)
{
console.log("Couldn't find field for filter: " + criteria.fieldName);
warningParts.push("Your filter contained an unrecognized field name: " + criteria.fieldName);
continue;
}
let values = criteria.values;
if (field.possibleValueSourceName)
{
//////////////////////////////////////////////////////////////////////////////////
// possible-values in query-string are expected to only be their id values. //
// e.g., ...values=[1]... //
// but we need them to be possibleValue objects (w/ id & label) so the label //
// can be shown in the filter dropdown. So, make backend call to look them up. //
//////////////////////////////////////////////////////////////////////////////////
if (values && values.length > 0)
{
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values);
}
////////////////////////////////////////////
// log message if no values were returned //
////////////////////////////////////////////
if (!values || values.length === 0)
{
console.warn("WARNING: No possible values were returned for [" + field.possibleValueSourceName + "] for values [" + criteria.values + "].");
}
}
//////////////////////////////////////////////////////////////////////////
// replace objects that look like expressions with expression instances //
//////////////////////////////////////////////////////////////////////////
if (values && values.length)
{
for (let i = 0; i < values.length; i++)
{
const expression = this.gridCriteriaValueToExpression(values[i]);
if (expression)
{
values[i] = expression;
}
}
}
defaultFilter.items.push({
columnField: criteria.fieldName,
operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, values),
value: FilterUtils.qqqCriteriaValuesToGrid(criteria.operator, values, field),
id: id++
});
}
defaultFilter.linkOperator = GridLinkOperator.And;
if (qQueryFilter.booleanOperator === "OR")
{
defaultFilter.linkOperator = GridLinkOperator.Or;
}
/////////////////////////////////////////////////////////////////
// translate from qqq-style orderBy to one that the grid wants //
/////////////////////////////////////////////////////////////////
if (qQueryFilter.orderBys && qQueryFilter.orderBys.length > 0)
{
for (let i = 0; i < qQueryFilter.orderBys.length; i++)
{
const orderBy = qQueryFilter.orderBys[i];
defaultSort.push({
field: orderBy.fieldName,
sort: orderBy.isAscending ? "asc" : "desc"
});
}
}
if (searchParams && searchParams.has("filter"))
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if we're setting the filter based on a filter query-string param, then make sure we don't have a currentSavedFilter in local storage. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
localStorage.removeItem(`${CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT}.${tableMetaData.name}`);
localStorage.setItem(filterLocalStorageKey, JSON.stringify(defaultFilter));
localStorage.setItem(sortLocalStorageKey, JSON.stringify(defaultSort));
}
return ({filter: defaultFilter, sort: defaultSort, warning: warningParts.length > 0 ? "Warning: " + warningParts.join("; ") : ""});
}
catch (e)
{
console.warn("Error parsing filter from query string", e);
}
}
if (localStorage.getItem(filterLocalStorageKey))
{
defaultFilter = JSON.parse(localStorage.getItem(filterLocalStorageKey));
console.log(`Got default from LS: ${JSON.stringify(defaultFilter)}`);
}
if (localStorage.getItem(sortLocalStorageKey))
{
defaultSort = JSON.parse(localStorage.getItem(sortLocalStorageKey));
console.log(`Got default from LS: ${JSON.stringify(defaultSort)}`);
}
}
/////////////////////////////////////////////////////////////////////////////////
// if any values in the items are objects, but should be expression instances, //
// then convert & replace them. //
/////////////////////////////////////////////////////////////////////////////////
if (defaultFilter && defaultFilter.items && defaultFilter.items.length)
{
defaultFilter.items.forEach((item) =>
{
if (item.value && item.value.length)
{
for (let i = 0; i < item.value.length; i++)
{
const expression = this.gridCriteriaValueToExpression(item.value[i]);
if (expression)
{
item.value[i] = expression;
}
}
}
else
{
const expression = this.gridCriteriaValueToExpression(item.value);
if (expression)
{
item.value = expression;
}
}
});
}
return ({filter: defaultFilter, sort: defaultSort, warning: warningParts.length > 0 ? "Warning: " + warningParts.join("; ") : ""});
}
/*******************************************************************************
** build a grid filter from a qqq filter
*******************************************************************************/
public static buildGridFilterFromQFilter(tableMetaData: QTableMetaData, queryFilter: QQueryFilter): GridFilterModel
{
const gridItems: GridFilterItem[] = [];
for (let i = 0; i < queryFilter.criteria.length; i++)
{
const criteria = queryFilter.criteria[i];
const [field, fieldTable] = FilterUtils.getField(tableMetaData, criteria.fieldName);
if (field)
{
gridItems.push({columnField: criteria.fieldName, id: i, operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, criteria.values), value: FilterUtils.qqqCriteriaValuesToGrid(criteria.operator, criteria.values, field)});
}
}
const gridFilter: GridFilterModel = {items: gridItems, linkOperator: queryFilter.booleanOperator == "AND" ? GridLinkOperator.And : GridLinkOperator.Or};
return (gridFilter);
}
/*******************************************************************************
**
*******************************************************************************/
public static getField(tableMetaData: QTableMetaData, fieldName: string): [QFieldMetaData, QTableMetaData]
{
if (fieldName == null)
{
return ([null, null]);
}
if (fieldName.indexOf(".") > -1)
{
let parts = fieldName.split(".", 2);
if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length)
{
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
{
const joinTable = tableMetaData.exposedJoins[i].joinTable;
if (joinTable.name == parts[0])
{
return ([joinTable.fields.get(parts[1]), joinTable]);
}
}
}
console.log(`Failed to find join field: ${fieldName}`);
return ([null, null]);
}
else
{
return ([tableMetaData.fields.get(fieldName), tableMetaData]);
}
}
/*******************************************************************************
** build a qqq filter from a grid and column sort model
*******************************************************************************/
public static buildQFilterFromGridFilter(tableMetaData: QTableMetaData, filterModel: GridFilterModel, columnSortModel: GridSortItem[], limit?: number, allowIncompleteCriteria = false): QQueryFilter
{
console.log("Building q filter with model:");
console.log(filterModel);
const qFilter = new QQueryFilter();
if (columnSortModel)
{
columnSortModel.forEach((gridSortItem) =>
{
qFilter.addOrderBy(new QFilterOrderBy(gridSortItem.field, gridSortItem.sort === "asc"));
});
}
if (limit)
{
console.log("Setting limit to: " + limit);
qFilter.limit = limit;
}
if (filterModel)
{
let foundFilter = false;
filterModel.items.forEach((item) =>
{
/////////////////////////////////////////////////////////////////////////
// set the values for these operators that otherwise don't have values //
/////////////////////////////////////////////////////////////////////////
if (item.operatorValue === "isTrue")
{
item.value = [true];
}
else if (item.operatorValue === "isFalse")
{
item.value = [false];
}
////////////////////////////////////////////////////////////////////////////////
// if no value set and not 'empty' or 'not empty' operators, skip this filter //
////////////////////////////////////////////////////////////////////////////////
let incomplete = false;
if (item.operatorValue === "between" || item.operatorValue === "notBetween")
{
if (!item.value || !item.value.length || item.value.length < 2 || this.isUnset(item.value[0]) || this.isUnset(item.value[1]))
{
incomplete = true;
}
}
else if ((!item.value || item.value.length == 0 || (item.value.length == 1 && this.isUnset(item.value[0]))) && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty")
{
incomplete = true;
}
if (incomplete && !allowIncompleteCriteria)
{
console.log(`Discarding incomplete filter criteria: ${JSON.stringify(item)}`);
return;
}
const fieldMetadata = tableMetaData?.fields.get(item.columnField);
const operator = FilterUtils.gridCriteriaOperatorToQQQ(item.operatorValue);
const values = FilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue, fieldMetadata);
let criteria = new QFilterCriteria(item.columnField, operator, values);
qFilter.addCriteria(criteria);
foundFilter = true;
});
qFilter.booleanOperator = "AND";
if (filterModel.linkOperator == "or")
{
///////////////////////////////////////////////////////////////////////////////////////////
// by default qFilter uses AND - so only if we see linkOperator=or do we need to set it //
///////////////////////////////////////////////////////////////////////////////////////////
qFilter.booleanOperator = "OR";
}
}
return qFilter;
};
/*******************************************************************************
**
*******************************************************************************/
private static isUnset(value: any)
{
return value === "" || value === undefined;
}
/*******************************************************************************
**
*******************************************************************************/
private static gridCriteriaValueToExpression(value: any)
{
if (value && value.length)
{
value = value[0];
}
if (value && value.type)
{
if (value.type == "NowWithOffset")
{
return (new NowWithOffsetExpression(value));
}
else if (value.type == "Now")
{
return (new NowExpression(value));
}
else if (value.type == "ThisOrLastPeriod")
{
return (new ThisOrLastPeriodExpression(value));
}
}
return (null);
}
/*******************************************************************************
** edit the input filter object, replacing any values which have {id,label} attributes
** to instead just have the id part.
*******************************************************************************/
public static convertFilterPossibleValuesToIds(inputFilter: QQueryFilter): QQueryFilter
{
const filter = Object.assign({}, inputFilter);
if (filter.criteria)
{
for (let i = 0; i < filter.criteria.length; i++)
{
const criteria = filter.criteria[i];
if (criteria.values)
{
for (let j = 0; j < criteria.values.length; j++)
{
let value = criteria.values[j];
if (value && value.id && value.label)
{
criteria.values[j] = value.id;
}
}
}
}
}
return (filter);
}
/*******************************************************************************
**
*******************************************************************************/
public static canFilterWorkAsBasic(tableMetaData: QTableMetaData, filter: QQueryFilter): { canFilterWorkAsBasic: boolean; reasonsWhyItCannot?: string[] }
{
const reasonsWhyItCannot: string[] = [];
if(filter == null)
{
return ({canFilterWorkAsBasic: true});
}
if(filter.booleanOperator == "OR")
{
reasonsWhyItCannot.push("Filter uses the 'OR' operator.")
}
if(filter.criteria)
{
const usedFields: {[name: string]: boolean} = {};
const warnedFields: {[name: string]: boolean} = {};
for (let i = 0; i < filter.criteria.length; i++)
{
const criteriaName = filter.criteria[i].fieldName;
if(!criteriaName)
{
continue;
}
if(usedFields[criteriaName])
{
if(!warnedFields[criteriaName])
{
const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, criteriaName);
let fieldLabel = field.label;
if(tableForField.name != tableMetaData.name)
{
let fieldLabel = `${tableForField.label}: ${field.label}`;
}
reasonsWhyItCannot.push(`Filter contains more than 1 condition for the field: ${fieldLabel}`);
warnedFields[criteriaName] = true;
}
}
usedFields[criteriaName] = true;
}
}
if(reasonsWhyItCannot.length == 0)
{
return ({canFilterWorkAsBasic: true});
}
else
{
return ({canFilterWorkAsBasic: false, reasonsWhyItCannot: reasonsWhyItCannot});
}
}
/*******************************************************************************
** get the values associated with a criteria as a string, e.g., for showing
** in a tooltip.
*******************************************************************************/
public static getValuesString(fieldMetaData: QFieldMetaData, criteria: QFilterCriteria, maxValuesToShow: number = 3): string
{
let valuesString = "";
if (criteria.values && criteria.values.length && fieldMetaData.type !== QFieldType.BOOLEAN)
{
let labels = [] as string[];
let maxLoops = criteria.values.length;
if (maxLoops > (maxValuesToShow + 2))
{
maxLoops = maxValuesToShow;
}
for (let i = 0; i < maxLoops; i++)
{
if (criteria.values[i] && criteria.values[i].label)
{
labels.push(criteria.values[i].label);
}
else
{
labels.push(criteria.values[i]);
}
}
if (maxLoops < criteria.values.length)
{
labels.push(" and " + (criteria.values.length - maxLoops) + " other values.");
}
valuesString = (labels.join(", "));
}
return valuesString;
}
}
export default FilterUtils;

View File

@ -0,0 +1,577 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {NowExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowExpression";
import {NowWithOffsetExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression";
import Box from "@mui/material/Box";
import {GridSortModel} from "@mui/x-data-grid-pro";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
/*******************************************************************************
** Utility class for working with QQQ Filters
**
*******************************************************************************/
class FilterUtils
{
/*******************************************************************************
** Helper method - take a list of values, which may be possible values, and
** either return the original list, or a new list that is just the ids of the
** possible values (if it was a list of possible values).
**
** Or, if the values are date-times, convert them to UTC.
*******************************************************************************/
public static cleanseCriteriaValueForQQQ = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] =>
{
if (param === null || param === undefined)
{
return (param);
}
if (FilterUtils.gridCriteriaValueToExpression(param))
{
return (param);
}
let rs = [];
for (let i = 0; i < param.length; i++)
{
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);
}
else
{
if (fieldMetaData?.type == QFieldType.DATE_TIME)
{
try
{
let toPush = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(param[i]);
rs.push(toPush);
}
catch (e)
{
console.log("Error converting date-time to UTC: ", e);
rs.push(param[i]);
}
}
else
{
rs.push(param[i]);
}
}
}
return (rs);
};
/*******************************************************************************
**
*******************************************************************************/
public static async cleanupValuesInFilerFromQueryString(qController: QController, tableMetaData: QTableMetaData, queryFilter: QQueryFilter)
{
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
const criteria = queryFilter.criteria[i];
let [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName);
let values = criteria.values;
if (field.possibleValueSourceName)
{
//////////////////////////////////////////////////////////////////////////////////
// possible-values in query-string are expected to only be their id values. //
// e.g., ...values=[1]... //
// but we need them to be possibleValue objects (w/ id & label) so the label //
// can be shown in the filter dropdown. So, make backend call to look them up. //
//////////////////////////////////////////////////////////////////////////////////
if (values && values.length > 0)
{
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values);
}
////////////////////////////////////////////
// log message if no values were returned //
////////////////////////////////////////////
if (!values || values.length === 0)
{
console.warn("WARNING: No possible values were returned for [" + field.possibleValueSourceName + "] for values [" + criteria.values + "].");
}
}
if (values && values.length)
{
for (let i = 0; i < values.length; i++)
{
//////////////////////////////////////////////////////////////////////////
// replace objects that look like expressions with expression instances //
//////////////////////////////////////////////////////////////////////////
const expression = this.gridCriteriaValueToExpression(values[i]);
if (expression)
{
values[i] = expression;
}
else
{
///////////////////////////////////////////
// make date-times work for the frontend //
///////////////////////////////////////////
if (field.type == QFieldType.DATE_TIME)
{
values[i] = ValueUtils.formatDateTimeValueForForm(values[i]);
}
}
}
}
criteria.values = values;
}
}
/*******************************************************************************
** given a table, and a field name (which may be prefixed with an exposed-join
** table name (from the table) - return the corresponding field-meta-data, and
** the table that the field is from (e.g., may be a join table!)
*******************************************************************************/
public static getField(tableMetaData: QTableMetaData, fieldName: string): [QFieldMetaData, QTableMetaData]
{
if (fieldName == null)
{
return ([null, null]);
}
if (fieldName.indexOf(".") > -1)
{
let parts = fieldName.split(".", 2);
if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length)
{
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
{
const joinTable = tableMetaData.exposedJoins[i].joinTable;
if (joinTable.name == parts[0])
{
return ([joinTable.fields.get(parts[1]), joinTable]);
}
}
}
console.log(`Failed to find join field: ${fieldName}`);
return ([null, null]);
}
else
{
return ([tableMetaData.fields.get(fieldName), tableMetaData]);
}
}
/*******************************************************************************
**
*******************************************************************************/
public static gridCriteriaValueToExpression(value: any)
{
if (value && value.length)
{
value = value[0];
}
if (value && value.type)
{
if (value.type == "NowWithOffset")
{
return (new NowWithOffsetExpression(value));
}
else if (value.type == "Now")
{
return (new NowExpression(value));
}
else if (value.type == "ThisOrLastPeriod")
{
return (new ThisOrLastPeriodExpression(value));
}
}
return (null);
}
/*******************************************************************************
** edit the input filter object, replacing any values which have {id,label} attributes
** to instead just have the id part.
*******************************************************************************/
public static convertFilterPossibleValuesToIds(inputFilter: QQueryFilter): QQueryFilter
{
const filter = Object.assign({}, inputFilter);
if (filter.criteria)
{
for (let i = 0; i < filter.criteria.length; i++)
{
const criteria = filter.criteria[i];
if (criteria.values)
{
for (let j = 0; j < criteria.values.length; j++)
{
let value = criteria.values[j];
if (value && value.id && value.label)
{
criteria.values[j] = value.id;
}
}
}
}
}
return (filter);
}
/*******************************************************************************
**
*******************************************************************************/
public static canFilterWorkAsBasic(tableMetaData: QTableMetaData, filter: QQueryFilter): { canFilterWorkAsBasic: boolean; reasonsWhyItCannot?: string[] }
{
const reasonsWhyItCannot: string[] = [];
if(filter == null)
{
return ({canFilterWorkAsBasic: true});
}
if(filter.booleanOperator == "OR")
{
reasonsWhyItCannot.push("Filter uses the 'OR' operator.")
}
if(filter.criteria)
{
const usedFields: {[name: string]: boolean} = {};
const warnedFields: {[name: string]: boolean} = {};
for (let i = 0; i < filter.criteria.length; i++)
{
const criteriaName = filter.criteria[i].fieldName;
if(!criteriaName)
{
continue;
}
if(usedFields[criteriaName])
{
if(!warnedFields[criteriaName])
{
const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, criteriaName);
let fieldLabel = field.label;
if(tableForField.name != tableMetaData.name)
{
let fieldLabel = `${tableForField.label}: ${field.label}`;
}
reasonsWhyItCannot.push(`Filter contains more than 1 condition for the field: ${fieldLabel}`);
warnedFields[criteriaName] = true;
}
}
usedFields[criteriaName] = true;
}
}
if(reasonsWhyItCannot.length == 0)
{
return ({canFilterWorkAsBasic: true});
}
else
{
return ({canFilterWorkAsBasic: false, reasonsWhyItCannot: reasonsWhyItCannot});
}
}
/*******************************************************************************
** get the values associated with a criteria as a string, e.g., for showing
** in a tooltip.
*******************************************************************************/
public static getValuesString(fieldMetaData: QFieldMetaData, criteria: QFilterCriteria, maxValuesToShow: number = 3, andMoreFormat: "andNOther" | "+N" = "andNOther"): string
{
let valuesString = "";
if(criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK)
{
///////////////////////////////////////////////
// we don't want values for these operators. //
///////////////////////////////////////////////
return valuesString;
}
if (criteria.values && criteria.values.length)
{
let labels = [] as string[];
let maxLoops = criteria.values.length;
if (maxLoops > (maxValuesToShow + 2))
{
maxLoops = maxValuesToShow;
}
else if(maxValuesToShow == 1 && criteria.values.length > 1)
{
maxLoops = 1;
}
for (let i = 0; i < maxLoops; i++)
{
const value = criteria.values[i];
if (value.type == "NowWithOffset")
{
const expression = new NowWithOffsetExpression(value);
labels.push(expression.toString());
}
else if (value.type == "Now")
{
const expression = new NowExpression(value);
labels.push(expression.toString());
}
else if (value.type == "ThisOrLastPeriod")
{
const expression = new ThisOrLastPeriodExpression(value);
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)
{
labels.push(value == true ? "yes" : "no")
}
else if(fieldMetaData.type == QFieldType.DATE_TIME)
{
labels.push(ValueUtils.formatDateTime(value));
}
else if(fieldMetaData.type == QFieldType.DATE)
{
labels.push(ValueUtils.formatDate(value));
}
else if (value && value.label)
{
labels.push(value.label);
}
else
{
labels.push(value);
}
}
if (maxLoops < criteria.values.length)
{
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(", "));
}
return valuesString;
}
/*******************************************************************************
**
*******************************************************************************/
public static buildQFilterFromJSONObject(object: any): QQueryFilter
{
const queryFilter = new QQueryFilter();
queryFilter.criteria = [];
for (let i = 0; i < object.criteria?.length; i++)
{
const criteriaObject = object.criteria[i];
queryFilter.criteria.push(new QFilterCriteria(criteriaObject.fieldName, criteriaObject.operator, criteriaObject.values));
}
queryFilter.orderBys = [];
for (let i = 0; i < object.orderBys?.length; i++)
{
const orderByObject = object.orderBys[i];
queryFilter.orderBys.push(new QFilterOrderBy(orderByObject.fieldName, orderByObject.isAscending));
}
queryFilter.booleanOperator = object.booleanOperator;
queryFilter.skip = object.skip;
queryFilter.limit = object.limit;
return (queryFilter);
}
/*******************************************************************************
**
*******************************************************************************/
public static getGridSortFromQueryFilter(queryFilter: QQueryFilter): GridSortModel
{
const gridSortModel: GridSortModel = [];
for (let i = 0; i < queryFilter?.orderBys?.length; i++)
{
const orderBy = queryFilter.orderBys[i];
gridSortModel.push({field: orderBy.fieldName, sort: orderBy.isAscending ? "asc" : "desc"})
}
return (gridSortModel);
}
/*******************************************************************************
**
*******************************************************************************/
public static operatorToHumanString(criteria: QFilterCriteria, field: QFieldMetaData): string
{
if(criteria == null || criteria.operator == null)
{
return (null);
}
const isDate = field.type == QFieldType.DATE;
const isDateTime = field.type == QFieldType.DATE_TIME;
try
{
switch(criteria.operator)
{
case QCriteriaOperator.EQUALS:
return ("equals");
case QCriteriaOperator.NOT_EQUALS:
case QCriteriaOperator.NOT_EQUALS_OR_IS_NULL:
return ("does not equal");
case QCriteriaOperator.IN:
return ("is any of");
case QCriteriaOperator.NOT_IN:
return ("is none of");
case QCriteriaOperator.STARTS_WITH:
return ("starts with");
case QCriteriaOperator.ENDS_WITH:
return ("ends with");
case QCriteriaOperator.CONTAINS:
return ("contains");
case QCriteriaOperator.NOT_STARTS_WITH:
return ("does not start with");
case QCriteriaOperator.NOT_ENDS_WITH:
return ("does not end with");
case QCriteriaOperator.NOT_CONTAINS:
return ("does not contain");
case QCriteriaOperator.LESS_THAN:
if(isDate || isDateTime)
{
return ("is before")
}
return ("less than");
case QCriteriaOperator.LESS_THAN_OR_EQUALS:
if(isDate)
{
return ("is on or before")
}
if(isDateTime)
{
return ("is at or before")
}
return ("less than or equals");
case QCriteriaOperator.GREATER_THAN:
if(isDate || isDateTime)
{
return ("is after")
}
return ("greater than or equals");
case QCriteriaOperator.GREATER_THAN_OR_EQUALS:
if(isDate)
{
return ("is on or after")
}
if(isDateTime)
{
return ("is at or after")
}
return ("greater than or equals");
case QCriteriaOperator.IS_BLANK:
return ("is empty");
case QCriteriaOperator.IS_NOT_BLANK:
return ("is not empty");
case QCriteriaOperator.BETWEEN:
return ("is between");
case QCriteriaOperator.NOT_BETWEEN:
return ("is not between");
}
}
catch(e)
{
console.log(`Error getting operator human string for ${JSON.stringify(criteria)}: ${e}`);
return criteria?.operator
}
}
/*******************************************************************************
**
*******************************************************************************/
public static criteriaToHumanString(table: QTableMetaData, criteria: QFilterCriteria, styled = false): string | JSX.Element
{
if(criteria == null)
{
return (null);
}
const [field, fieldTable] = TableUtils.getFieldAndTable(table, criteria.fieldName);
const fieldLabel = TableUtils.getFieldFullLabel(table, criteria.fieldName);
const valuesString = FilterUtils.getValuesString(field, criteria);
if(styled)
{
return (
<Box display="inline" whiteSpace="nowrap" color="#FFFFFF" mb={"0.5rem"}>
<Box display="inline" p="0.125rem" pl="0.5rem" sx={{background: "#0062FF"}} borderRadius="0.5rem 0 0 0.5rem">{fieldLabel} </Box>
<Box display="inline" p="0.125rem" sx={{background: "#757575"}} borderRadius={valuesString ? "0" : "0 0.5rem 0.5rem 0"}> {FilterUtils.operatorToHumanString(criteria, field)} </Box>
{valuesString && <Box display="inline" p="0.125rem" pr="0.5rem" sx={{background: "#009971"}} borderRadius="0 0.5rem 0.5rem 0"> {valuesString}</Box>}
&nbsp;
</Box>
)
}
else
{
return (`${fieldLabel} ${FilterUtils.operatorToHumanString(criteria, field)} ${valuesString}`);
}
}
}
export default FilterUtils;

View File

@ -0,0 +1,418 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import QQueryColumns from "qqq/models/query/QQueryColumns";
import RecordQueryView from "qqq/models/query/RecordQueryView";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
/*******************************************************************************
** Utility class for working with QQQ Saved Views
**
*******************************************************************************/
export class SavedViewUtils
{
/*******************************************************************************
**
*******************************************************************************/
public static fieldNameToLabel = (tableMetaData: QTableMetaData, fieldName: string): string =>
{
try
{
const [fieldMetaData, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
if (fieldTable.name != tableMetaData.name)
{
return (fieldTable.label + ": " + fieldMetaData.label);
}
return (fieldMetaData.label);
}
catch (e)
{
return (fieldName);
}
};
/*******************************************************************************
**
*******************************************************************************/
public static diffFilters = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void =>
{
try
{
////////////////////////////////////////////////////////////////////////////////
// inner helper function for reporting on the number of criteria for a field. //
// e.g., will tell us "added criteria X" or "removed 2 criteria on Y" //
////////////////////////////////////////////////////////////////////////////////
const diffCriteriaFunction = (base: QQueryFilter, compare: QQueryFilter, messagePrefix: string, isCheckForChanged = false) =>
{
const baseCriteriaMap: { [name: string]: QFilterCriteria[] } = {};
base?.criteria?.forEach((criteria) =>
{
if (validateCriteria(criteria).criteriaIsValid)
{
if (!baseCriteriaMap[criteria.fieldName])
{
baseCriteriaMap[criteria.fieldName] = [];
}
baseCriteriaMap[criteria.fieldName].push(criteria);
}
});
const compareCriteriaMap: { [name: string]: QFilterCriteria[] } = {};
compare?.criteria?.forEach((criteria) =>
{
if (validateCriteria(criteria).criteriaIsValid)
{
if (!compareCriteriaMap[criteria.fieldName])
{
compareCriteriaMap[criteria.fieldName] = [];
}
compareCriteriaMap[criteria.fieldName].push(criteria);
}
});
for (let fieldName of Object.keys(compareCriteriaMap))
{
const noBaseCriteria = baseCriteriaMap[fieldName]?.length ?? 0;
const noCompareCriteria = compareCriteriaMap[fieldName]?.length ?? 0;
if (isCheckForChanged)
{
/////////////////////////////////////////////////////////////////////////////////////////////
// first - if we're checking for changes to specific criteria (e.g., change id=5 to id<>5, //
// or change id=5 to id=6, or change id=5 to id<>7) //
// our "sweet spot" is if there's a single criteria on each side of the check //
/////////////////////////////////////////////////////////////////////////////////////////////
if (noBaseCriteria == 1 && noCompareCriteria == 1)
{
const baseCriteria = baseCriteriaMap[fieldName][0];
const compareCriteria = compareCriteriaMap[fieldName][0];
const baseValuesJSON = JSON.stringify(baseCriteria.values ?? []);
const compareValuesJSON = JSON.stringify(compareCriteria.values ?? []);
if (baseCriteria.operator != compareCriteria.operator || baseValuesJSON != compareValuesJSON)
{
viewDiffs.push(`Changed a filter from ${FilterUtils.criteriaToHumanString(tableMetaData, baseCriteria)} to ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteria)}`);
}
}
else if (noBaseCriteria == noCompareCriteria)
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else - if the number of criteria on this field differs, that'll get caught in a non-isCheckForChanged call, so //
// todo, i guess - this is kinda weak - but if there's the same number of criteria on a field, then just ... do a shitty JSON compare between them... //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const baseJSON = JSON.stringify(baseCriteriaMap[fieldName]);
const compareJSON = JSON.stringify(compareCriteriaMap[fieldName]);
if (baseJSON != compareJSON)
{
viewDiffs.push(`${messagePrefix} 1 or more filters on ${SavedViewUtils.fieldNameToLabel(tableMetaData, fieldName)}`);
}
}
}
else
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else - we're not checking for changes to individual criteria - rather - we're just checking if criteria were added or removed. //
// we'll do that by starting to see if the nubmer of criteria is different. //
// and, only do it in only 1 direction, assuming we'll get called twice, with the base & compare sides flipped //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (noBaseCriteria < noCompareCriteria)
{
if (noBaseCriteria == 0 && noCompareCriteria == 1)
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if the difference is 0 to 1 (1 to 0 when called in reverse), then we can report the full criteria that was added/removed //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
viewDiffs.push(`${messagePrefix} filter: ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteriaMap[fieldName][0])}`);
}
else
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else, say 0 to 2, or 2 to 1 - just report on how many were changed... //
// todo this isn't great, as you might have had, say, (A,B), and now you have (C) - but all we'll say is "removed 1"... //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const noDiffs = noCompareCriteria - noBaseCriteria;
viewDiffs.push(`${messagePrefix} ${noDiffs} filters on ${SavedViewUtils.fieldNameToLabel(tableMetaData, fieldName)}`);
}
}
}
}
};
diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Added");
diffCriteriaFunction(activeView.queryFilter, savedView.queryFilter, "Removed");
diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Changed", true);
//////////////////////
// boolean operator //
//////////////////////
if (savedView.queryFilter.booleanOperator != activeView.queryFilter.booleanOperator)
{
viewDiffs.push("Changed filter from 'And' to 'Or'");
}
///////////////
// order-bys //
///////////////
const savedOrderBys = savedView.queryFilter.orderBys;
const activeOrderBys = activeView.queryFilter.orderBys;
if (savedOrderBys.length != activeOrderBys.length)
{
viewDiffs.push("Changed sort");
}
else if (savedOrderBys.length > 0)
{
const toWord = ((b: boolean) => b ? "ascending" : "descending");
if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName && savedOrderBys[0].isAscending != activeOrderBys[0].isAscending)
{
viewDiffs.push(`Changed sort from ${SavedViewUtils.fieldNameToLabel(tableMetaData, savedOrderBys[0].fieldName)} ${toWord(savedOrderBys[0].isAscending)} to ${SavedViewUtils.fieldNameToLabel(tableMetaData, activeOrderBys[0].fieldName)} ${toWord(activeOrderBys[0].isAscending)}`);
}
else if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName)
{
viewDiffs.push(`Changed sort field from ${SavedViewUtils.fieldNameToLabel(tableMetaData, savedOrderBys[0].fieldName)} to ${SavedViewUtils.fieldNameToLabel(tableMetaData, activeOrderBys[0].fieldName)}`);
}
else if (savedOrderBys[0].isAscending != activeOrderBys[0].isAscending)
{
viewDiffs.push(`Changed sort direction from ${toWord(savedOrderBys[0].isAscending)} to ${toWord(activeOrderBys[0].isAscending)}`);
}
}
}
catch (e)
{
console.log(`Error looking for differences in filters ${e}`);
}
};
/*******************************************************************************
**
*******************************************************************************/
public static diffColumns = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void =>
{
try
{
if (!savedView.queryColumns || !savedView.queryColumns.columns || savedView.queryColumns.columns.length == 0)
{
viewDiffs.push("This view did not previously have columns saved with it, so the next time you save it they will be initialized.");
return;
}
////////////////////////////////////////////////////////////
// nested function to help diff visible status of columns //
////////////////////////////////////////////////////////////
const diffVisibilityFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) =>
{
const baseColumnsMap: { [name: string]: boolean } = {};
base.columns.forEach((column) =>
{
if (column.isVisible)
{
baseColumnsMap[column.name] = true;
}
});
const diffFields: string[] = [];
for (let i = 0; i < compare.columns.length; i++)
{
const column = compare.columns[i];
if (column.isVisible)
{
if (!baseColumnsMap[column.name])
{
diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name));
}
}
}
if (diffFields.length > 0)
{
if (diffFields.length > 5)
{
viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`);
}
else
{
viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
}
}
};
///////////////////////////////////////////////////////////
// nested function to help diff pinned status of columns //
///////////////////////////////////////////////////////////
const diffPinsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) =>
{
const baseColumnsMap: { [name: string]: string } = {};
base.columns.forEach((column) => baseColumnsMap[column.name] = column.pinned);
const diffFields: string[] = [];
for (let i = 0; i < compare.columns.length; i++)
{
const column = compare.columns[i];
if (baseColumnsMap[column.name] != column.pinned)
{
diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name));
}
}
if (diffFields.length > 0)
{
if (diffFields.length > 5)
{
viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`);
}
else
{
viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
}
}
};
///////////////////////////////////////////////////
// nested function to help diff width of columns //
///////////////////////////////////////////////////
const diffWidthsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) =>
{
const baseColumnsMap: { [name: string]: number } = {};
base.columns.forEach((column) => baseColumnsMap[column.name] = column.width);
const diffFields: string[] = [];
for (let i = 0; i < compare.columns.length; i++)
{
const column = compare.columns[i];
if (baseColumnsMap[column.name] != column.width)
{
diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name));
}
}
if (diffFields.length > 0)
{
if (diffFields.length > 5)
{
viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`);
}
else
{
viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
}
}
};
diffVisibilityFunction(savedView.queryColumns, activeView.queryColumns, "Turned on ");
diffVisibilityFunction(activeView.queryColumns, savedView.queryColumns, "Turned off ");
diffPinsFunction(savedView.queryColumns, activeView.queryColumns, "Changed pinned state for ");
if (savedView.queryColumns.columns.map(c => c.name).join(",") != activeView.queryColumns.columns.map(c => c.name).join(","))
{
viewDiffs.push("Changed the order of columns.");
}
diffWidthsFunction(savedView.queryColumns, activeView.queryColumns, "Changed width for ");
}
catch (e)
{
console.log(`Error looking for differences in columns: ${e}`);
}
};
/*******************************************************************************
**
*******************************************************************************/
public static diffQuickFilterFieldNames = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void =>
{
try
{
const diffFunction = (base: string[], compare: string[], messagePrefix: string) =>
{
const baseFieldNameMap: { [name: string]: boolean } = {};
base.forEach((name) => baseFieldNameMap[name] = true);
const diffFields: string[] = [];
for (let i = 0; i < compare.length; i++)
{
const name = compare[i];
if (!baseFieldNameMap[name])
{
diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, name));
}
}
if (diffFields.length > 0)
{
viewDiffs.push(`${messagePrefix} basic filter${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`);
}
};
diffFunction(savedView.quickFilterFieldNames, activeView.quickFilterFieldNames, "Turned on");
diffFunction(activeView.quickFilterFieldNames, savedView.quickFilterFieldNames, "Turned off");
}
catch (e)
{
console.log(`Error looking for differences in quick filter field names: ${e}`);
}
};
/*******************************************************************************
**
*******************************************************************************/
public static diffViews = (tableMetaData: QTableMetaData, baseView: RecordQueryView, activeView: RecordQueryView): string[] =>
{
const viewDiffs: string[] = [];
SavedViewUtils.diffFilters(tableMetaData, baseView, activeView, viewDiffs);
SavedViewUtils.diffColumns(tableMetaData, baseView, activeView, viewDiffs);
SavedViewUtils.diffQuickFilterFieldNames(tableMetaData, baseView, activeView, viewDiffs);
if (baseView.mode != activeView.mode)
{
if (baseView.mode)
{
viewDiffs.push(`Mode changed from ${baseView.mode} to ${activeView.mode}`);
}
else
{
viewDiffs.push(`Mode set to ${activeView.mode}`);
}
}
if (baseView.rowsPerPage != activeView.rowsPerPage)
{
if (baseView.rowsPerPage)
{
viewDiffs.push(`Rows per page changed from ${baseView.rowsPerPage} to ${activeView.rowsPerPage}`);
}
else
{
viewDiffs.push(`Rows per page set to ${activeView.rowsPerPage}`);
}
}
return viewDiffs;
};
}

View File

@ -113,6 +113,31 @@ class TableUtils
return (null);
}
/*******************************************************************************
** for a field that might be from a join table, get its label - either the field's
** label, if it's from "this" table - or the table's label: field's label, if it's
** from a join table.
*******************************************************************************/
public static getFieldFullLabel(tableMetaData: QTableMetaData, fieldName: string): string
{
try
{
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
if (fieldTable.name == tableMetaData.name)
{
return (field.label);
}
return `${fieldTable.label}: ${field.label}`;
}
catch (e)
{
console.log(`Error getting full field label for ${fieldName} in table ${tableMetaData?.name}: ${e}`);
return fieldName
}
}
/*******************************************************************************
**
*******************************************************************************/

View File

@ -177,7 +177,7 @@ public class QBaseSeleniumTest
.withRouteToFile("/metaData/table/city", "metaData/table/person.json")
.withRouteToFile("/metaData/table/script", "metaData/table/script.json")
.withRouteToFile("/metaData/table/scriptRevision", "metaData/table/scriptRevision.json")
.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json");
.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json");
}

View File

@ -28,7 +28,7 @@ package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib;
public interface QQQMaterialDashboardSelectors
{
String SIDEBAR_ITEM = ".MuiDrawer-paperAnchorDockedLeft li.MuiListItem-root";
String BREADCRUMB_HEADER = ".MuiToolbar-root h3";
String BREADCRUMB_HEADER = "h3";
String QUERY_GRID_CELL = ".MuiDataGrid-root .MuiDataGrid-cellContent";
String QUERY_FILTER_INPUT = ".customFilterPanel input.MuiInput-input";

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;
@ -43,7 +44,7 @@ import org.openqa.selenium.WebElement;
import org.openqa.selenium.interactions.Actions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.fail;
@ -63,6 +64,11 @@ public class QSeleniumLib
private boolean autoHighlight = false;
//////////////////////////////////////////////////////////////////////////////////////
// useful to use on a WebElement, in a call like: .findElement(QSeleniumLib.PARENT) //
//////////////////////////////////////////////////////////////////////////////////////
public static final By PARENT = By.xpath("./..");
/*******************************************************************************
@ -196,7 +202,7 @@ public class QSeleniumLib
/*******************************************************************************
**
*******************************************************************************/
public void gotoAndWaitForBreadcrumbHeader(String path, String headerText)
public void gotoAndWaitForBreadcrumbHeaderToContain(String path, String expectedHeaderText)
{
driver.get(BASE_URL + path);
@ -204,7 +210,7 @@ public class QSeleniumLib
.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER)));
LOG.debug("Navigated to [" + path + "]. Breadcrumb Header: " + header.getText());
assertEquals(headerText, header.getText());
assertThat(header.getText()).contains(expectedHeaderText);
}
@ -245,6 +251,18 @@ public class QSeleniumLib
/*******************************************************************************
**
*******************************************************************************/
public void moveMouseCursorToElement(WebElement element)
{
Actions actions = new Actions(driver);
actions.moveToElement(element);
actions.perform();
}
/*******************************************************************************
**
*******************************************************************************/
@ -487,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
@ -497,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);
@ -520,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;
@ -52,7 +55,40 @@ public class QueryScreenLib
*******************************************************************************/
public WebElement assertFilterButtonBadge(int valueInBadge)
{
return qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", String.valueOf(valueInBadge));
return qSeleniumLib.waitForSelectorContaining(".filterBuilderCountBadge", String.valueOf(valueInBadge));
}
/*******************************************************************************
**
*******************************************************************************/
public void clickAdvancedFilterClearIcon()
{
qSeleniumLib.moveMouseCursorToElement(qSeleniumLib.waitForSelector(".filterBuilderButton"));
qSeleniumLib.waitForSelector(".filterBuilderXIcon BUTTON").click();
qSeleniumLib.waitForSelectorContaining("BUTTON", "Yes").click();
}
/*******************************************************************************
**
*******************************************************************************/
public void clickQuickFilterClearIcon(String fieldName)
{
qSeleniumLib.moveMouseCursorToElement(qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName));
qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName + "+span button").click();
}
/*******************************************************************************
**
*******************************************************************************/
public void assertNoFilterButtonBadge(int valueInBadge)
{
qSeleniumLib.waitForSelectorContainingToNotExist(".filterBuilderCountBadge", String.valueOf(valueInBadge));
}
@ -70,7 +106,7 @@ public class QueryScreenLib
/*******************************************************************************
**
*******************************************************************************/
public void clickFilterButton()
public void clickFilterBuilderButton()
{
qSeleniumLib.waitForSelectorContaining("BUTTON", "FILTER BUILDER").click();
}
@ -80,9 +116,19 @@ public class QueryScreenLib
/*******************************************************************************
**
*******************************************************************************/
public WebElement assertQuickFilterButtonBadge(String fieldName)
public WebElement assertQuickFilterButtonIndicatesActiveFilter(String fieldName)
{
return qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName + " .MuiBadge-root");
return qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName + ".filterActive");
}
/*******************************************************************************
**
*******************************************************************************/
public void assertQuickFilterButtonDoesNotIndicateActiveFilter(String fieldName)
{
qSeleniumLib.waitForSelectorToNotExist("#quickFilter\\." + fieldName + ".filterActive");
}
@ -92,7 +138,6 @@ public class QueryScreenLib
*******************************************************************************/
public void clickQuickFilterButton(String fieldName)
{
// qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click();
qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName).click();
}
@ -123,7 +168,27 @@ public class QueryScreenLib
/*******************************************************************************
**
*******************************************************************************/
public void addQueryFilterInput(QSeleniumLib qSeleniumLib, int index, String fieldLabel, String operator, String value, String booleanOperator)
public void assertSavedViewNameOnScreen(String savedViewName)
{
qSeleniumLib.waitForSelectorContaining("H3", savedViewName);
}
/*******************************************************************************
**
*******************************************************************************/
public WebElement waitForDataGridCellContaining(String containingText)
{
return qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", containingText);
}
/*******************************************************************************
**
*******************************************************************************/
public void addAdvancedQueryFilterInput(int index, String fieldLabel, String operator, String value, String booleanOperator)
{
if(index > 0)
{
@ -160,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

@ -57,7 +57,7 @@ public class AppPageNavTest extends QBaseSeleniumTest
@Test
void testHomeToAppPageViaLeftNav()
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/", "Greetings App");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/", "Greetings App");
qSeleniumLib.waitForSelectorContaining(QQQMaterialDashboardSelectors.SIDEBAR_ITEM, "People App").click();
qSeleniumLib.waitForSelectorContaining(QQQMaterialDashboardSelectors.SIDEBAR_ITEM, "Greetings App").click();
}
@ -70,7 +70,7 @@ public class AppPageNavTest extends QBaseSeleniumTest
@Test
void testAppPageToTablePage()
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp", "Greetings App");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp", "Greetings App");
qSeleniumLib.tryMultiple(3, () -> qSeleniumLib.waitForSelectorContaining("a", "Person").click());
qSeleniumLib.waitForSelectorContaining(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER, "Person");
}

View File

@ -53,7 +53,7 @@ public class AssociatedRecordScriptTest extends QBaseSeleniumTest
@Test
void testNavigatingBackAndForth()
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1", "John Doe");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1", "John Doe");
qSeleniumLib.waitForSelectorContaining("BUTTON", "actions").click();
qSeleniumLib.waitForSelectorContaining("LI", "Developer Mode").click();

View File

@ -63,7 +63,7 @@ public class AuditTest extends QBaseSeleniumTest
qSeleniumJavalin.withRouteToFile("/data/audit/query", "data/audit/query-empty.json");
qSeleniumJavalin.restart();
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1701", "John Doe");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1701", "John Doe");
qSeleniumLib.waitForSelectorContaining("BUTTON", "Actions").click();
qSeleniumLib.waitForSelectorContaining("LI", "Audit").click();
@ -90,7 +90,7 @@ public class AuditTest extends QBaseSeleniumTest
qSeleniumJavalin.withRouteToFile(auditQueryPath, "data/audit/query.json");
qSeleniumJavalin.restart();
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1701", "John Doe");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1701", "John Doe");
qSeleniumLib.waitForSelectorContaining("BUTTON", "Actions").click();
qSeleniumLib.waitForSelectorContaining("LI", "Audit").click();
@ -121,7 +121,7 @@ public class AuditTest extends QBaseSeleniumTest
qSeleniumJavalin.withRouteToFile(auditQueryPath, "data/audit/query.json");
qSeleniumJavalin.restart();
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1701", "John Doe");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1701", "John Doe");
qSeleniumLib.waitForSelectorContaining("BUTTON", "Actions").click();
qSeleniumLib.waitForSelectorContaining("LI", "Audit").click();

View File

@ -71,7 +71,7 @@ public class BulkEditTest extends QBaseSeleniumTest
// @RepeatedTest(100)
void test()
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
qSeleniumLib.waitForSelectorContaining("button", "selection").click();
qSeleniumLib.waitForSelectorContaining("li", "This page").click();
qSeleniumLib.waitForSelectorContaining("div", "records on this page are selected");

View File

@ -55,7 +55,7 @@ public class ClickLinkOnRecordThenEditShortcutTest extends QBaseSeleniumTest
@Test
void testClickLinkOnRecordThenEditShortcutTest()
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/developer/script/1", "Hello, Script");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/developer/script/1", "Hello, Script");
qSeleniumLib.waitForSelectorContaining("A", "100").click();
qSeleniumLib.waitForSelectorContaining("BUTTON", "actions").sendKeys("e");

View File

@ -26,6 +26,7 @@ import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
import org.apache.commons.io.FileUtils;
import org.junit.jupiter.api.Test;
@ -76,7 +77,7 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest
@Test
void testDashboardTableWidgetExport() throws IOException
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/", "Greetings App");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/", "Greetings App");
////////////////////////////////////////////////////////////////////////
// assert that the table widget rendered its header and some contents //
@ -89,7 +90,7 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest
// click the export button //
/////////////////////////////
qSeleniumLib.waitForSelector("#SampleTableWidget h6")
.findElement(By.xpath("./.."))
.findElement(QSeleniumLib.PARENT)
.findElement(By.cssSelector("button"))
.click();

View File

@ -1,143 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Test for the record query screen
*******************************************************************************/
public class QueryScreenTest extends QBaseSeleniumTest
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
{
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/person/query", "data/person/index.json")
.withRouteToFile("/data/person/variants", "data/person/variants.json")
.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testBuildQueryQueryAndClearFilters()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.gotoAdvancedMode();
queryScreenLib.clickFilterButton();
/////////////////////////////////////////////////////////////////////
// open the filter window, enter a value, wait for query to re-run //
/////////////////////////////////////////////////////////////////////
qSeleniumJavalin.beginCapture();
queryScreenLib.addQueryFilterInput(qSeleniumLib, 0, "Id", "equals", "1", null);
///////////////////////////////////////////////////////////////////
// assert that query & count both have the expected filter value //
///////////////////////////////////////////////////////////////////
String idEquals1FilterSubstring = """
{"fieldName":"id","operator":"EQUALS","values":["1"]}""";
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/count", idEquals1FilterSubstring);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", idEquals1FilterSubstring);
qSeleniumJavalin.endCapture();
///////////////////////////////////////
// click away from the filter window //
///////////////////////////////////////
qSeleniumLib.waitForSeconds(1); // todo grr.
qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER).click();
qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", "1");
///////////////////////////////////////////////////////////////////
// click the 'x' clear icon, then yes, then expect another query //
///////////////////////////////////////////////////////////////////
qSeleniumJavalin.beginCapture();
qSeleniumLib.tryMultiple(3, () -> qSeleniumLib.waitForSelector("#clearFiltersButton").click());
qSeleniumLib.waitForSelectorContaining("BUTTON", "Yes").click();
////////////////////////////////////////////////////////////////////
// assert that query & count both no longer have the filter value //
////////////////////////////////////////////////////////////////////
CapturedContext capturedCount = qSeleniumJavalin.waitForCapturedPath("/data/person/count");
CapturedContext capturedQuery = qSeleniumJavalin.waitForCapturedPath("/data/person/query");
assertThat(capturedCount).extracting("body").asString().doesNotContain(idEquals1FilterSubstring);
assertThat(capturedQuery).extracting("body").asString().doesNotContain(idEquals1FilterSubstring);
qSeleniumJavalin.endCapture();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testMultiCriteriaQueryWithOr()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.gotoAdvancedMode();
queryScreenLib.clickFilterButton();
qSeleniumJavalin.beginCapture();
queryScreenLib.addQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or");
queryScreenLib.addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or");
String expectedFilterContents0 = """
{"fieldName":"firstName","operator":"CONTAINS","values":["Dar"]}""";
String expectedFilterContents1 = """
{"fieldName":"firstName","operator":"CONTAINS","values":["Jam"]}""";
String expectedFilterContents2 = """
"booleanOperator":"OR\"""";
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectedFilterContents0);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectedFilterContents1);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectedFilterContents2);
qSeleniumJavalin.endCapture();
}
// todo - table requires variant - prompt for it, choose it, see query; change variant, change on-screen, re-query
}

View File

@ -59,7 +59,7 @@ public class ScriptTableTest extends QBaseSeleniumTest
@Test
void test()
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/developer/script/1", "Hello, Script");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/developer/script/1", "Hello, Script");
qSeleniumLib.waitForSelectorContaining("DIV.ace_line", "var hello;");
qSeleniumLib.waitForSelectorContaining("DIV", "2nd commit");

View File

@ -19,7 +19,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query;
import java.net.URLEncoder;
@ -55,7 +55,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
.withRouteToFile("/data/person/query", "data/person/index.json")
.withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json")
.withRouteToFile("/data/person/variants", "data/person/variants.json")
.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json");
.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json");
}
@ -71,7 +71,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
////////////////////////////////
// put table in advanced mode //
////////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.gotoAdvancedMode();
////////////////////////////////////////
@ -79,10 +79,10 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
////////////////////////////////////////
String filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
qSeleniumLib.waitForSelector("input[value=\"is not empty\"]");
///////////////////////////////
@ -90,35 +90,33 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
///////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
qSeleniumLib.waitForSelector("input[value=\"is between\"]");
qSeleniumLib.waitForSelector("input[value=\"1701\"]");
qSeleniumLib.waitForSelector("input[value=\"74656\"]");
//////////////////////////////////////////
// not-equals on a possible-value field //
//////////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
qSeleniumLib.waitForSelector("input[value=\"does not equal\"]");
qSeleniumLib.waitForSelector("input[value=\"St. Louis\"]");
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// click the x to remove a condition from the filter (in the on-screen preview) //
// reload the page first, so filter-panel won't be up (clicking backdrop doesn't seem to be closing it like it should...) //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.highlightElement(qSeleniumLib.waitForSelectorContaining(".advancedQueryString DIV DIV", "1701"));
qSeleniumLib.moveMouseCursorToElement(qSeleniumLib.waitForSelectorContaining(".advancedQueryString DIV DIV", "1701"));
qSeleniumLib.waitForSelector(".advancedQueryPreviewX-0 button").click();
queryScreenLib.assertNoFilterButtonBadge(1);
//////////////////////////////////////
// an IN for a possible-value field //
//////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.IN, 1, 2)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
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");
@ -128,10 +126,10 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
/////////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS))));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
qSeleniumLib.waitForSelector("input[value=\"is after\"]");
qSeleniumLib.waitForSelector("input[value=\"5 days ago\"]");
@ -141,20 +139,41 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("firstName", QCriteriaOperator.STARTS_WITH, "Dar"))
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, ThisOrLastPeriod.this_(ChronoUnit.YEARS))));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(2);
queryScreenLib.clickFilterButton();
queryScreenLib.clickFilterBuilderButton();
qSeleniumLib.waitForSelector("input[value=\"is at or before\"]");
qSeleniumLib.waitForSelector("input[value=\"start of this year\"]");
qSeleniumLib.waitForSelector("input[value=\"starts with\"]");
qSeleniumLib.waitForSelector("input[value=\"Dar\"]");
/////////////////////////////////////////////////
// replace the homeCityId possible-value route //
/////////////////////////////////////////////////
qSeleniumJavalin.stop();
qSeleniumJavalin.clearRoutes();
addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin.withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId=1.json");
qSeleniumJavalin.restart();
//////////////////////////////////////////
// not-equals on a possible-value field //
//////////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterBuilderButton();
qSeleniumLib.waitForSelector("input[value=\"does not equal\"]");
qSeleniumLib.waitForSelector("input[value=\"St. Louis\"]");
////////////////
// remove one //
////////////////
qSeleniumLib.waitForSelectorContaining(".MuiIcon-root", "close").click();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickAdvancedFilterClearIcon();
queryScreenLib.assertNoFilterButtonBadge(1);
// qSeleniumLib.waitForever();
}

View File

@ -19,7 +19,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query;
import java.net.URLEncoder;
@ -55,7 +55,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
.withRouteToFile("/data/person/query", "data/person/index.json")
.withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json")
.withRouteToFile("/data/person/variants", "data/person/variants.json")
.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json");
.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json");
}
@ -73,9 +73,9 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
////////////////////////////////////////
String filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonBadge("annualSalary");
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("annualSalary");
queryScreenLib.clickQuickFilterButton("annualSalary");
qSeleniumLib.waitForSelector("input[value=\"is not empty\"]");
@ -84,9 +84,9 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
///////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonBadge("annualSalary");
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("annualSalary");
queryScreenLib.clickQuickFilterButton("annualSalary");
qSeleniumLib.waitForSelector("input[value=\"is between\"]");
qSeleniumLib.waitForSelector("input[value=\"1701\"]");
@ -97,9 +97,9 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
//////////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonBadge("homeCityId");
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("homeCityId");
queryScreenLib.clickQuickFilterButton("homeCityId");
qSeleniumLib.waitForSelector("input[value=\"does not equal\"]");
qSeleniumLib.waitForSelector("input[value=\"St. Louis\"]");
@ -109,9 +109,9 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
//////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.IN, 1, 2)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonBadge("homeCityId");
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("homeCityId");
queryScreenLib.clickQuickFilterButton("homeCityId");
qSeleniumLib.waitForSelector("input[value=\"is any of\"]");
qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "St. Louis");
@ -122,9 +122,9 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
/////////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS))));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonBadge("createDate");
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("createDate");
queryScreenLib.clickQuickFilterButton("createDate");
qSeleniumLib.waitForSelector("input[value=\"is after\"]");
qSeleniumLib.waitForSelector("input[value=\"5 days ago\"]");
@ -135,10 +135,10 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("firstName", QCriteriaOperator.STARTS_WITH, "Dar"))
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, ThisOrLastPeriod.this_(ChronoUnit.YEARS))));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonBadge("firstName");
queryScreenLib.assertQuickFilterButtonBadge("createDate");
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("firstName");
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("createDate");
queryScreenLib.clickQuickFilterButton("createDate");
qSeleniumLib.waitForSelector("input[value=\"is at or before\"]");
qSeleniumLib.waitForSelector("input[value=\"start of this year\"]");
@ -147,11 +147,15 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
qSeleniumLib.waitForSelector("input[value=\"starts with\"]");
qSeleniumLib.waitForSelector("input[value=\"Dar\"]");
////////////////
// remove one //
////////////////
// todo! qSeleniumLib.waitForSelectorContaining(".MuiIcon-root", "close").click();
// todo! assertQuickFilterButtonBadge(1);
////////////////////////////////
// remove one, then the other //
////////////////////////////////
qSeleniumLib.clickBackdrop();
queryScreenLib.clickQuickFilterClearIcon("createDate");
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("firstName");
queryScreenLib.assertQuickFilterButtonDoesNotIndicateActiveFilter("createDate");
queryScreenLib.clickQuickFilterClearIcon("firstName");
queryScreenLib.assertQuickFilterButtonDoesNotIndicateActiveFilter("firstName");
// qSeleniumLib.waitForever();
}

View File

@ -0,0 +1,276 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Test for the record query screen
*******************************************************************************/
public class QueryScreenTest extends QBaseSeleniumTest
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
{
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/person/query", "data/person/index.json")
.withRouteToFile("/data/person/variants", "data/person/variants.json")
.withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json")
.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testBuildQueryQueryAndClearFilters()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.gotoAdvancedMode();
queryScreenLib.clickFilterBuilderButton();
/////////////////////////////////////////////////////////////////////
// open the filter window, enter a value, wait for query to re-run //
/////////////////////////////////////////////////////////////////////
qSeleniumJavalin.beginCapture();
queryScreenLib.addAdvancedQueryFilterInput(0, "Id", "equals", "1", null);
///////////////////////////////////////////////////////////////////
// assert that query & count both have the expected filter value //
///////////////////////////////////////////////////////////////////
String idEquals1FilterSubstring = """
{"fieldName":"id","operator":"EQUALS","values":["1"]}""";
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/count", idEquals1FilterSubstring);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", idEquals1FilterSubstring);
qSeleniumJavalin.endCapture();
///////////////////////////////////////
// click away from the filter window //
///////////////////////////////////////
qSeleniumLib.waitForSeconds(1); // todo grr.
qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER).click();
queryScreenLib.assertFilterButtonBadge(1);
///////////////////////////////////////////////////////////////////
// click the 'x' clear icon, then yes, then expect another query //
///////////////////////////////////////////////////////////////////
qSeleniumJavalin.beginCapture();
queryScreenLib.clickAdvancedFilterClearIcon();
////////////////////////////////////////////////////////////////////
// assert that query & count both no longer have the filter value //
////////////////////////////////////////////////////////////////////
CapturedContext capturedCount = qSeleniumJavalin.waitForCapturedPath("/data/person/count");
CapturedContext capturedQuery = qSeleniumJavalin.waitForCapturedPath("/data/person/query");
assertThat(capturedCount).extracting("body").asString().doesNotContain(idEquals1FilterSubstring);
assertThat(capturedQuery).extracting("body").asString().doesNotContain(idEquals1FilterSubstring);
qSeleniumJavalin.endCapture();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testMultiCriteriaQueryWithOr()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.gotoAdvancedMode();
queryScreenLib.clickFilterBuilderButton();
qSeleniumJavalin.beginCapture();
queryScreenLib.addAdvancedQueryFilterInput(0, "First Name", "contains", "Dar", "Or");
queryScreenLib.addAdvancedQueryFilterInput(1, "First Name", "contains", "Jam", "Or");
String expectedFilterContents0 = """
{"fieldName":"firstName","operator":"CONTAINS","values":["Dar"]}""";
String expectedFilterContents1 = """
{"fieldName":"firstName","operator":"CONTAINS","values":["Jam"]}""";
String expectedFilterContents2 = """
"booleanOperator":"OR\"""";
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectedFilterContents0);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectedFilterContents1);
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectedFilterContents2);
qSeleniumJavalin.endCapture();
}
/*******************************************************************************
**
*******************************************************************************/
@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();
}
}

View File

@ -19,19 +19,24 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebElement;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Test for Saved Filters functionality on the Query screen.
** Test for Saved View functionality on the Query screen.
*******************************************************************************/
public class SavedFiltersTest extends QBaseSeleniumTest
public class SavedViewsTest extends QBaseSeleniumTest
{
/*******************************************************************************
@ -66,10 +71,9 @@ public class SavedFiltersTest extends QBaseSeleniumTest
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.gotoAdvancedMode();
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
qSeleniumLib.waitForSelectorContaining("BUTTON", "Saved Filters").click();
qSeleniumLib.waitForSelectorContaining("BUTTON", "Views").click();
qSeleniumLib.waitForSelectorContaining("LI", "Some People");
////////////////////////////////////////
@ -78,47 +82,48 @@ public class SavedFiltersTest extends QBaseSeleniumTest
qSeleniumJavalin.stop();
qSeleniumJavalin.clearRoutes();
addStandardRoutesForThisTest(qSeleniumJavalin);
qSeleniumJavalin.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init-id=2.json");
qSeleniumJavalin.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init-id=2.json");
qSeleniumJavalin.restart();
///////////////////////////////////////////////////////
// go to a specific filter - assert that it's loaded //
///////////////////////////////////////////////////////
/////////////////////////////////////////////////////
// go to a specific view - assert that it's loaded //
/////////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("LI", "Some People").click();
qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedFilter/2"));
qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People");
qSeleniumLib.waitForCondition("Current URL should have view id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2"));
queryScreenLib.assertSavedViewNameOnScreen("Some People");
//////////////////////////////
// click into a view screen //
//////////////////////////////
qSeleniumLib.waitForSeconds(1); // wait for the filters menu to fully disappear? if this doesn't work, try a different word to look for...
qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click();
queryScreenLib.waitForDataGridCellContaining("jdoe@kingsrook.com").click();
qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe");
/////////////////////////////////////////////////////
// take breadcrumb back to table query //
// assert the previously selected filter is loaded //
/////////////////////////////////////////////////////
///////////////////////////////////////////////////
// take breadcrumb back to table query //
// assert the previously selected View is loaded //
///////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("A", "Person").click();
qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedFilter/2"));
qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People");
qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1");
qSeleniumLib.waitForCondition("Current URL should have View id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2"));
queryScreenLib.assertSavedViewNameOnScreen("Some People");
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("firstName");
//////////////////////
// modify the query //
//////////////////////
/* todo - right now - this is changed - but - working through it with Views story... revisit before merge!
queryScreenLib.clickFilterButton();
queryScreenLib.addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or");
qSeleniumLib.waitForSelectorContaining("H3", "Person").click();
qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People")
.findElement(By.cssSelector("CIRCLE"));
qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "2");
queryScreenLib.clickQuickFilterButton("lastName");
WebElement valueInput = qSeleniumLib.waitForSelector(".filterValuesColumn INPUT");
valueInput.click();
valueInput.sendKeys("Kelkhoff");
qSeleniumLib.waitForMillis(100);
qSeleniumLib.clickBackdrop();
qSeleniumLib.waitForSelectorContaining("DIV", "Unsaved Changes");
//////////////////////////////
// click into a view screen //
//////////////////////////////
qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click();
queryScreenLib.waitForDataGridCellContaining("jdoe@kingsrook.com").click();
qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe");
///////////////////////////////////////////////////////////////////////////////
@ -127,37 +132,26 @@ public class SavedFiltersTest extends QBaseSeleniumTest
///////////////////////////////////////////////////////////////////////////////
qSeleniumJavalin.beginCapture();
qSeleniumLib.waitForSelectorContaining("A", "Person").click();
qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedFilter/2"));
qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People")
.findElement(By.cssSelector("CIRCLE"));
qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "2");
qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2"));
queryScreenLib.assertSavedViewNameOnScreen("Some People");
qSeleniumLib.waitForSelectorContaining("DIV", "Unsaved Changes");
CapturedContext capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query");
assertTrue(capturedContext.getBody().contains("Jam"));
assertTrue(capturedContext.getBody().contains("Kelkhoff"));
qSeleniumJavalin.endCapture();
////////////////////////////////////////////////////
// navigate to the table with a filter in the URL //
////////////////////////////////////////////////////
//////////////////////////////////////////////////
// navigate to the table with a View in the URL //
//////////////////////////////////////////////////
String filter = """
{
"criteria":
[
{
"fieldName": "id",
"operator": "LESS_THAN",
"values": [10]
}
]
}
{"criteria":[{"fieldName":"id", "operator":"LESS_THAN", "values":[10]}]}
""".replace('\n', ' ').replaceAll(" ", "");
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8), "Person");
qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1");
qSeleniumLib.waitForSelectorContainingToNotExist("DIV", "Current Filter");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8), "Person");
qSeleniumLib.waitForSelectorContaining("BUTTON", "Save View As");
//////////////////////////////
// click into a view screen //
//////////////////////////////
qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click();
queryScreenLib.waitForDataGridCellContaining("jdoe@kingsrook.com").click();
qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe");
/////////////////////////////////////////////////////////////////////////////////
@ -166,12 +160,11 @@ public class SavedFiltersTest extends QBaseSeleniumTest
/////////////////////////////////////////////////////////////////////////////////
qSeleniumJavalin.beginCapture();
qSeleniumLib.waitForSelectorContaining("A", "Person").click();
qSeleniumLib.waitForCondition("Current URL should not have filter id", () -> !driver.getCurrentUrl().endsWith("/person/savedFilter/2"));
qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1");
qSeleniumLib.waitForCondition("Current URL should not have filter id", () -> !driver.getCurrentUrl().endsWith("/person/savedView/2"));
qSeleniumLib.waitForSelectorContaining("BUTTON", "Save View As");
capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query");
assertTrue(capturedContext.getBody().matches("(?s).*id.*LESS_THAN.*10.*"));
qSeleniumJavalin.endCapture();
*/
}
}

View File

@ -0,0 +1,8 @@
{
"options": [
{
"id": 1,
"label": "St. Louis"
}
]
}

View File

@ -223,21 +223,21 @@
"label": "Sleep Interactive",
"isHidden": false
},
"querySavedFilter": {
"name": "querySavedFilter",
"label": "Query Saved Filter",
"querySavedView": {
"name": "querySavedView",
"label": "Query Saved View",
"isHidden": false,
"hasPermission": true
},
"storeSavedFilter": {
"name": "storeSavedFilter",
"label": "Store Saved Filter",
"storeSavedView": {
"name": "storeSavedView",
"label": "Store Saved View",
"isHidden": false,
"hasPermission": true
},
"deleteSavedFilter": {
"name": "deleteSavedFilter",
"label": "Delete Saved Filter",
"deleteSavedView": {
"name": "deleteSavedView",
"label": "Delete Saved View",
"isHidden": false,
"hasPermission": true
},

View File

@ -1,16 +1,16 @@
{
"values": {
"_qStepTimeoutMillis": "60000",
"savedFilterList": [
"savedViewList": [
{
"tableName": "savedFilter",
"tableName": "savedView",
"values": {
"label": "Some People",
"id": 2,
"createDate": "2023-02-20T18:40:58Z",
"modifyDate": "2023-02-20T18:40:58Z",
"tableName": "person",
"filterJson": "{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}",
"viewJson": "{\"queryFilter\":{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}}",
"userId": "darin.kelkhoff@kingsrook.com"
}
}

View File

@ -1,28 +1,28 @@
{
"values": {
"_qStepTimeoutMillis": "60000",
"savedFilterList": [
"savedViewList": [
{
"tableName": "savedFilter",
"tableName": "savedView",
"values": {
"label": "All People",
"id": 1,
"createDate": "2023-02-20T18:39:11Z",
"modifyDate": "2023-02-20T18:39:11Z",
"tableName": "person",
"filterJson": "{\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}",
"viewJson": "{\"queryFilter\":{\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}}",
"userId": "darin.kelkhoff@kingsrook.com"
}
},
{
"tableName": "savedFilter",
"tableName": "savedView",
"values": {
"label": "Some People",
"id": 2,
"createDate": "2023-02-20T18:40:58Z",
"modifyDate": "2023-02-20T18:40:58Z",
"tableName": "person",
"filterJson": "{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}",
"viewJson": "{\"queryFilter\":{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}}",
"userId": "darin.kelkhoff@kingsrook.com"
}
}