mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-17 12:50:43 +00:00
Merge pull request #42 from Kingsrook/feature/CE-798-quick-filters
Feature/ce 798 quick filters
This commit is contained in:
2
pom.xml
2
pom.xml
@ -66,7 +66,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
<version>0.17.0-SNAPSHOT</version>
|
||||
<version>feature-CE-798-quick-filters-20240123.205854-1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
|
@ -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),
|
||||
|
@ -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: {},
|
||||
|
@ -1,10 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Placeholder class, because maven really wants some source under src/main?
|
||||
*******************************************************************************/
|
||||
public class Placeholder
|
||||
{
|
||||
public void f()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
@ -22,17 +22,23 @@
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** table-level meta-data for this module (handled as QSupplementalTableMetaData)
|
||||
*******************************************************************************/
|
||||
public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
{
|
||||
private List<List<String>> gotoFieldNames;
|
||||
|
||||
private List<String> defaultQuickFilterFieldNames;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -86,4 +92,73 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, QTableMetaData tableMetaData, QInstanceValidator qInstanceValidator)
|
||||
{
|
||||
super.validate(qInstance, tableMetaData, qInstanceValidator);
|
||||
|
||||
String prefix = "MaterialDashboardTableMetaData supplementalTableMetaData for table [" + tableMetaData.getName() + "] ";
|
||||
|
||||
for(List<String> gotoFieldNameSubList : CollectionUtils.nonNullList(gotoFieldNames))
|
||||
{
|
||||
qInstanceValidator.assertCondition(!gotoFieldNameSubList.isEmpty(), prefix + "has an empty gotoFieldNames list");
|
||||
validateListOfFieldNames(tableMetaData, gotoFieldNameSubList, qInstanceValidator, prefix + "gotoFieldNames: ");
|
||||
}
|
||||
validateListOfFieldNames(tableMetaData, defaultQuickFilterFieldNames, qInstanceValidator, prefix + "defaultQuickFilterFieldNames: ");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void validateListOfFieldNames(QTableMetaData tableMetaData, List<String> fieldNames, QInstanceValidator qInstanceValidator, String prefix)
|
||||
{
|
||||
Set<String> usedNames = new HashSet<>();
|
||||
for(String fieldName : CollectionUtils.nonNullList(fieldNames))
|
||||
{
|
||||
if(qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldName), prefix + " unrecognized field name: " + fieldName))
|
||||
{
|
||||
qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + " has a duplicated field name: " + fieldName);
|
||||
usedNames.add(fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultQuickFilterFieldNames
|
||||
*******************************************************************************/
|
||||
public List<String> getDefaultQuickFilterFieldNames()
|
||||
{
|
||||
return (this.defaultQuickFilterFieldNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for defaultQuickFilterFieldNames
|
||||
*******************************************************************************/
|
||||
public void setDefaultQuickFilterFieldNames(List<String> defaultQuickFilterFieldNames)
|
||||
{
|
||||
this.defaultQuickFilterFieldNames = defaultQuickFilterFieldNames;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultQuickFilterFieldNames
|
||||
*******************************************************************************/
|
||||
public MaterialDashboardTableMetaData withDefaultQuickFilterFieldNames(List<String> defaultQuickFilterFieldNames)
|
||||
{
|
||||
this.defaultQuickFilterFieldNames = defaultQuickFilterFieldNames;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ interface QCreateNewButtonProps
|
||||
export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Box ml={3} mr={2} 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 filters
|
||||
<Icon>keyboard_arrow_down</Icon>
|
||||
</MDButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface QCancelButtonProps
|
||||
{
|
||||
onClickHandler: any;
|
||||
|
@ -51,6 +51,7 @@ interface Props
|
||||
bulkEditSwitchChangeHandler?: any;
|
||||
otherValues?: Map<string, any>;
|
||||
variant: "standard" | "outlined";
|
||||
initiallyOpen: boolean;
|
||||
}
|
||||
|
||||
DynamicSelect.defaultProps = {
|
||||
@ -66,6 +67,7 @@ DynamicSelect.defaultProps = {
|
||||
bulkEditMode: false,
|
||||
otherValues: new Map<string, any>(),
|
||||
variant: "outlined",
|
||||
initiallyOpen: false,
|
||||
bulkEditSwitchChangeHandler: () =>
|
||||
{
|
||||
},
|
||||
@ -73,12 +75,13 @@ DynamicSelect.defaultProps = {
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant}: Props)
|
||||
function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen}: Props)
|
||||
{
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(initiallyOpen);
|
||||
const [options, setOptions] = useState<readonly QPossibleValue[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState(null);
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const [otherValuesWhenResultsWereLoaded, setOtherValuesWhenResultsWereLoaded] = useState(JSON.stringify(Object.fromEntries((otherValues))))
|
||||
const {inputBorderColor} = colors;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -113,7 +116,14 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
|
||||
{
|
||||
// console.log("First render, so not searching...");
|
||||
setFirstRender(false);
|
||||
return;
|
||||
|
||||
/*
|
||||
if(!initiallyOpen)
|
||||
{
|
||||
console.log("returning because not initially open?");
|
||||
return;
|
||||
}
|
||||
*/
|
||||
}
|
||||
// console.log("Use effect for searchTerm - searching!");
|
||||
|
||||
@ -146,6 +156,24 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe
|
||||
};
|
||||
}, [ searchTerm ]);
|
||||
|
||||
// todo - finish... call it in onOpen?
|
||||
const reloadIfOtherValuesAreChanged = () =>
|
||||
{
|
||||
if(JSON.stringify(Object.fromEntries(otherValues)) != otherValuesWhenResultsWereLoaded)
|
||||
{
|
||||
(async () =>
|
||||
{
|
||||
setLoading(true);
|
||||
setOptions([]);
|
||||
console.log("Refreshing possible values...");
|
||||
const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, fieldName, searchTerm ?? "", null, otherValues);
|
||||
setLoading(false);
|
||||
setOptions([ ...results ]);
|
||||
setOtherValuesWhenResultsWereLoaded(JSON.stringify(Object.fromEntries(otherValues)));
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
const inputChanged = (event: React.SyntheticEvent, value: string, reason: string) =>
|
||||
{
|
||||
// console.log(`input changed. Reason: ${reason}, setting search term to ${value}`);
|
||||
@ -293,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)
|
||||
{
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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",
|
||||
|
158
src/qqq/components/misc/FieldAutoComplete.tsx
Normal file
158
src/qqq/components/misc/FieldAutoComplete.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2023. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import React, {ReactNode} from "react";
|
||||
|
||||
interface FieldAutoCompleteProps
|
||||
{
|
||||
id: string;
|
||||
metaData: QInstance;
|
||||
tableMetaData: QTableMetaData;
|
||||
handleFieldChange: (event: any, newValue: any, reason: string) => void;
|
||||
defaultValue?: {field: QFieldMetaData, table: QTableMetaData, fieldName: string};
|
||||
autoFocus?: boolean;
|
||||
forceOpen?: boolean;
|
||||
hiddenFieldNames?: string[];
|
||||
}
|
||||
|
||||
FieldAutoComplete.defaultProps =
|
||||
{
|
||||
defaultValue: null,
|
||||
autoFocus: false,
|
||||
forceOpen: null,
|
||||
hiddenFieldNames: []
|
||||
};
|
||||
|
||||
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[])
|
||||
{
|
||||
const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
for (let i = 0; i < sortedFields.length; i++)
|
||||
{
|
||||
const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name;
|
||||
|
||||
if(hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName});
|
||||
}
|
||||
}
|
||||
|
||||
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element
|
||||
{
|
||||
const fieldOptions: any[] = [];
|
||||
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames);
|
||||
let fieldsGroupBy = null;
|
||||
|
||||
if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0)
|
||||
{
|
||||
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
|
||||
{
|
||||
const exposedJoin = tableMetaData.exposedJoins[i];
|
||||
if (metaData.tables.has(exposedJoin.joinTable.name))
|
||||
{
|
||||
fieldsGroupBy = (option: any) => `${option.table.label} fields`;
|
||||
makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getFieldOptionLabel(option: any)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// note - we're using renderFieldOption below for the actual select-box options, which //
|
||||
// are always jut field label (as they are under groupings that show their table name) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (option && option.field && option.table)
|
||||
{
|
||||
if (option.table.name == tableMetaData.name)
|
||||
{
|
||||
return (option.field.label);
|
||||
}
|
||||
else
|
||||
{
|
||||
return (option.table.label + ": " + option.field.label);
|
||||
}
|
||||
}
|
||||
|
||||
return ("");
|
||||
}
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// for options, we only want the field label (contrast with what we show in the input box, //
|
||||
// which comes out of getFieldOptionLabel, which is the table-label prefix for join fields) //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
function renderFieldOption(props: React.HTMLAttributes<HTMLLIElement>, option: any, state: AutocompleteRenderOptionState): ReactNode
|
||||
{
|
||||
let label = "";
|
||||
if (option && option.field)
|
||||
{
|
||||
label = (option.field.label);
|
||||
}
|
||||
|
||||
return (<li {...props}>{label}</li>);
|
||||
}
|
||||
|
||||
|
||||
function isFieldOptionEqual(option: any, value: any)
|
||||
{
|
||||
return option.fieldName === value.fieldName;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// seems like, if we always add the open attribute, then if its false or null, then the autocomplete //
|
||||
// doesn't open at all... so, only add the attribute at all, if forceOpen is true //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const alsoOpen: {[key: string]: any} = {}
|
||||
if(forceOpen)
|
||||
{
|
||||
alsoOpen["open"] = forceOpen;
|
||||
}
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
id={id}
|
||||
renderInput={(params) => (<TextField {...params} autoFocus={autoFocus} label={"Field"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
|
||||
// @ts-ignore
|
||||
defaultValue={defaultValue}
|
||||
options={fieldOptions}
|
||||
onChange={handleFieldChange}
|
||||
isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)}
|
||||
groupBy={fieldsGroupBy}
|
||||
getOptionLabel={(option) => getFieldOptionLabel(option)}
|
||||
renderOption={(props, option, state) => renderFieldOption(props, option, state)}
|
||||
autoSelect={true}
|
||||
autoHighlight={true}
|
||||
slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
|
||||
{...alsoOpen}
|
||||
/>
|
||||
|
||||
);
|
||||
}
|
@ -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) => (
|
||||
|
@ -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:
|
||||
<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;
|
682
src/qqq/components/misc/SavedViews.tsx
Normal file
682
src/qqq/components/misc/SavedViews.tsx
Normal 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…</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…</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;
|
837
src/qqq/components/query/BasicAndAdvancedQueryControls.tsx
Normal file
837
src/qqq/components/query/BasicAndAdvancedQueryControls.tsx
Normal file
@ -0,0 +1,837 @@
|
||||
/*
|
||||
* 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 {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {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} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
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 Tooltip from "@mui/material/Tooltip";
|
||||
import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro";
|
||||
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 {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
|
||||
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";
|
||||
|
||||
interface BasicAndAdvancedQueryControlsProps
|
||||
{
|
||||
metaData: QInstance;
|
||||
tableMetaData: QTableMetaData;
|
||||
|
||||
savedViewsComponent: JSX.Element;
|
||||
columnMenuComponent: JSX.Element;
|
||||
|
||||
quickFilterFieldNames: string[];
|
||||
setQuickFilterFieldNames: (names: string[]) => void;
|
||||
|
||||
queryFilter: QQueryFilter;
|
||||
setQueryFilter: (queryFilter: QQueryFilter) => void;
|
||||
|
||||
gridApiRef: React.MutableRefObject<GridApiPro>;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this prop is used as a way to recognize changes in the query filter internal structure, //
|
||||
// since the queryFilter object (reference) doesn't get updated //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
queryFilterJSON: string;
|
||||
|
||||
mode: string;
|
||||
setMode: (mode: string) => void;
|
||||
}
|
||||
|
||||
let debounceTimeout: string | number | NodeJS.Timeout;
|
||||
|
||||
/*******************************************************************************
|
||||
** Component to provide the basic & advanced query-filter controls for the
|
||||
** RecordQueryOrig screen.
|
||||
**
|
||||
** 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, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props
|
||||
|
||||
/////////////////////
|
||||
// state variables //
|
||||
/////////////////////
|
||||
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 //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
useImperativeHandle(ref, () =>
|
||||
{
|
||||
return {
|
||||
ensureAllFilterCriteriaAreActiveQuickFilters(currentFilter: QQueryFilter, reason: string)
|
||||
{
|
||||
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, currentFilter, reason);
|
||||
},
|
||||
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.
|
||||
*******************************************************************************/
|
||||
function getDefaultOperatorForField(field: QFieldMetaData)
|
||||
{
|
||||
// todo - sometimes i want contains instead of equals on strings (client.name, for example...)
|
||||
let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS;
|
||||
if (field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE)
|
||||
{
|
||||
defaultOperator = QCriteriaOperator.GREATER_THAN;
|
||||
}
|
||||
else if (field?.type == QFieldType.BOOLEAN)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// for booleans, if we set a default, since none of them have values, then they are ALWAYS selected, which isn't what we want. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
defaultOperator = null;
|
||||
}
|
||||
return defaultOperator;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Callback passed into the QuickFilter component, to update the criteria
|
||||
** after user makes changes to it or to clear it out.
|
||||
*******************************************************************************/
|
||||
const updateQuickCriteria = (newCriteria: QFilterCriteria, needDebounce = false, doClearCriteria = false) =>
|
||||
{
|
||||
let found = false;
|
||||
let foundIndex = null;
|
||||
for (let i = 0; i < queryFilter?.criteria?.length; i++)
|
||||
{
|
||||
if(queryFilter.criteria[i].fieldName == newCriteria.fieldName)
|
||||
{
|
||||
queryFilter.criteria[i] = newCriteria;
|
||||
found = true;
|
||||
foundIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(doClearCriteria)
|
||||
{
|
||||
if(found)
|
||||
{
|
||||
queryFilter.criteria.splice(foundIndex, 1);
|
||||
setQueryFilter(queryFilter);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if(!found)
|
||||
{
|
||||
if(!queryFilter.criteria)
|
||||
{
|
||||
queryFilter.criteria = [];
|
||||
}
|
||||
queryFilter.criteria.push(newCriteria);
|
||||
found = true;
|
||||
}
|
||||
|
||||
if(found)
|
||||
{
|
||||
clearTimeout(debounceTimeout)
|
||||
debounceTimeout = setTimeout(() =>
|
||||
{
|
||||
setQueryFilter(queryFilter);
|
||||
}, needDebounce ? 500 : 1);
|
||||
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the QFilterCriteriaWithId object to pass in to the QuickFilter component
|
||||
** for a given field name.
|
||||
*******************************************************************************/
|
||||
const getQuickCriteriaParam = (fieldName: string): QFilterCriteriaWithId | null | "tooComplex" =>
|
||||
{
|
||||
const matches: QFilterCriteriaWithId[] = [];
|
||||
for (let i = 0; i < queryFilter?.criteria?.length; i++)
|
||||
{
|
||||
if(queryFilter.criteria[i].fieldName == fieldName)
|
||||
{
|
||||
matches.push(queryFilter.criteria[i] as QFilterCriteriaWithId);
|
||||
}
|
||||
}
|
||||
|
||||
if(matches.length == 0)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
else if(matches.length == 1)
|
||||
{
|
||||
return (matches[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
return "tooComplex";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Event handler for QuickFilter component, to remove a quick filter field from
|
||||
** the screen.
|
||||
*******************************************************************************/
|
||||
const handleRemoveQuickFilterField = (fieldName: string): void =>
|
||||
{
|
||||
const index = quickFilterFieldNames.indexOf(fieldName)
|
||||
if(index >= 0)
|
||||
{
|
||||
//////////////////////////////////////
|
||||
// remove this field from the query //
|
||||
//////////////////////////////////////
|
||||
const criteria = new QFilterCriteria(fieldName, null, []);
|
||||
updateQuickCriteria(criteria, false, true);
|
||||
|
||||
quickFilterFieldNames.splice(index, 1);
|
||||
setQuickFilterFieldNames(quickFilterFieldNames);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Event handler for button that opens the add-quick-filter menu
|
||||
*******************************************************************************/
|
||||
const openAddQuickFilterMenu = (event: any) =>
|
||||
{
|
||||
setAddQuickFilterMenu(event.currentTarget);
|
||||
setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Handle closing the add-quick-filter menu
|
||||
*******************************************************************************/
|
||||
const closeAddQuickFilterMenu = () =>
|
||||
{
|
||||
setAddQuickFilterMenu(null);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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" | "activatedView" | string) =>
|
||||
{
|
||||
console.log(`Adding quick filter field as: ${JSON.stringify(newValue)}`);
|
||||
if (reason == "blur")
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// this keeps a click out of the menu from selecting the option //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldName = newValue ? newValue.fieldName : null;
|
||||
if (fieldName)
|
||||
{
|
||||
if(defaultQuickFilterFieldNameMap[fieldName])
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (quickFilterFieldNames.indexOf(fieldName) == -1)
|
||||
{
|
||||
/////////////////////////////////
|
||||
// add the field if we need to //
|
||||
/////////////////////////////////
|
||||
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" && reason != "activatedView")
|
||||
{
|
||||
setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5);
|
||||
}
|
||||
}
|
||||
else if(reason == "columnMenu")
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if field was already on-screen, but user clicked an option from the columnMenu, then open the quick-filter field //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5);
|
||||
}
|
||||
|
||||
closeAddQuickFilterMenu();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
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>) =>
|
||||
{
|
||||
gridApiRef.current.showFilterPanel();
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for the clear-filters modal
|
||||
*******************************************************************************/
|
||||
const handleClearFiltersAction = (event: React.KeyboardEvent<HTMLDivElement>, isYesButton: boolean = false) =>
|
||||
{
|
||||
if (isYesButton || event.key == "Enter")
|
||||
{
|
||||
setShowClearFiltersWarning(false);
|
||||
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.
|
||||
*******************************************************************************/
|
||||
const queryToAdvancedString = () =>
|
||||
{
|
||||
if(queryFilter == null || !queryFilter.criteria)
|
||||
{
|
||||
return (<span></span>);
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
|
||||
return (
|
||||
<Box display="flex" flexWrap="wrap" fontSize="0.875rem">
|
||||
{queryFilter.criteria.map((criteria, i) =>
|
||||
{
|
||||
const {criteriaIsValid} = validateCriteria(criteria, null);
|
||||
if(criteriaIsValid)
|
||||
{
|
||||
counter++;
|
||||
|
||||
return (
|
||||
<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} </span> : <span/>}
|
||||
{FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)}
|
||||
{mouseOverElement == `queryPreview-${i}` && <span className={`advancedQueryPreviewX-${counter - 1}`}><XIcon position="forAdvancedQueryPreview" onClick={() => removeCriteriaByIndex(i)} /></span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
return (<span />);
|
||||
}
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for toggling between modes - basic & advanced.
|
||||
*******************************************************************************/
|
||||
const modeToggleClicked = (newValue: string | null) =>
|
||||
{
|
||||
if (newValue)
|
||||
{
|
||||
if(newValue == "basic")
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// we're always allowed to go to advanced - //
|
||||
// but if we're trying to go to basic, make sure the filter isn't too complex //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
|
||||
if (!canFilterWorkAsBasic)
|
||||
{
|
||||
console.log("Query cannot work as basic - so - not allowing toggle to basic.")
|
||||
return;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// when going to basic, make sure all fields in the current query are active as quick-filters //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (queryFilter && queryFilter.criteria)
|
||||
{
|
||||
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "modeToggleClicked", "basic");
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
// note - this is a callback to the parent - as it is responsible for this state... //
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
setMode(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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, newMode?: string) =>
|
||||
{
|
||||
if(!tableMetaData || !queryFilter)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
|
||||
if (!canFilterWorkAsBasic)
|
||||
{
|
||||
console.log("query is too complex for basic - so - switching to advanced");
|
||||
modeToggleClicked("advanced");
|
||||
forceUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const modeToUse = newMode ?? mode;
|
||||
if(modeToUse == "basic")
|
||||
{
|
||||
for (let i = 0; i < queryFilter?.criteria?.length; i++)
|
||||
{
|
||||
const criteria = queryFilter.criteria[i];
|
||||
if (criteria && criteria.fieldName)
|
||||
{
|
||||
addQuickFilterField(criteria, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** count how many valid criteria are in the query - for showing badge
|
||||
*******************************************************************************/
|
||||
const countValidCriteria = (queryFilter: QQueryFilter): number =>
|
||||
{
|
||||
let count = 0;
|
||||
for (let i = 0; i < queryFilter?.criteria?.length; i++)
|
||||
{
|
||||
const {criteriaIsValid} = validateCriteria(queryFilter.criteria[i], null);
|
||||
if(criteriaIsValid)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
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></>
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this is being used as a version of like forcing that we get re-rendered if the query filter changes... //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const [lastIndex, setLastIndex] = useState(queryFilterJSON);
|
||||
if(queryFilterJSON != lastIndex)
|
||||
{
|
||||
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "defaultFilterLoaded");
|
||||
setLastIndex(queryFilterJSON);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// set some status flags based on current filter //
|
||||
///////////////////////////////////////////////////
|
||||
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:
|
||||
<ul style={{marginLeft: "1rem"}}>
|
||||
{reasonsWhyItCannot.map((reason, i) => <li key={i}>{reason}</li>)}
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
|
||||
const borderGray = colors.grayLines.main;
|
||||
|
||||
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}>
|
||||
<>
|
||||
{
|
||||
tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) =>
|
||||
{
|
||||
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
let defaultOperator = getDefaultOperatorForField(field);
|
||||
|
||||
return (<QuickFilter
|
||||
key={fieldName}
|
||||
fullFieldName={fieldName}
|
||||
tableMetaData={tableMetaData}
|
||||
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" &&
|
||||
<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">
|
||||
<>
|
||||
<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>
|
||||
}
|
||||
</>
|
||||
</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
|
||||
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>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
122
src/qqq/components/query/CustomPaginationComponent.tsx
Normal file
122
src/qqq/components/query/CustomPaginationComponent.tsx
Normal 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">
|
||||
({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}
|
||||
/>
|
||||
);
|
||||
|
||||
}
|
131
src/qqq/components/query/ExportMenuItem.tsx
Normal file
131
src/qqq/components/query/ExportMenuItem.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import {GridColDef, GridExportMenuItemProps} from "@mui/x-data-grid-pro";
|
||||
import React from "react";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
|
||||
interface QExportMenuItemProps extends GridExportMenuItemProps<{}>
|
||||
{
|
||||
tableMetaData: QTableMetaData;
|
||||
totalRecords: number
|
||||
columnsModel: GridColDef[];
|
||||
columnVisibilityModel: { [index: string]: boolean };
|
||||
queryFilter: QQueryFilter;
|
||||
format: string;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Component to serve as an item in the Export menu
|
||||
*******************************************************************************/
|
||||
export default function ExportMenuItem(props: QExportMenuItemProps)
|
||||
{
|
||||
const {format, tableMetaData, totalRecords, columnsModel, columnVisibilityModel, queryFilter, hideMenu} = props;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
disabled={!totalRecords}
|
||||
onClick={() =>
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// build the list of visible fields. note, not doing them in-order (in case //
|
||||
// the user did drag & drop), because column order model isn't right yet //
|
||||
// so just doing them to match columns (which were pKey, then sorted) //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
const visibleFields: string[] = [];
|
||||
columnsModel.forEach((gridColumn) =>
|
||||
{
|
||||
const fieldName = gridColumn.field;
|
||||
if (columnVisibilityModel[fieldName] !== false)
|
||||
{
|
||||
visibleFields.push(fieldName);
|
||||
}
|
||||
});
|
||||
|
||||
//////////////////////////////////////
|
||||
// construct the url for the export //
|
||||
//////////////////////////////////////
|
||||
const dateString = ValueUtils.formatDateTimeForFileName(new Date());
|
||||
const filename = `${tableMetaData.label} Export ${dateString}.${format}`;
|
||||
const url = `/data/${tableMetaData.name}/export/${filename}`;
|
||||
|
||||
const encodedFilterJSON = encodeURIComponent(JSON.stringify(queryFilter));
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
// open a window (tab) with a little page that says the file is being generated. //
|
||||
// then have that page load the url for the export. //
|
||||
// If there's an error, it'll appear in that window. else, the file will download. //
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
const exportWindow = window.open("", "_blank");
|
||||
exportWindow.document.write(`<html lang="en">
|
||||
<head>
|
||||
<style>
|
||||
* { font-family: "SF Pro Display","Roboto","Helvetica","Arial",sans-serif; }
|
||||
</style>
|
||||
<title>${filename}</title>
|
||||
<script>
|
||||
setTimeout(() =>
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// need to encode and decode this value, so set it in the form here, instead of literally below //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
document.getElementById("filter").value = decodeURIComponent("${encodedFilterJSON}");
|
||||
|
||||
document.getElementById("exportForm").submit();
|
||||
}, 1);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
|
||||
<form id="exportForm" method="post" action="${url}" >
|
||||
<input type="hidden" name="fields" value="${visibleFields.join(",")}">
|
||||
<input type="hidden" name="filter" id="filter">
|
||||
</form>
|
||||
</body>
|
||||
</html>`);
|
||||
|
||||
/*
|
||||
// todo - probably better - generate the report in an iframe...
|
||||
// only open question is, giving user immediate feedback, and knowing when the stream has started and/or stopped
|
||||
// maybe a busy-loop that would check iframe's url (e.g., after posting should change, maybe?)
|
||||
const iframe = document.getElementById("exportIFrame");
|
||||
const form = iframe.querySelector("form");
|
||||
form.action = url;
|
||||
form.target = "exportIFrame";
|
||||
(iframe.querySelector("#authorizationInput") as HTMLInputElement).value = qController.getAuthorizationHeaderValue();
|
||||
form.submit();
|
||||
*/
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Hide the export menu after the export //
|
||||
///////////////////////////////////////////
|
||||
hideMenu?.();
|
||||
}}
|
||||
>
|
||||
Export
|
||||
{` ${format.toUpperCase()}`}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
736
src/qqq/components/query/FieldListMenu.tsx
Normal file
736
src/qqq/components/query/FieldListMenu.tsx
Normal 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> <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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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";
|
||||
@ -34,6 +34,7 @@ import Select, {SelectChangeEvent} from "@mui/material/Select/Select";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import React, {ReactNode, SyntheticEvent, useState} from "react";
|
||||
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
|
||||
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
|
||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
||||
|
||||
@ -52,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;
|
||||
@ -177,16 +199,71 @@ interface FilterCriteriaRowProps
|
||||
updateBooleanOperator: (newValue: string) => void;
|
||||
}
|
||||
|
||||
FilterCriteriaRow.defaultProps = {};
|
||||
|
||||
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean)
|
||||
{
|
||||
const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
for (let i = 0; i < sortedFields.length; i++)
|
||||
FilterCriteriaRow.defaultProps =
|
||||
{
|
||||
const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name;
|
||||
fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName});
|
||||
};
|
||||
|
||||
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.";
|
||||
|
||||
function isNotSet(value: any)
|
||||
{
|
||||
return (value === null || value == undefined || String(value).trim() === "");
|
||||
}
|
||||
|
||||
if(!criteria)
|
||||
{
|
||||
criteriaIsValid = false;
|
||||
criteriaStatusTooltip = "This condition is not defined.";
|
||||
return {criteriaIsValid, criteriaStatusTooltip};
|
||||
}
|
||||
|
||||
if (!criteria.fieldName)
|
||||
{
|
||||
criteriaIsValid = false;
|
||||
criteriaStatusTooltip = "You must select a field to begin to define this condition.";
|
||||
}
|
||||
else if (!criteria.operator)
|
||||
{
|
||||
criteriaIsValid = false;
|
||||
criteriaStatusTooltip = "You must select an operator to continue to define this condition.";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK)
|
||||
{
|
||||
//////////////////////////////////
|
||||
// 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]))
|
||||
{
|
||||
criteriaIsValid = false;
|
||||
criteriaStatusTooltip = "You must enter two values to complete the definition of this condition.";
|
||||
}
|
||||
}
|
||||
else if (criteria.operator == QCriteriaOperator.IN || criteria.operator == QCriteriaOperator.NOT_IN)
|
||||
{
|
||||
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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
return {criteriaIsValid, criteriaStatusTooltip};
|
||||
}
|
||||
|
||||
export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator}: FilterCriteriaRowProps): JSX.Element
|
||||
@ -195,27 +272,6 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
const [operatorSelectedValue, setOperatorSelectedValue] = useState(null as OperatorOption);
|
||||
const [operatorInputValue, setOperatorInputValue] = useState("");
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// set up the array of options for the fields Autocomplete //
|
||||
// also, a groupBy function, in case there are exposed joins //
|
||||
///////////////////////////////////////////////////////////////
|
||||
const fieldOptions: any[] = [];
|
||||
makeFieldOptionsForTable(tableMetaData, fieldOptions, false);
|
||||
let fieldsGroupBy = null;
|
||||
|
||||
if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0)
|
||||
{
|
||||
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
|
||||
{
|
||||
const exposedJoin = tableMetaData.exposedJoins[i];
|
||||
if (metaData.tables.has(exposedJoin.joinTable.name))
|
||||
{
|
||||
fieldsGroupBy = (option: any) => `${option.table.label} fields`;
|
||||
makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// set up array of options for operator dropdown //
|
||||
// only call the function to do it if we have a field set //
|
||||
@ -332,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)
|
||||
{
|
||||
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
|
||||
if(requiredValueCount != null && criteria.values.length > requiredValueCount)
|
||||
{
|
||||
criteria.values.splice(requiredValueCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -383,111 +457,19 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
return (false);
|
||||
};
|
||||
|
||||
function isFieldOptionEqual(option: any, value: any)
|
||||
{
|
||||
return option.fieldName === value.fieldName;
|
||||
}
|
||||
|
||||
function getFieldOptionLabel(option: any)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// note - we're using renderFieldOption below for the actual select-box options, which //
|
||||
// are always jut field label (as they are under groupings that show their table name) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(option && option.field && option.table)
|
||||
{
|
||||
if(option.table.name == tableMetaData.name)
|
||||
{
|
||||
return (option.field.label);
|
||||
}
|
||||
else
|
||||
{
|
||||
return (option.table.label + ": " + option.field.label);
|
||||
}
|
||||
}
|
||||
|
||||
return ("");
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// for options, we only want the field label (contrast with what we show in the input box, //
|
||||
// which comes out of getFieldOptionLabel, which is the table-label prefix for join fields) //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
function renderFieldOption(props: React.HTMLAttributes<HTMLLIElement>, option: any, state: AutocompleteRenderOptionState): ReactNode
|
||||
{
|
||||
let label = ""
|
||||
if(option && option.field)
|
||||
{
|
||||
label = (option.field.label);
|
||||
}
|
||||
|
||||
return (<li {...props}>{label}</li>);
|
||||
}
|
||||
|
||||
function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption)
|
||||
{
|
||||
return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues));
|
||||
}
|
||||
|
||||
let criteriaIsValid = true;
|
||||
let criteriaStatusTooltip = "This condition is fully defined and is part of your filter.";
|
||||
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
|
||||
|
||||
function isNotSet(value: any)
|
||||
{
|
||||
return (value === null || value == undefined || String(value).trim() === "");
|
||||
}
|
||||
|
||||
if(!criteria.fieldName)
|
||||
{
|
||||
criteriaIsValid = false;
|
||||
criteriaStatusTooltip = "You must select a field to begin to define this condition.";
|
||||
}
|
||||
else if(!criteria.operator)
|
||||
{
|
||||
criteriaIsValid = false;
|
||||
criteriaStatusTooltip = "You must select an operator to continue to define this condition.";
|
||||
}
|
||||
else
|
||||
{
|
||||
if(operatorSelectedValue)
|
||||
{
|
||||
if (operatorSelectedValue.valueMode == ValueMode.NONE || operatorSelectedValue.implicitValues)
|
||||
{
|
||||
//////////////////////////////////
|
||||
// don't need to look at values //
|
||||
//////////////////////////////////
|
||||
}
|
||||
else if(operatorSelectedValue.valueMode == ValueMode.DOUBLE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE_TIME)
|
||||
{
|
||||
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.";
|
||||
}
|
||||
}
|
||||
else if(operatorSelectedValue.valueMode == ValueMode.MULTI || operatorSelectedValue.valueMode == ValueMode.PVS_MULTI)
|
||||
{
|
||||
if(criteria.values.length < 1 || isNotSet(criteria.values[0]))
|
||||
{
|
||||
criteriaIsValid = false;
|
||||
criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition.";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if(!criteria.values || isNotSet(criteria.values[0]))
|
||||
{
|
||||
criteriaIsValid = false;
|
||||
criteriaStatusTooltip = "You must enter a value to complete the definition of this condition.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const tooltipEnterDelay = 750;
|
||||
|
||||
return (
|
||||
<Box className="filterCriteriaRow" pt={0.5} display="flex" alignItems="flex-end">
|
||||
<Box className="filterCriteriaRow" pt={0.5} display="flex" alignItems="flex-end" pr={0.5}>
|
||||
<Box display="inline-block">
|
||||
<Tooltip title="Remove this condition from your filter" enterDelay={750} placement="left">
|
||||
<Tooltip title="Remove this condition from your filter" enterDelay={tooltipEnterDelay} placement="left">
|
||||
<IconButton onClick={removeCriteria}><Icon fontSize="small">close</Icon></IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
@ -502,24 +484,10 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
: <span />}
|
||||
</Box>
|
||||
<Box display="inline-block" width={250} className="fieldColumn">
|
||||
<Autocomplete
|
||||
id={`field-${id}`}
|
||||
renderInput={(params) => (<TextField {...params} label={"Field"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
|
||||
// @ts-ignore
|
||||
defaultValue={defaultFieldValue}
|
||||
options={fieldOptions}
|
||||
onChange={handleFieldChange}
|
||||
isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)}
|
||||
groupBy={fieldsGroupBy}
|
||||
getOptionLabel={(option) => getFieldOptionLabel(option)}
|
||||
renderOption={(props, option, state) => renderFieldOption(props, option, state)}
|
||||
autoSelect={true}
|
||||
autoHighlight={true}
|
||||
slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
|
||||
/>
|
||||
<FieldAutoComplete id={`field-${id}`} metaData={metaData} tableMetaData={tableMetaData} defaultValue={defaultFieldValue} handleFieldChange={handleFieldChange} />
|
||||
</Box>
|
||||
<Box display="inline-block" width={200} className="operatorColumn">
|
||||
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={750}>
|
||||
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={tooltipEnterDelay}>
|
||||
<Autocomplete
|
||||
id={"criteriaOperator"}
|
||||
renderInput={(params) => (<TextField {...params} label={"Operator"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
|
||||
@ -546,8 +514,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
|
||||
/>
|
||||
</Box>
|
||||
<Box display="inline-block" pl={0.5} pr={1}>
|
||||
<Tooltip title={criteriaStatusTooltip} enterDelay={750} placement="right">
|
||||
<Box display="inline-block">
|
||||
<Tooltip title={criteriaStatusTooltip} enterDelay={tooltipEnterDelay} placement="bottom">
|
||||
{
|
||||
criteriaIsValid
|
||||
? <Icon color="success">check</Icon>
|
||||
|
@ -44,9 +44,13 @@ interface Props
|
||||
field: QFieldMetaData;
|
||||
table: QTableMetaData;
|
||||
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
|
||||
initiallyOpenMultiValuePvs?: boolean
|
||||
}
|
||||
|
||||
FilterCriteriaRowValues.defaultProps = {};
|
||||
FilterCriteriaRowValues.defaultProps =
|
||||
{
|
||||
initiallyOpenMultiValuePvs: false
|
||||
};
|
||||
|
||||
export const getTypeForTextField = (field: QFieldMetaData): string =>
|
||||
{
|
||||
@ -110,16 +114,17 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
|
||||
InputLabelProps={inputLabelProps}
|
||||
InputProps={inputProps}
|
||||
fullWidth
|
||||
autoFocus={true}
|
||||
/>;
|
||||
};
|
||||
|
||||
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler}: Props): JSX.Element
|
||||
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler, initiallyOpenMultiValuePvs}: Props): JSX.Element
|
||||
{
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
if (!operatorOption)
|
||||
{
|
||||
return <br />;
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveNewPasterValues(newValues: any[])
|
||||
@ -148,7 +153,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
|
||||
switch (operatorOption.valueMode)
|
||||
{
|
||||
case ValueMode.NONE:
|
||||
return <br />;
|
||||
return null;
|
||||
case ValueMode.SINGLE:
|
||||
return makeTextField(field, criteria, valueChangeHandler);
|
||||
case ValueMode.SINGLE_DATE:
|
||||
@ -241,6 +246,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
|
||||
isMultiple
|
||||
fieldLabel="Values"
|
||||
initialValues={initialValues}
|
||||
initiallyOpen={false /*initiallyOpenMultiValuePvs*/}
|
||||
inForm={false}
|
||||
onChange={(value: any) => valueChangeHandler(null, "all", value)}
|
||||
variant="standard"
|
||||
|
134
src/qqq/components/query/QueryScreenActionMenu.tsx
Normal file
134
src/qqq/components/query/QueryScreenActionMenu.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
511
src/qqq/components/query/QuickFilter.tsx
Normal file
511
src/qqq/components/query/QuickFilter.tsx
Normal file
@ -0,0 +1,511 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2023. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
|
||||
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||
import {Tooltip} from "@mui/material";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import React, {SyntheticEvent, useContext, useState} from "react";
|
||||
import QContext from "QContext";
|
||||
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
|
||||
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";
|
||||
|
||||
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
|
||||
|
||||
interface QuickFilterProps
|
||||
{
|
||||
tableMetaData: QTableMetaData;
|
||||
fullFieldName: string;
|
||||
fieldMetaData: QFieldMetaData;
|
||||
criteriaParam: CriteriaParamType;
|
||||
updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean, doRemoveCriteria: boolean) => void;
|
||||
defaultOperator?: QCriteriaOperator;
|
||||
handleRemoveQuickFilterField?: (fieldName: string) => void;
|
||||
}
|
||||
|
||||
QuickFilter.defaultProps =
|
||||
{
|
||||
defaultOperator: QCriteriaOperator.EQUALS,
|
||||
handleRemoveQuickFilterField: null
|
||||
};
|
||||
|
||||
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.
|
||||
*******************************************************************************/
|
||||
const criteriaParamIsCriteria = (param: CriteriaParamType): boolean =>
|
||||
{
|
||||
return (param != null && param != "tooComplex");
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** Test of an OperatorOption equals a query Criteria - that is - that the
|
||||
** operators within them are equal - AND - if the OperatorOption has implicit
|
||||
** values (e.g., the booleans), then those options equal the criteria's options.
|
||||
*******************************************************************************/
|
||||
const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteria: QFilterCriteriaWithId): boolean =>
|
||||
{
|
||||
if(operatorOption.value == criteria.operator)
|
||||
{
|
||||
if(operatorOption.implicitValues)
|
||||
{
|
||||
if(JSON.stringify(operatorOption.implicitValues) == JSON.stringify(criteria.values))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
else
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
return (true);
|
||||
}
|
||||
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the object to use as the selected OperatorOption (e.g., value for that
|
||||
** autocomplete), given an array of options, the query's active criteria in this
|
||||
** field, and the default operator to use for this field
|
||||
*******************************************************************************/
|
||||
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption =>
|
||||
{
|
||||
if(criteria)
|
||||
{
|
||||
const filteredOptions = operatorOptions.filter(o => doesOperatorOptionEqualCriteria(o, criteria));
|
||||
if(filteredOptions.length > 0)
|
||||
{
|
||||
return (filteredOptions[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredOptions = operatorOptions.filter(o => o.value == defaultOperator);
|
||||
if(filteredOptions.length > 0)
|
||||
{
|
||||
return (filteredOptions[0]);
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Component to render a QuickFilter - that is - a button, with a Menu under it,
|
||||
** with Operator and Value controls.
|
||||
*******************************************************************************/
|
||||
export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField}: QuickFilterProps): JSX.Element
|
||||
{
|
||||
const operatorOptions = fieldMetaData ? getOperatorOptions(tableMetaData, fullFieldName) : [];
|
||||
const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName);
|
||||
|
||||
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);
|
||||
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 {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
|
||||
|
||||
const {accentColor} = useContext(QContext);
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
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];
|
||||
setOperatorSelectedValue(operatorOption);
|
||||
setOperatorInputValue(operatorOption.label);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Test if we need to construct a new criteria object
|
||||
*******************************************************************************/
|
||||
const criteriaNeedsReset = (): boolean =>
|
||||
{
|
||||
if(criteria != null && criteriaParam == null)
|
||||
{
|
||||
const defaultOperatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0];
|
||||
if(criteria.operator !== defaultOperatorOption?.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
return (false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a new criteria object - resetting the values tied to the oprator
|
||||
** autocomplete at the same time.
|
||||
*******************************************************************************/
|
||||
const makeNewCriteria = (): QFilterCriteria =>
|
||||
{
|
||||
const operatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0];
|
||||
const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption?.value, getDefaultCriteriaValue());
|
||||
criteria.id = id;
|
||||
setOperatorSelectedValue(operatorOption);
|
||||
setOperatorInputValue(operatorOption?.label);
|
||||
setCriteria(criteria);
|
||||
return(criteria);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler to open the menu in response to the button being clicked.
|
||||
*******************************************************************************/
|
||||
const handleOpenMenu = (event: any) =>
|
||||
{
|
||||
setIsOpen(!isOpen);
|
||||
setAnchorEl(event.currentTarget);
|
||||
|
||||
setTimeout(() =>
|
||||
{
|
||||
const element = document.getElementById("value-" + criteria.id);
|
||||
element?.focus();
|
||||
})
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** handler for the Menu when being closed
|
||||
*******************************************************************************/
|
||||
const closeMenu = () =>
|
||||
{
|
||||
setIsOpen(false);
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for operator Autocomplete having its value changed
|
||||
*******************************************************************************/
|
||||
const handleOperatorChange = (event: any, newValue: any, reason: string) =>
|
||||
{
|
||||
criteria.operator = newValue ? newValue.value : null;
|
||||
|
||||
if (newValue)
|
||||
{
|
||||
setOperatorSelectedValue(newValue);
|
||||
setOperatorInputValue(newValue.label);
|
||||
|
||||
if (newValue.implicitValues)
|
||||
{
|
||||
criteria.values = newValue.implicitValues;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 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)
|
||||
{
|
||||
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
|
||||
if(requiredValueCount != null && criteria.values.length > requiredValueCount)
|
||||
{
|
||||
criteria.values.splice(requiredValueCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
setOperatorSelectedValue(null);
|
||||
setOperatorInputValue("");
|
||||
}
|
||||
|
||||
updateCriteria(criteria, false, false);
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** implementation of isOptionEqualToValue for Autocomplete - compares both the
|
||||
** value (e.g., what operator it is) and the implicitValues within the option
|
||||
*******************************************************************************/
|
||||
function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption)
|
||||
{
|
||||
return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for the value field (of all types), when it changes
|
||||
*******************************************************************************/
|
||||
const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) =>
|
||||
{
|
||||
// @ts-ignore
|
||||
const value = newValue !== undefined ? newValue : event ? event.target.value : null;
|
||||
|
||||
if (!criteria.values)
|
||||
{
|
||||
criteria.values = [];
|
||||
}
|
||||
|
||||
if (valueIndex == "all")
|
||||
{
|
||||
criteria.values = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
criteria.values[valueIndex] = value;
|
||||
}
|
||||
|
||||
updateCriteria(criteria, true, false);
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** a noop event handler, e.g., for a too-complex
|
||||
*******************************************************************************/
|
||||
const noop = () =>
|
||||
{
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler that responds to 'x' button that removes the criteria from the
|
||||
** quick-filter, resetting it to a new filter.
|
||||
*******************************************************************************/
|
||||
const resetCriteria = (e: React.MouseEvent<HTMLSpanElement>) =>
|
||||
{
|
||||
if(criteriaIsValid)
|
||||
{
|
||||
e.stopPropagation();
|
||||
const newCriteria = makeNewCriteria();
|
||||
updateCriteria(newCriteria, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 RecordQueryOrig).
|
||||
*******************************************************************************/
|
||||
const handleTurningOffQuickFilterField = () =>
|
||||
{
|
||||
closeMenu()
|
||||
if(handleRemoveQuickFilterField)
|
||||
{
|
||||
handleRemoveQuickFilterField(criteria?.fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// if no field was input (e.g., record-query is still loading), return null early //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
if(!fieldMetaData)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if there should be a selected value in the operator autocomplete, and it's different //
|
||||
// from the last selected one, then set the state vars that control that autocomplete //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator);
|
||||
if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue))
|
||||
{
|
||||
setOperatorSelectedValue(maybeNewOperatorSelectedValue)
|
||||
setOperatorInputValue(maybeNewOperatorSelectedValue?.label)
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// if there wasn't a criteria, or we need to reset it (make a new one), then do so //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
if (criteria == null || criteriaNeedsReset())
|
||||
{
|
||||
makeNewCriteria();
|
||||
}
|
||||
|
||||
/////////////////////////
|
||||
// build up the button //
|
||||
/////////////////////////
|
||||
const tooComplex = criteriaParam == "tooComplex";
|
||||
const tooltipEnterDelay = 500;
|
||||
|
||||
let buttonAdditionalStyles: any = {};
|
||||
let buttonContent = <span>{tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label}</span>
|
||||
let buttonClassName = "filterNotActive";
|
||||
if (criteriaIsValid)
|
||||
{
|
||||
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} </>);
|
||||
if(operatorSelectedValue.value == QCriteriaOperator.EQUALS || operatorSelectedValue.value == QCriteriaOperator.IN)
|
||||
{
|
||||
operatorString = (<></>)
|
||||
}
|
||||
|
||||
buttonContent = (<><span style={{fontWeight: 700}}>{buttonContent}:</span> <span style={{fontWeight: 400}}>{operatorString}{valuesString}</span></>);
|
||||
}
|
||||
|
||||
const mouseEvents =
|
||||
{
|
||||
onMouseOver: () => handleMouseOverElement(),
|
||||
onMouseOut: () => handleMouseOutElement()
|
||||
};
|
||||
|
||||
let button = fieldMetaData && <Button
|
||||
id={`quickFilter.${fullFieldName}`}
|
||||
className={buttonClassName}
|
||||
{...mouseEvents}
|
||||
sx={{...quickFilterButtonStyles, ...buttonAdditionalStyles, mr: "0.5rem"}}
|
||||
onClick={tooComplex ? noop : handleOpenMenu}
|
||||
disabled={tooComplex}
|
||||
>{buttonContent}</Button>;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the criteria on this field is the "tooComplex" sentinel, then wrap the button in a tooltip stating such, and return early. //
|
||||
// note this was part of original design on this widget, but later deprecated... //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (tooComplex)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// wrap button in span, so disabled button doesn't cause disabled tooltip //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
return (
|
||||
<Tooltip title={`Your current filter is too complex to do a Quick Filter on ${fieldMetaData.label}. Use the Filter button to edit.`} enterDelay={tooltipEnterDelay} slotProps={{popper: {sx: {top: "-0.75rem!important"}}}}>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 //
|
||||
//////////////////////////////
|
||||
const widthAndMaxWidth = 250
|
||||
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"}}>
|
||||
<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}} />)}
|
||||
options={operatorOptions}
|
||||
value={operatorSelectedValue as any}
|
||||
inputValue={operatorInputValue}
|
||||
onChange={handleOperatorChange}
|
||||
onInputChange={(e, value) => setOperatorInputValue(value)}
|
||||
isOptionEqualToValue={(option, value) => isOperatorOptionEqual(option, value)}
|
||||
getOptionLabel={(option: any) => option.label}
|
||||
autoSelect={true}
|
||||
autoHighlight={true}
|
||||
disableClearable
|
||||
slotProps={{popper: {style: {padding: 0, maxHeight: "unset", width: "250px"}}}}
|
||||
/>
|
||||
</Box>
|
||||
<Box width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="quickFilter filterValuesColumn">
|
||||
<FilterCriteriaRowValues
|
||||
operatorOption={operatorSelectedValue}
|
||||
criteria={criteria}
|
||||
field={fieldMetaData}
|
||||
table={tableForField}
|
||||
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
|
||||
initiallyOpenMultiValuePvs={true} // todo - maybe not?
|
||||
/>
|
||||
</Box>
|
||||
</Menu>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
74
src/qqq/components/query/SelectionSubsetDialog.tsx
Normal file
74
src/qqq/components/query/SelectionSubsetDialog.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import React, {useState} from "react";
|
||||
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Component that is the dialog for the user to enter the selection-subset
|
||||
*******************************************************************************/
|
||||
export default function SelectionSubsetDialog(props: { isOpen: boolean; initialValue: number; closeHandler: (value?: number) => void })
|
||||
{
|
||||
const [value, setValue] = useState(props.initialValue);
|
||||
|
||||
const handleChange = (newValue: string) =>
|
||||
{
|
||||
setValue(parseInt(newValue));
|
||||
};
|
||||
|
||||
const keyPressed = (e: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
{
|
||||
if (e.key == "Enter" && value)
|
||||
{
|
||||
props.closeHandler(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={props.isOpen} onClose={() => props.closeHandler()} onKeyPress={(e) => keyPressed(e)}>
|
||||
<DialogTitle>Subset of the Query Result</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>How many records do you want to select?</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
name="selection-subset-size"
|
||||
inputProps={{width: "100%", type: "number", min: 1}}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
value={value}
|
||||
sx={{width: "100%"}}
|
||||
onFocus={event => event.target.select()}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<QCancelButton disabled={false} onClickHandler={() => props.closeHandler()} />
|
||||
<QSaveButton label="OK" iconName="check" disabled={value == undefined || isNaN(value)} onClickHandler={() => props.closeHandler(value)} />
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
122
src/qqq/components/query/TableVariantDialog.tsx
Normal file
122
src/qqq/components/query/TableVariantDialog.tsx
Normal 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
/*******************************************************************************
|
||||
** Component that is the dialog for the user to select a variant on tables with variant backends //
|
||||
*******************************************************************************/
|
||||
export default function TableVariantDialog(props: { isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void })
|
||||
{
|
||||
const [value, setValue] = useState(null);
|
||||
const [dropDownOpen, setDropDownOpen] = useState(false);
|
||||
const [variants, setVariants] = useState(null);
|
||||
|
||||
const handleVariantChange = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) =>
|
||||
{
|
||||
const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${props.table.name}`;
|
||||
if (value != null)
|
||||
{
|
||||
localStorage.setItem(tableVariantLocalStorageKey, JSON.stringify(value));
|
||||
}
|
||||
else
|
||||
{
|
||||
localStorage.removeItem(tableVariantLocalStorageKey);
|
||||
}
|
||||
props.closeHandler(value);
|
||||
};
|
||||
|
||||
const keyPressed = (e: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
{
|
||||
if (e.key == "Enter" && value)
|
||||
{
|
||||
props.closeHandler(value);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
console.log("queryVariants");
|
||||
try
|
||||
{
|
||||
(async () =>
|
||||
{
|
||||
const variants = await qController.tableVariants(props.table.name);
|
||||
console.log(JSON.stringify(variants));
|
||||
setVariants(variants);
|
||||
})();
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
return variants && (
|
||||
<Dialog open={props.isOpen} onKeyPress={(e) => keyPressed(e)}>
|
||||
<DialogTitle>{props.table.variantTableLabel}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>Select the {props.table.variantTableLabel} to be used on this table:</DialogContentText>
|
||||
<Autocomplete
|
||||
id="tableVariantId"
|
||||
sx={{width: "400px", marginTop: "10px"}}
|
||||
open={dropDownOpen}
|
||||
size="small"
|
||||
onOpen={() =>
|
||||
{
|
||||
setDropDownOpen(true);
|
||||
}}
|
||||
onClose={() =>
|
||||
{
|
||||
setDropDownOpen(false);
|
||||
}}
|
||||
// @ts-ignore
|
||||
onChange={handleVariantChange}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
options={variants}
|
||||
renderInput={(params) => <TextField {...params} label={props.table.variantTableLabel} />}
|
||||
getOptionLabel={(option) =>
|
||||
{
|
||||
if (typeof option == "object")
|
||||
{
|
||||
return (option as QTableVariant).name;
|
||||
}
|
||||
return option;
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
92
src/qqq/components/query/XIcon.tsx
Normal file
92
src/qqq/components/query/XIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
406
src/qqq/models/query/QQueryColumns.ts
Normal file
406
src/qqq/models/query/QQueryColumns.ts
Normal 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
|
||||
{
|
||||
}
|
||||
|
100
src/qqq/models/query/RecordQueryView.ts
Normal file
100
src/qqq/models/query/RecordQueryView.ts
Normal 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);
|
||||
};
|
||||
|
||||
}
|
@ -34,6 +34,7 @@ import Grid from "@mui/material/Grid";
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
import {Link, useLocation} from "react-router-dom";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import ProcessLinkCard from "qqq/components/processes/ProcessLinkCard";
|
||||
import DashboardWidgets from "qqq/components/widgets/DashboardWidgets";
|
||||
@ -180,9 +181,6 @@ function AppHome({app}: Props): JSX.Element
|
||||
}
|
||||
}, [qInstance, location]);
|
||||
|
||||
const widgetCount = widgets ? widgets.length : 0;
|
||||
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
const tileSizeLg = 3;
|
||||
|
||||
const hasTablePermission = (tableName: string) =>
|
||||
@ -200,10 +198,63 @@ function AppHome({app}: Props): JSX.Element
|
||||
return reports.find(r => r.name === reportName && r.hasPermission);
|
||||
};
|
||||
|
||||
const widgetCount = widgets ? widgets.length : 0;
|
||||
const sectionCount = app.sections ? app.sections.length : 0;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if our app has no widgets or sections, but it does have child apps, then return those child apps //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(widgetCount == 0 && sectionCount == 0 && childApps && childApps.length > 0)
|
||||
{
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} lg={12}>
|
||||
<Card sx={{overflow: "visible"}}>
|
||||
<Box p={3} display="flex" alignItems="center" gap=".5rem">
|
||||
<Typography variant="h5">Apps</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={3} padding={3} pt={0}>
|
||||
{childApps.map((childApp) => (
|
||||
<Grid key={childApp.name} item xs={12} lg={3}>
|
||||
<Link to={childApp.name}>
|
||||
<Card>
|
||||
<Box display="flex" alignItems="center" p={2}>
|
||||
<Box
|
||||
color={"#FFFFFF"}
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
width="4rem"
|
||||
height="4rem"
|
||||
sx={{borderRadius: "10px", backgroundColor: colors.info.main}}
|
||||
>
|
||||
<Icon fontSize="medium" color="inherit">
|
||||
{childApp.iconName || app.iconName}
|
||||
</Icon>
|
||||
</Box>
|
||||
<Box textAlign="left" ml={2}>
|
||||
<MDTypography variant="button" fontWeight="bold" color="text">
|
||||
{childApp.label}
|
||||
</MDTypography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</Link>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</BaseLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box>
|
||||
{app.widgets && (
|
||||
{app.widgets && app.widgets.length > 0 && (
|
||||
<Box pb={app.sections ? 2.375 : 0}>
|
||||
<DashboardWidgets widgetMetaDataList={widgets} />
|
||||
</Box>
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
@ -424,6 +432,20 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
|
||||
top: -60px !important;
|
||||
}
|
||||
|
||||
.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, 264px) !important;
|
||||
}
|
||||
|
||||
/* within the data-grid, the filter-panel's container has a max-height. mirror that, and set an overflow-y */
|
||||
.MuiDataGrid-panel .customFilterPanel
|
||||
{
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* tighten the text in the field select dropdown in custom filters */
|
||||
.customFilterPanel .MuiAutocomplete-paper
|
||||
{
|
||||
@ -487,7 +509,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
|
||||
}
|
||||
|
||||
/* change tags in any-of value fields to not be black bg with white text */
|
||||
.customFilterPanel .filterValuesColumn .MuiChip-root
|
||||
.customFilterPanel .filterValuesColumn .MuiChip-root,
|
||||
.quickFilter.filterValuesColumn .MuiChip-root
|
||||
{
|
||||
background: none;
|
||||
color: black;
|
||||
@ -495,20 +518,23 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
|
||||
}
|
||||
|
||||
/* change 'x' icon in tags in any-of value */
|
||||
.customFilterPanel .filterValuesColumn .MuiChip-root .MuiChip-deleteIcon
|
||||
.customFilterPanel .filterValuesColumn .MuiChip-root .MuiChip-deleteIcon,
|
||||
.quickFilter.filterValuesColumn .MuiChip-root .MuiChip-deleteIcon
|
||||
{
|
||||
color: gray;
|
||||
}
|
||||
|
||||
/* change tags in any-of value fields to not be black bg with white text */
|
||||
.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag
|
||||
.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag,
|
||||
.quickFilter.filterValuesColumn .MuiAutocomplete-tag
|
||||
{
|
||||
color: #191919;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* default hover color for the 'x' to remove a tag from an 'any-of' value was white, which made it disappear */
|
||||
.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag .MuiSvgIcon-root:hover
|
||||
.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag .MuiSvgIcon-root:hover,
|
||||
.quickFilter.filterValuesColumn .MuiAutocomplete-tag .MuiSvgIcon-root:hover
|
||||
{
|
||||
color: lightgray;
|
||||
}
|
||||
@ -597,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;
|
||||
}
|
||||
|
@ -1,752 +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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default FilterUtils;
|
573
src/qqq/utils/qqq/FilterUtils.tsx
Normal file
573
src/qqq/utils/qqq/FilterUtils.tsx
Normal file
@ -0,0 +1,573 @@
|
||||
/*
|
||||
* 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++)
|
||||
{
|
||||
console.log(param[i]);
|
||||
if (param[i] && param[i].id && param[i].label)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////
|
||||
// if the param looks like a possible value, return its id //
|
||||
/////////////////////////////////////////////////////////////
|
||||
rs.push(param[i].id);
|
||||
}
|
||||
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);
|
||||
labels.push(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>}
|
||||
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
return (`${fieldLabel} ${FilterUtils.operatorToHumanString(criteria, field)} ${valuesString}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default FilterUtils;
|
418
src/qqq/utils/qqq/SavedViewUtils.ts
Normal file
418
src/qqq/utils/qqq/SavedViewUtils.ts
Normal 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;
|
||||
};
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
@ -462,6 +462,19 @@ class ValueUtils
|
||||
|
||||
return (String(param).replaceAll(/"/g, "\"\""));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static safeToLocaleString(n: Number): string
|
||||
{
|
||||
if (n != null && n != undefined)
|
||||
{
|
||||
return (n.toLocaleString());
|
||||
}
|
||||
return ("");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.junit;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class BaseTest
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(BaseTest.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void baseBeforeEach()
|
||||
{
|
||||
QContext.init(TestUtils.defineInstance(), new QSession());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void baseAfterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
protected static void reInitInstanceInContext(QInstance qInstance)
|
||||
{
|
||||
if(qInstance.equals(QContext.getQInstance()))
|
||||
{
|
||||
LOG.warn("Unexpected condition - the same qInstance that is already in the QContext was passed into reInit. You probably want a new QInstance object instance.");
|
||||
}
|
||||
QContext.init(qInstance, new QSession());
|
||||
}
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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.junit;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class TestUtils
|
||||
{
|
||||
public static final String DEFAULT_BACKEND_NAME = "memoryBackend";
|
||||
public static final String TABLE_NAME_PERSON = "person";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static QInstance defineInstance()
|
||||
{
|
||||
QInstance qInstance = new QInstance();
|
||||
qInstance.addBackend(defineBackend());
|
||||
qInstance.addTable(defineTablePerson());
|
||||
qInstance.setAuthentication(defineAuthentication());
|
||||
return (qInstance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Define the authentication used in standard tests - using 'mock' type.
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static QAuthenticationMetaData defineAuthentication()
|
||||
{
|
||||
return new QAuthenticationMetaData()
|
||||
.withName("mock")
|
||||
.withType(QAuthenticationType.MOCK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static QBackendMetaData defineBackend()
|
||||
{
|
||||
return (new QBackendMetaData()
|
||||
.withName(DEFAULT_BACKEND_NAME)
|
||||
.withBackendType("memory"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData defineTablePerson()
|
||||
{
|
||||
return new QTableMetaData()
|
||||
.withName(TABLE_NAME_PERSON)
|
||||
.withLabel("Person")
|
||||
.withBackendName(DEFAULT_BACKEND_NAME)
|
||||
.withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("firstName", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("lastName", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("birthDate", QFieldType.DATE))
|
||||
.withField(new QFieldMetaData("email", QFieldType.STRING));
|
||||
}
|
||||
}
|
@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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.model.metadata;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.junit.BaseTest;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.junit.TestUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit test for MaterialDashboardTableMetaData
|
||||
*******************************************************************************/
|
||||
class MaterialDashboardTableMetaDataTest extends BaseTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testValidateGoToFieldNames()
|
||||
{
|
||||
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of()))),
|
||||
"empty gotoFieldNames list");
|
||||
|
||||
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of("foo")))),
|
||||
"unrecognized field name: foo");
|
||||
|
||||
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of("foo"), List.of("bar", "baz")))),
|
||||
"unrecognized field name: foo",
|
||||
"unrecognized field name: bar",
|
||||
"unrecognized field name: baz");
|
||||
|
||||
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of("firstName", "firstName")))),
|
||||
"duplicated field name: firstName");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testValidateQuickFilterFieldNames()
|
||||
{
|
||||
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withDefaultQuickFilterFieldNames(List.of("foo"))),
|
||||
"unrecognized field name: foo");
|
||||
|
||||
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withDefaultQuickFilterFieldNames(List.of("firstName", "lastName", "firstName"))),
|
||||
"duplicated field name: firstName");
|
||||
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// todo - methods below here were copied from QInstanceValidatorTest... //
|
||||
// how to share those... //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a little setup code on a qInstance; then validate it, and assert that it
|
||||
** failed validation with reasons that match the supplied vararg-reasons (but allow
|
||||
** more reasons - e.g., helpful when one thing we're testing causes other errors).
|
||||
*******************************************************************************/
|
||||
private void assertValidationFailureReasonsAllowingExtraReasons(Consumer<QInstance> setup, String... reasons)
|
||||
{
|
||||
assertValidationFailureReasons(setup, true, reasons);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a little setup code on a qInstance; then validate it, and assert that it
|
||||
** failed validation with reasons that match the supplied vararg-reasons (and
|
||||
** require that exact # of reasons).
|
||||
*******************************************************************************/
|
||||
private void assertValidationFailureReasons(Consumer<QInstance> setup, String... reasons)
|
||||
{
|
||||
assertValidationFailureReasons(setup, false, reasons);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Implementation for the overloads of this name.
|
||||
*******************************************************************************/
|
||||
private void assertValidationFailureReasons(Consumer<QInstance> setup, boolean allowExtraReasons, String... reasons)
|
||||
{
|
||||
try
|
||||
{
|
||||
QInstance qInstance = TestUtils.defineInstance();
|
||||
setup.accept(qInstance);
|
||||
new QInstanceValidator().validate(qInstance);
|
||||
fail("Should have thrown validationException");
|
||||
}
|
||||
catch(QInstanceValidationException e)
|
||||
{
|
||||
if(!allowExtraReasons)
|
||||
{
|
||||
int noOfReasons = e.getReasons() == null ? 0 : e.getReasons().size();
|
||||
assertEquals(reasons.length, noOfReasons, "Expected number of validation failure reasons.\nExpected reasons: " + String.join(",", reasons)
|
||||
+ "\nActual reasons: " + (noOfReasons > 0 ? String.join("\n", e.getReasons()) : "--"));
|
||||
}
|
||||
|
||||
for(String reason : reasons)
|
||||
{
|
||||
assertReason(reason, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Assert that an instance is valid!
|
||||
*******************************************************************************/
|
||||
private void assertValidationSuccess(Consumer<QInstance> setup)
|
||||
{
|
||||
try
|
||||
{
|
||||
QInstance qInstance = TestUtils.defineInstance();
|
||||
setup.accept(qInstance);
|
||||
new QInstanceValidator().validate(qInstance);
|
||||
}
|
||||
catch(QInstanceValidationException e)
|
||||
{
|
||||
fail("Expected no validation errors, but received: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** utility method for asserting that a specific reason string is found within
|
||||
** the list of reasons in the QInstanceValidationException.
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void assertReason(String reason, QInstanceValidationException e)
|
||||
{
|
||||
assertNotNull(e.getReasons(), "Expected there to be a reason for the failure (but there was not)");
|
||||
assertThat(e.getReasons())
|
||||
.withFailMessage("Expected any of:\n%s\nTo match: [%s]", e.getReasons(), reason)
|
||||
.anyMatch(s -> s.contains(reason));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// todo - end of methods copied from QInstanceValidatorTest... //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
}
|
@ -1,4 +1,25 @@
|
||||
package com.kingsrook.qqq.materialdashboard.lib;
|
||||
/*
|
||||
* 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.lib;
|
||||
|
||||
|
||||
import java.io.File;
|
||||
@ -6,7 +27,7 @@ import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
|
||||
import io.github.bonigarcia.wdm.WebDriverManager;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
@ -156,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");
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2023. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,7 +19,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.lib;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -28,7 +28,7 @@ package com.kingsrook.qqq.materialdashboard.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";
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2023. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,7 +19,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.lib;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib;
|
||||
|
||||
|
||||
import java.io.File;
|
||||
@ -43,7 +43,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 +63,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 +201,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 +209,27 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void clickBackdrop()
|
||||
{
|
||||
for(WebElement webElement : this.waitForSelectorAll(".MuiBackdrop-root", 0))
|
||||
{
|
||||
try
|
||||
{
|
||||
webElement.click();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -225,6 +250,18 @@ public class QSeleniumLib
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void moveMouseCursorToElement(WebElement element)
|
||||
{
|
||||
Actions actions = new Actions(driver);
|
||||
actions.moveToElement(element);
|
||||
actions.perform();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
@ -0,0 +1,230 @@
|
||||
/*
|
||||
* 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.lib;
|
||||
|
||||
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.Keys;
|
||||
import org.openqa.selenium.WebElement;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class QueryScreenLib
|
||||
{
|
||||
private final QSeleniumLib qSeleniumLib;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
**
|
||||
*******************************************************************************/
|
||||
public QueryScreenLib(QSeleniumLib qSeleniumLib)
|
||||
{
|
||||
this.qSeleniumLib = qSeleniumLib;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public WebElement assertFilterButtonBadge(int 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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public WebElement waitForQueryToHaveRan()
|
||||
{
|
||||
return qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void clickFilterButton()
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContaining("BUTTON", "FILTER BUILDER").click();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public WebElement assertQuickFilterButtonIndicatesActiveFilter(String fieldName)
|
||||
{
|
||||
return qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName + ".filterActive");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void assertQuickFilterButtonDoesNotIndicateActiveFilter(String fieldName)
|
||||
{
|
||||
qSeleniumLib.waitForSelectorToNotExist("#quickFilter\\." + fieldName + ".filterActive");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void clickQuickFilterButton(String fieldName)
|
||||
{
|
||||
qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName).click();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void gotoAdvancedMode()
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContaining("BUTTON", "ADVANCED").click();
|
||||
qSeleniumLib.waitForSelectorContaining("BUTTON", "FILTER BUILDER");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void gotoBasicMode()
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContaining("BUTTON", "BASIC").click();
|
||||
qSeleniumLib.waitForSelectorContaining("BUTTON", "ADD FILTER");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void assertSavedViewNameOnScreen(String savedViewName)
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContaining("H3", savedViewName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public WebElement waitForDataGridCellContaining(String containingText)
|
||||
{
|
||||
return qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", containingText);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void addAdvancedQueryFilterInput(QSeleniumLib qSeleniumLib, int index, String fieldLabel, String operator, String value, String booleanOperator)
|
||||
{
|
||||
if(index > 0)
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContaining("BUTTON", "Add condition").click();
|
||||
}
|
||||
|
||||
WebElement subFormForField = qSeleniumLib.waitForSelectorAll(".filterCriteriaRow", index + 1).get(index);
|
||||
|
||||
if(index == 1)
|
||||
{
|
||||
WebElement booleanOperatorInput = subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input"));
|
||||
booleanOperatorInput.click();
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
|
||||
subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input"));
|
||||
qSeleniumLib.waitForSelectorContaining("li", booleanOperator).click();
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
}
|
||||
|
||||
WebElement fieldInput = subFormForField.findElement(By.cssSelector(".fieldColumn INPUT"));
|
||||
fieldInput.click();
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
fieldInput.clear();
|
||||
fieldInput.sendKeys(fieldLabel);
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
fieldInput.sendKeys("\n");
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
|
||||
WebElement operatorInput = subFormForField.findElement(By.cssSelector(".operatorColumn INPUT"));
|
||||
operatorInput.click();
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
operatorInput.sendKeys(Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, operator);
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
operatorInput.sendKeys("\n");
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
|
||||
WebElement valueInput = subFormForField.findElement(By.cssSelector(".filterValuesColumn INPUT"));
|
||||
valueInput.click();
|
||||
valueInput.sendKeys(value);
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
}
|
||||
|
||||
}
|
@ -1,4 +1,25 @@
|
||||
package com.kingsrook.qqq.materialdashboard.lib.javalin;
|
||||
/*
|
||||
* 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.lib.javalin;
|
||||
|
||||
|
||||
import io.javalin.http.Context;
|
@ -1,4 +1,25 @@
|
||||
package com.kingsrook.qqq.materialdashboard.lib.javalin;
|
||||
/*
|
||||
* 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.lib.javalin;
|
||||
|
||||
|
||||
import io.javalin.http.Context;
|
@ -1,4 +1,25 @@
|
||||
package com.kingsrook.qqq.materialdashboard.lib.javalin;
|
||||
/*
|
||||
* 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.lib.javalin;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
@ -6,7 +27,7 @@ import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QSeleniumLib;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib;
|
||||
import io.javalin.Javalin;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
@ -1,4 +1,25 @@
|
||||
package com.kingsrook.qqq.materialdashboard.lib.javalin;
|
||||
/*
|
||||
* 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.lib.javalin;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
@ -1,4 +1,25 @@
|
||||
package com.kingsrook.qqq.materialdashboard.lib.javalin;
|
||||
/*
|
||||
* 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.lib.javalin;
|
||||
|
||||
|
||||
import io.javalin.http.Context;
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,12 +19,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.tests;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QQQMaterialDashboardSelectors;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||
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.javalin.QSeleniumJavalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,11 +19,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.tests;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@ -53,13 +53,13 @@ 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();
|
||||
assertTrue(qSeleniumLib.driver.getCurrentUrl().endsWith("/1/dev"));
|
||||
|
||||
qSeleniumLib.waitForever();
|
||||
// qSeleniumLib.waitForever();
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,13 +19,13 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.tests;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
|
||||
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.assertj.core.api.Assertions.assertThat;
|
||||
@ -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();
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,11 +19,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.tests;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
|
||||
@ -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");
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,11 +19,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.tests;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@ -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");
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,14 +19,15 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.tests;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
|
||||
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||
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;
|
||||
import org.openqa.selenium.By;
|
||||
@ -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();
|
||||
|
||||
@ -104,7 +105,7 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest
|
||||
"3","Bart J."
|
||||
""", fileContents);
|
||||
|
||||
qSeleniumLib.waitForever();
|
||||
// qSeleniumLib.waitForever();
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,11 +19,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.tests;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
|
||||
@ -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");
|
@ -0,0 +1,181 @@
|
||||
/*
|
||||
* 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.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.ThisOrLastPeriod;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
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.QSeleniumJavalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test for the record query screen when a filter is given in the URL
|
||||
*******************************************************************************/
|
||||
public class QueryScreenFilterInUrlAdvancedModeTest 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/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json")
|
||||
.withRouteToFile("/data/person/variants", "data/person/variants.json")
|
||||
.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUrlWithFilter()
|
||||
{
|
||||
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
|
||||
|
||||
////////////////////////////////
|
||||
// put table in advanced mode //
|
||||
////////////////////////////////
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
|
||||
queryScreenLib.gotoAdvancedMode();
|
||||
|
||||
////////////////////////////////////////
|
||||
// not-blank -- criteria w/ no values //
|
||||
////////////////////////////////////////
|
||||
String filterJSON = JsonUtils.toJson(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK)));
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.assertFilterButtonBadge(1);
|
||||
queryScreenLib.clickFilterButton();
|
||||
qSeleniumLib.waitForSelector("input[value=\"is not empty\"]");
|
||||
|
||||
///////////////////////////////
|
||||
// between on a number field //
|
||||
///////////////////////////////
|
||||
filterJSON = JsonUtils.toJson(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656)));
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.assertFilterButtonBadge(1);
|
||||
queryScreenLib.clickFilterButton();
|
||||
qSeleniumLib.waitForSelector("input[value=\"is between\"]");
|
||||
qSeleniumLib.waitForSelector("input[value=\"1701\"]");
|
||||
qSeleniumLib.waitForSelector("input[value=\"74656\"]");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 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.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.assertFilterButtonBadge(1);
|
||||
queryScreenLib.clickFilterButton();
|
||||
qSeleniumLib.waitForSelector("input[value=\"is any of\"]");
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "St. Louis");
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "Chesterfield");
|
||||
|
||||
/////////////////////////////////////////
|
||||
// greater than a date-time expression //
|
||||
/////////////////////////////////////////
|
||||
filterJSON = JsonUtils.toJson(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS))));
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.assertFilterButtonBadge(1);
|
||||
queryScreenLib.clickFilterButton();
|
||||
qSeleniumLib.waitForSelector("input[value=\"is after\"]");
|
||||
qSeleniumLib.waitForSelector("input[value=\"5 days ago\"]");
|
||||
|
||||
///////////////////////
|
||||
// multiple criteria //
|
||||
///////////////////////
|
||||
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.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.assertFilterButtonBadge(2);
|
||||
queryScreenLib.clickFilterButton();
|
||||
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.clickFilterButton();
|
||||
qSeleniumLib.waitForSelector("input[value=\"does not equal\"]");
|
||||
qSeleniumLib.waitForSelector("input[value=\"St. Louis\"]");
|
||||
|
||||
////////////////
|
||||
// remove one //
|
||||
////////////////
|
||||
queryScreenLib.clickAdvancedFilterClearIcon();
|
||||
queryScreenLib.assertNoFilterButtonBadge(1);
|
||||
|
||||
// qSeleniumLib.waitForever();
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,7 +19,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.tests;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query;
|
||||
|
||||
|
||||
import java.net.URLEncoder;
|
||||
@ -31,17 +31,16 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.ThisOrLastPeriod;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QQQMaterialDashboardSelectors;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||
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.QSeleniumJavalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openqa.selenium.WebElement;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test for the record query screen when a filter is given in the URL
|
||||
*******************************************************************************/
|
||||
public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest
|
||||
public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
@ -56,7 +55,7 @@ public class QueryScreenFilterInUrlTest 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");
|
||||
}
|
||||
|
||||
|
||||
@ -67,15 +66,17 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest
|
||||
@Test
|
||||
void testUrlWithFilter()
|
||||
{
|
||||
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
|
||||
|
||||
////////////////////////////////////////
|
||||
// not-blank -- criteria w/ no values //
|
||||
////////////////////////////////////////
|
||||
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");
|
||||
waitForQueryToHaveRan();
|
||||
assertFilterButtonBadge(1);
|
||||
clickFilterButton();
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("annualSalary");
|
||||
queryScreenLib.clickQuickFilterButton("annualSalary");
|
||||
qSeleniumLib.waitForSelector("input[value=\"is not empty\"]");
|
||||
|
||||
///////////////////////////////
|
||||
@ -83,10 +84,10 @@ public class QueryScreenFilterInUrlTest 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");
|
||||
waitForQueryToHaveRan();
|
||||
assertFilterButtonBadge(1);
|
||||
clickFilterButton();
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("annualSalary");
|
||||
queryScreenLib.clickQuickFilterButton("annualSalary");
|
||||
qSeleniumLib.waitForSelector("input[value=\"is between\"]");
|
||||
qSeleniumLib.waitForSelector("input[value=\"1701\"]");
|
||||
qSeleniumLib.waitForSelector("input[value=\"74656\"]");
|
||||
@ -96,10 +97,10 @@ public class QueryScreenFilterInUrlTest 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");
|
||||
waitForQueryToHaveRan();
|
||||
assertFilterButtonBadge(1);
|
||||
clickFilterButton();
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("homeCityId");
|
||||
queryScreenLib.clickQuickFilterButton("homeCityId");
|
||||
qSeleniumLib.waitForSelector("input[value=\"does not equal\"]");
|
||||
qSeleniumLib.waitForSelector("input[value=\"St. Louis\"]");
|
||||
|
||||
@ -108,10 +109,10 @@ public class QueryScreenFilterInUrlTest 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");
|
||||
waitForQueryToHaveRan();
|
||||
assertFilterButtonBadge(1);
|
||||
clickFilterButton();
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("homeCityId");
|
||||
queryScreenLib.clickQuickFilterButton("homeCityId");
|
||||
qSeleniumLib.waitForSelector("input[value=\"is any of\"]");
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "St. Louis");
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "Chesterfield");
|
||||
@ -121,10 +122,10 @@ public class QueryScreenFilterInUrlTest 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");
|
||||
waitForQueryToHaveRan();
|
||||
assertFilterButtonBadge(1);
|
||||
clickFilterButton();
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("createDate");
|
||||
queryScreenLib.clickQuickFilterButton("createDate");
|
||||
qSeleniumLib.waitForSelector("input[value=\"is after\"]");
|
||||
qSeleniumLib.waitForSelector("input[value=\"5 days ago\"]");
|
||||
|
||||
@ -134,52 +135,29 @@ public class QueryScreenFilterInUrlTest 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");
|
||||
waitForQueryToHaveRan();
|
||||
assertFilterButtonBadge(2);
|
||||
clickFilterButton();
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
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\"]");
|
||||
qSeleniumLib.clickBackdrop();
|
||||
queryScreenLib.clickQuickFilterButton("firstName");
|
||||
qSeleniumLib.waitForSelector("input[value=\"starts with\"]");
|
||||
qSeleniumLib.waitForSelector("input[value=\"Dar\"]");
|
||||
|
||||
////////////////
|
||||
// remove one //
|
||||
////////////////
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiIcon-root", "close").click();
|
||||
assertFilterButtonBadge(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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private WebElement assertFilterButtonBadge(int valueInBadge)
|
||||
{
|
||||
return qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", String.valueOf(valueInBadge));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private WebElement waitForQueryToHaveRan()
|
||||
{
|
||||
return qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void clickFilterButton()
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click();
|
||||
// qSeleniumLib.waitForever();
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,18 +19,15 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.tests;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QQQMaterialDashboardSelectors;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QSeleniumLib;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||
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 org.openqa.selenium.By;
|
||||
import org.openqa.selenium.Keys;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
@ -51,7 +48,7 @@ public class QueryScreenTest extends QBaseSeleniumTest
|
||||
.withRouteToFile("/data/person/count", "data/person/count.json")
|
||||
.withRouteToFile("/data/person/query", "data/person/index.json")
|
||||
.withRouteToFile("/data/person/variants", "data/person/variants.json")
|
||||
.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json");
|
||||
.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json");
|
||||
}
|
||||
|
||||
|
||||
@ -60,32 +57,28 @@ public class QueryScreenTest extends QBaseSeleniumTest
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testBasicQueryAndClearFilters()
|
||||
void testBuildQueryQueryAndClearFilters()
|
||||
{
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
|
||||
qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL);
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click();
|
||||
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
|
||||
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.gotoAdvancedMode();
|
||||
queryScreenLib.clickFilterButton();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// open the filter window, enter a value, wait for query to re-run //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
WebElement filterInput = qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_FILTER_INPUT);
|
||||
qSeleniumLib.waitForElementToHaveFocus(filterInput);
|
||||
filterInput.sendKeys("id");
|
||||
filterInput.sendKeys("\t");
|
||||
driver.switchTo().activeElement().sendKeys("\t");
|
||||
qSeleniumJavalin.beginCapture();
|
||||
driver.switchTo().activeElement().sendKeys("1");
|
||||
queryScreenLib.addAdvancedQueryFilterInput(qSeleniumLib, 0, "Id", "equals", "1", null);
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// assert that query & count both have the expected filter value //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
String idEquals1FilterSubstring = """
|
||||
{"fieldName":"id","operator":"EQUALS","values":["1"]}""";
|
||||
CapturedContext capturedCount = qSeleniumJavalin.waitForCapturedPath("/data/person/count");
|
||||
CapturedContext capturedQuery = qSeleniumJavalin.waitForCapturedPath("/data/person/query");
|
||||
assertThat(capturedCount).extracting("body").asString().contains(idEquals1FilterSubstring);
|
||||
assertThat(capturedQuery).extracting("body").asString().contains(idEquals1FilterSubstring);
|
||||
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/count", idEquals1FilterSubstring);
|
||||
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", idEquals1FilterSubstring);
|
||||
qSeleniumJavalin.endCapture();
|
||||
|
||||
///////////////////////////////////////
|
||||
@ -93,20 +86,19 @@ public class QueryScreenTest extends QBaseSeleniumTest
|
||||
///////////////////////////////////////
|
||||
qSeleniumLib.waitForSeconds(1); // todo grr.
|
||||
qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER).click();
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", "1");
|
||||
queryScreenLib.assertFilterButtonBadge(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();
|
||||
queryScreenLib.clickAdvancedFilterClearIcon();
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// assert that query & count both no longer have the filter value //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
capturedCount = qSeleniumJavalin.waitForCapturedPath("/data/person/count");
|
||||
capturedQuery = qSeleniumJavalin.waitForCapturedPath("/data/person/query");
|
||||
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();
|
||||
@ -120,13 +112,16 @@ public class QueryScreenTest extends QBaseSeleniumTest
|
||||
@Test
|
||||
void testMultiCriteriaQueryWithOr()
|
||||
{
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
|
||||
qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL);
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click();
|
||||
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
|
||||
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
|
||||
queryScreenLib.waitForQueryToHaveRan();
|
||||
queryScreenLib.gotoAdvancedMode();
|
||||
queryScreenLib.clickFilterButton();
|
||||
|
||||
qSeleniumJavalin.beginCapture();
|
||||
addQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or");
|
||||
addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or");
|
||||
queryScreenLib.addAdvancedQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or");
|
||||
queryScreenLib.addAdvancedQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or");
|
||||
|
||||
String expectedFilterContents0 = """
|
||||
{"fieldName":"firstName","operator":"CONTAINS","values":["Dar"]}""";
|
||||
@ -142,51 +137,6 @@ public class QueryScreenTest extends QBaseSeleniumTest
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static void addQueryFilterInput(QSeleniumLib qSeleniumLib, int index, String fieldLabel, String operator, String value, String booleanOperator)
|
||||
{
|
||||
if(index > 0)
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContaining("BUTTON", "Add condition").click();
|
||||
}
|
||||
|
||||
WebElement subFormForField = qSeleniumLib.waitForSelectorAll(".filterCriteriaRow", index + 1).get(index);
|
||||
|
||||
if(index == 1)
|
||||
{
|
||||
WebElement booleanOperatorInput = subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input"));
|
||||
booleanOperatorInput.click();
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
|
||||
subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input"));
|
||||
qSeleniumLib.waitForSelectorContaining("li", booleanOperator).click();
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
}
|
||||
|
||||
WebElement fieldInput = subFormForField.findElement(By.cssSelector(".fieldColumn INPUT"));
|
||||
fieldInput.click();
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
fieldInput.clear();
|
||||
fieldInput.sendKeys(fieldLabel);
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
fieldInput.sendKeys("\n");
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
|
||||
WebElement operatorInput = subFormForField.findElement(By.cssSelector(".operatorColumn INPUT"));
|
||||
operatorInput.click();
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
operatorInput.sendKeys(Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, operator);
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
operatorInput.sendKeys("\n");
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
|
||||
WebElement valueInput = subFormForField.findElement(By.cssSelector(".filterValuesColumn INPUT"));
|
||||
valueInput.click();
|
||||
valueInput.sendKeys(value);
|
||||
qSeleniumLib.waitForMillis(100);
|
||||
}
|
||||
// todo - table requires variant - prompt for it, choose it, see query; change variant, change on-screen, re-query
|
||||
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,24 +19,24 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.materialdashboard.tests;
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query;
|
||||
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext;
|
||||
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
|
||||
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.By;
|
||||
import static com.kingsrook.qqq.materialdashboard.tests.QueryScreenTest.addQueryFilterInput;
|
||||
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
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
@ -69,8 +69,11 @@ public class SavedFiltersTest extends QBaseSeleniumTest
|
||||
@Test
|
||||
void testNavigatingBackAndForth()
|
||||
{
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
|
||||
qSeleniumLib.waitForSelectorContaining("BUTTON", "Saved Filters").click();
|
||||
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
|
||||
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
|
||||
|
||||
qSeleniumLib.waitForSelectorContaining("BUTTON", "Views").click();
|
||||
qSeleniumLib.waitForSelectorContaining("LI", "Some People");
|
||||
|
||||
////////////////////////////////////////
|
||||
@ -79,46 +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 //
|
||||
//////////////////////
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click();
|
||||
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,8 +160,8 @@ 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();
|
@ -0,0 +1,8 @@
|
||||
{
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "St. Louis"
|
||||
}
|
||||
]
|
||||
}
|
@ -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
|
||||
},
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user