Merged feature/CE-798-quick-filters into integration/sprint-35

This commit is contained in:
2024-02-06 20:34:25 -06:00
33 changed files with 2196 additions and 826 deletions

View File

@ -656,6 +656,7 @@ export default function App()
const [pageHeader, setPageHeader] = useState("" as string | JSX.Element);
const [accentColor, setAccentColor] = useState("#0062FF");
const [accentColorLight, setAccentColorLight] = useState("#C0D6F7")
const [tableMetaData, setTableMetaData] = useState(null);
const [tableProcesses, setTableProcesses] = useState(null);
const [dotMenuOpen, setDotMenuOpen] = useState(false);
@ -668,6 +669,7 @@ export default function App()
<QContext.Provider value={{
pageHeader: pageHeader,
accentColor: accentColor,
accentColorLight: accentColorLight,
tableMetaData: tableMetaData,
tableProcesses: tableProcesses,
dotMenuOpen: dotMenuOpen,
@ -675,6 +677,7 @@ export default function App()
helpHelpActive: helpHelpActive,
setPageHeader: (header: string | JSX.Element) => setPageHeader(header),
setAccentColor: (accentColor: string) => setAccentColor(accentColor),
setAccentColorLight: (accentColorLight: string) => setAccentColorLight(accentColorLight),
setTableMetaData: (tableMetaData: QTableMetaData) => setTableMetaData(tableMetaData),
setTableProcesses: (tableProcesses: QProcessMetaData[]) => setTableProcesses(tableProcesses),
setDotMenuOpen: (dotMenuOpent: boolean) => setDotMenuOpen(dotMenuOpent),

View File

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

View File

@ -37,7 +37,7 @@ interface QCreateNewButtonProps
export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element
{
return (
<Box ml={3} mr={0} width={standardWidth}>
<Box display="inline-block" ml={3} mr={0} width={standardWidth}>
<Link to={`${tablePath}/create`}>
<MDButton variant="gradient" color="info" fullWidth startIcon={<Icon>add</Icon>}>
Create New
@ -127,24 +127,6 @@ export function QActionsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonP
);
}
export function QSavedViewsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonProps): JSX.Element
{
return (
<Box width={standardWidth} ml={1}>
<MDButton
variant={isOpen ? "contained" : "outlined"}
color="dark"
onClick={onClickHandler}
fullWidth
startIcon={<Icon>visibility</Icon>}
>
Saved&nbsp;Views&nbsp;
<Icon>keyboard_arrow_down</Icon>
</MDButton>
</Box>
);
}
interface QCancelButtonProps
{
onClickHandler: any;

View File

@ -70,7 +70,7 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
}
const routes: string[] | any = route.slice(0, -1);
const {pageHeader, pathToLabelMap, branding} = useContext(QContext);
const {pathToLabelMap, branding} = useContext(QContext);
const fullPathToLabel = (fullPath: string, route: string): string =>
{
@ -149,15 +149,6 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
</Link>
))}
</MuiBreadcrumbs>
<MDTypography
pt={1}
textTransform="capitalize"
variant="h3"
color={light ? "white" : "dark"}
noWrap
>
{pageHeader}
</MDTypography>
</Box>
);
}

View File

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

View File

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

View File

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

View File

@ -25,10 +25,7 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT
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 {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {FiberManualRecord} from "@mui/icons-material";
import {Alert} from "@mui/material";
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";
@ -42,15 +39,15 @@ 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 Typography from "@mui/material/Typography";
import FormData from "form-data";
import React, {useEffect, useRef, useState} from "react";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
import {QCancelButton, QDeleteButton, QSaveButton, QSavedViewsMenuButton} from "qqq/components/buttons/DefaultButtons";
import QQueryColumns from "qqq/models/query/QQueryColumns";
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 TableUtils from "qqq/utils/qqq/TableUtils";
import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils";
interface Props
{
@ -58,13 +55,14 @@ interface Props
metaData: QInstance;
tableMetaData: QTableMetaData;
currentSavedView: QRecord;
tableDefaultView: RecordQueryView;
view?: RecordQueryView;
viewAsJson?: string;
viewOnChangeCallback?: (selectedSavedViewId: number) => void;
loadingSavedView: boolean
}
function SavedViews({qController, metaData, tableMetaData, currentSavedView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element
function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element
{
const navigate = useNavigate();
@ -91,6 +89,8 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
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);
@ -107,385 +107,14 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
}, [location, tableMetaData])
/*******************************************************************************
**
*******************************************************************************/
const fieldNameToLabel = (fieldName: string): string =>
{
try
{
const [fieldMetaData, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
if(fieldTable.name != tableMetaData.name)
{
return (tableMetaData.label + ": " + fieldMetaData.label);
}
return (fieldMetaData.label);
}
catch(e)
{
return (fieldName);
}
}
/*******************************************************************************
**
*******************************************************************************/
const diffFilters = (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(!baseCriteriaMap[criteria.fieldName])
{
baseCriteriaMap[criteria.fieldName] = []
}
baseCriteriaMap[criteria.fieldName].push(criteria)
});
const compareCriteriaMap: { [name: string]: QFilterCriteria[] } = {};
compare?.criteria?.forEach((criteria) =>
{
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 ${fieldNameToLabel(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 ${fieldNameToLabel(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 ${fieldNameToLabel(savedOrderBys[0].fieldName)} ${toWord(savedOrderBys[0].isAscending)} to ${fieldNameToLabel(activeOrderBys[0].fieldName)} ${toWord(activeOrderBys[0].isAscending)}`)
}
else if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName)
{
viewDiffs.push(`Changed sort field from ${fieldNameToLabel(savedOrderBys[0].fieldName)} to ${fieldNameToLabel(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}`);
}
}
/*******************************************************************************
**
*******************************************************************************/
const diffColumns = (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(fieldNameToLabel(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(fieldNameToLabel(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(fieldNameToLabel(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 columns.");
}
diffWidthsFunction(savedView.queryColumns, activeView.queryColumns, "Changed width for ");
}
catch (e)
{
console.log(`Error looking for differences in columns: ${e}`);
}
}
/*******************************************************************************
**
*******************************************************************************/
const diffQuickFilterFieldNames = (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(fieldNameToLabel(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}`);
}
}
const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView;
const viewDiffs = SavedViewUtils.diffViews(tableMetaData, baseView, view);
let viewIsModified = false;
let viewDiffs:string[] = [];
if(currentSavedView != null)
if(viewDiffs.length > 0)
{
const savedView = JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView;
const activeView = view;
diffFilters(savedView, activeView, viewDiffs);
diffColumns(savedView, activeView, viewDiffs);
diffQuickFilterFieldNames(savedView, activeView, viewDiffs);
if(savedView.mode != activeView.mode)
{
if(savedView.mode)
{
viewDiffs.push(`Mode changed from ${savedView.mode} to ${activeView.mode}`)
}
else
{
viewDiffs.push(`Mode set to ${activeView.mode}`)
}
}
if(savedView.rowsPerPage != activeView.rowsPerPage)
{
if(savedView.rowsPerPage)
{
viewDiffs.push(`Rows per page changed from ${savedView.rowsPerPage} to ${activeView.rowsPerPage}`)
}
else
{
viewDiffs.push(`Rows per page set to ${activeView.rowsPerPage}`)
}
}
if(viewDiffs.length > 0)
{
viewIsModified = true;
}
viewIsModified = true;
}
/*******************************************************************************
** make request to load all saved filters from backend
*******************************************************************************/
@ -534,8 +163,13 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
switch(optionName)
{
case SAVE_OPTION:
if(currentSavedView == null)
{
setSavedViewNameInputValue("");
}
break;
case DUPLICATE_OPTION:
setSavedViewNameInputValue("");
setIsSaveFilterAs(true);
break;
case CLEAR_OPTION:
@ -760,13 +394,13 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
keepMounted
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minHeight: "200px"}}}
>
<MenuItem sx={{width: "300px"}} disabled style={{"opacity": "initial"}}><b>Actions</b></MenuItem>
<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>
Save...
{currentSavedView ? "Save..." : "Save As..."}
</MenuItem>
</Tooltip>
}
@ -815,48 +449,150 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
</MenuItem>
)
): (
<MenuItem >
<i>No views have been saved for this table.</i>
<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(loadingSavedView)
{
buttonText = "Loading...";
}
else if(currentSavedView)
{
buttonText = currentSavedView.values.get("label")
}
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 display="flex" flexGrow={1}>
<QSavedViewsMenuButton isOpen={savedViewsMenu} onClickHandler={openSavedViewsMenu} />
{renderSavedViewsMenu}
<Box display="flex" justifyContent="center" flexDirection="column">
<>
<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"}}>
{
savedViewsHaveLoaded && currentSavedView && (
<Typography mr={2} variant="h6">Current View:&nbsp;
<span style={{fontWeight: "initial"}}>
!currentSavedView && viewIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
loadingSavedView
? "..."
:
<>
{currentSavedView.values.get("label")}
{
viewIsModified && (
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>The current view has been modified:
<ul style={{padding: "1rem"}}>
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul>Click &quot;Save...&quot; to save the changes.</>}>
<FiberManualRecord sx={{color: "orange", paddingLeft: "2px", paddingTop: "4px"}} />
</Tooltip>
)
}
</>
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</span>
</Typography>
)
</ul>
</>}>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save View As&hellip;</Button>
</Tooltip>
{/* vertical rule */}
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset All Changes</Button>
</>
}
{
currentSavedView && viewIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul></>}>
<Box display="inline" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>{viewDiffs.length} Unsaved Change{viewDiffs.length == 1 ? "" : "s"}</Box>
</Tooltip>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save&hellip;</Button>
{/* vertical rule */}
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ... linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset All Changes</Button>
</>
}
</Box>
</Box>
@ -917,7 +653,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
autoFocus
name="custom-delimiter-value"
placeholder="View Name"
label="View Name"
inputProps={{width: "100%", maxLength: 100}}
value={savedViewNameInputValue}
sx={{width: "100%"}}
@ -943,12 +678,12 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie
isDeleteFilter ?
<QDeleteButton onClickHandler={handleFilterDialogButtonOnClick} disabled={isSubmitting} />
:
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={isSubmitting || ((isSaveFilterAs || currentSavedView == null) && savedViewNameInputValue == null)}/>
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={isSaveButtonDisabled()}/>
}
</DialogActions>
</Dialog>
}
</Box>
</>
) : null
);
}

View File

@ -27,8 +27,9 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Badge, ToggleButton, ToggleButtonGroup, Typography} from "@mui/material";
import {Badge, ToggleButton, ToggleButtonGroup} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
@ -37,15 +38,17 @@ import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import Icon from "@mui/material/Icon";
import Menu from "@mui/material/Menu";
import Tooltip from "@mui/material/Tooltip";
import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro";
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react";
import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import 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";
@ -54,6 +57,9 @@ interface BasicAndAdvancedQueryControlsProps
metaData: QInstance;
tableMetaData: QTableMetaData;
savedViewsComponent: JSX.Element;
columnMenuComponent: JSX.Element;
quickFilterFieldNames: string[];
setQuickFilterFieldNames: (names: string[]) => void;
@ -83,7 +89,7 @@ let debounceTimeout: string | number | NodeJS.Timeout;
*******************************************************************************/
const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) =>
{
const {metaData, tableMetaData, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props
const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props
/////////////////////
// state variables //
@ -95,6 +101,8 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false);
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 //
//////////////////////////////////////////////////////////////////////////////////
@ -279,6 +287,11 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
const fieldName = newValue ? newValue.fieldName : null;
if (fieldName)
{
if(defaultQuickFilterFieldNameMap[fieldName])
{
return;
}
if (quickFilterFieldNames.indexOf(fieldName) == -1)
{
/////////////////////////////////
@ -309,7 +322,22 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
/*******************************************************************************
** event handler for the Filter Buidler button - e.g., opens the parent's grid's
**
*******************************************************************************/
const handleFieldListMenuSelection = (field: QFieldMetaData, table: QTableMetaData): void =>
{
let fullFieldName = field.name;
if(table && table.name != tableMetaData.name)
{
fullFieldName = `${table.name}.${field.name}`;
}
addQuickFilterField({fieldName: fullFieldName}, "selectedFromAddFilterMenu");
}
/*******************************************************************************
** event handler for the Filter Builder button - e.g., opens the parent's grid's
** filter panel
*******************************************************************************/
const openFilterBuilder = (e: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>) =>
@ -326,11 +354,21 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
if (isYesButton || event.key == "Enter")
{
setShowClearFiltersWarning(false);
setQueryFilter(new QQueryFilter());
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.
*******************************************************************************/
@ -344,20 +382,20 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
let counter = 0;
return (
<span>
<Box display="flex" flexWrap="wrap" fontSize="0.875rem">
{queryFilter.criteria.map((criteria, i) =>
{
const {criteriaIsValid} = validateCriteria(criteria, null);
if(criteriaIsValid)
{
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName);
counter++;
return (
<span key={i}>
{counter > 1 ? <span>{queryFilter.booleanOperator}&nbsp;</span> : <span/>}
<React.Fragment key={i}>
{counter > 1 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{queryFilter.booleanOperator}&nbsp;</span> : <span/>}
{FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)}
</span>
<XIcon position="forAdvancedQueryPreview" onClick={() => removeCriteriaByIndex(i)} />
</React.Fragment>
);
}
else
@ -365,7 +403,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
return (<span />);
}
})}
</span>
</Box>
);
};
@ -427,12 +465,15 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
return;
}
for (let i = 0; i < queryFilter?.criteria?.length; i++)
if(mode == "basic")
{
const criteria = queryFilter.criteria[i];
if (criteria && criteria.fieldName)
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
addQuickFilterField(criteria, reason);
const criteria = queryFilter.criteria[i];
if (criteria && criteria.fieldName)
{
addQuickFilterField(criteria, reason);
}
}
}
}
@ -455,6 +496,70 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
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... //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -481,140 +586,172 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
</>
}
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}
/>);
return (
<Box display="flex" alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" position="relative" top={"-0.5rem"} left={"0.5rem"} minHeight="2.5rem">
<Box display="flex" alignItems="center" flexShrink={1} flexGrow={1}>
<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 width="100px" flexShrink={1} flexGrow={1}>
<>
{
tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) =>
<Box display="flex" alignItems="flex-start" flexShrink={1} flexGrow={1}>
<Box width="100px" flexShrink={1} flexGrow={1}>
<>
{
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let defaultOperator = getDefaultOperatorForField(field);
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} />);
})
}
<Box display="inline-block" borderLeft="1px solid gray" height="1.75rem" width="1px" marginRight="0.5rem" position="relative" top="0.5rem" />
{
tableMetaData && quickFilterFieldNames?.map((fieldName) =>
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" />
{
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let defaultOperator = getDefaultOperatorForField(field);
tableMetaData && quickFilterFieldNames?.map((fieldName) =>
{
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let defaultOperator = getDefaultOperatorForField(field);
return (defaultQuickFilterFieldNameMap[fieldName] ? null : <QuickFilter
key={fieldName}
fullFieldName={fieldName}
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}
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
defaultOperator={defaultOperator}
handleRemoveQuickFilterField={handleRemoveQuickFilterField} />);
})
}
{
tableMetaData &&
<>
<Tooltip enterDelay={500} title="Add a Quick Filter field" placement="top">
<Button onClick={(e) => openAddQuickFilterMenu(e)} startIcon={<Icon>add</Icon>} sx={{...quickFilterButtonStyles}}>
Add Filter
</Button>
</Tooltip>
<Menu
anchorEl={addQuickFilterMenu}
anchorOrigin={{vertical: "bottom", horizontal: "left"}}
transformOrigin={{vertical: "top", horizontal: "left"}}
transitionDuration={0}
open={Boolean(addQuickFilterMenu)}
onClose={closeAddQuickFilterMenu}
keepMounted
>
<Box width="250px">
<FieldAutoComplete
key={addQuickFilterOpenCounter} // use a unique key each time we open it, because we don't want the user's last selection to stick.
id={"add-quick-filter-field"}
metaData={metaData}
tableMetaData={tableMetaData}
defaultValue={null}
handleFieldChange={(e, newValue, reason) => addQuickFilterField(newValue, reason)}
autoFocus={true}
forceOpen={Boolean(addQuickFilterMenu)}
hiddenFieldNames={[...(defaultQuickFilterFieldNames??[]), ...(quickFilterFieldNames??[])]}
/>
</Box>
</Menu>
</>
}
</>
fieldNamesToHide={[...(defaultQuickFilterFieldNames ?? []), ...(quickFilterFieldNames ?? [])]}
placeholder="Search Fields"
buttonProps={{sx: quickFilterButtonStyles, startIcon: (<Icon>add</Icon>)}}
buttonChildren={"Add Filter"}
isModeSelectOne={true}
handleSelectedField={handleFieldListMenuSelection}
/>
}
</>
</Box>
<Box>
{sortMenuComponent}
</Box>
</Box>
}
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// advanced mode - 2 rows - one for Filter Builder button & sort control, 2nd row for the filter-detail box //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
metaData && tableMetaData && mode == "advanced" &&
<>
<Tooltip enterDelay={500} title="Build an advanced Filter" placement="top">
<Button onClick={(e) => openFilterBuilder(e)} startIcon={<Badge badgeContent={countValidCriteria(queryFilter)} color="warning" sx={{"& .MuiBadge-badge": {color: "#FFFFFF"}}} anchorOrigin={{vertical: "top", horizontal: "left"}}><Icon>filter_list</Icon></Badge>} sx={{width: "180px", minWidth: "180px", border: "1px solid gray"}}>
Filter Builder
</Button>
</Tooltip>
<div id="clearFiltersButton" style={{display: "inline-block", position: "relative", top: "2px", left: "-1.5rem", width: "1rem"}}>
{
hasValidFilters && (
<Box borderRadius="0.75rem" border={`1px solid ${borderGray}`}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box p="0.5rem">
<Tooltip enterDelay={500} title="Build an advanced Filter" placement="top">
<>
<Tooltip title="Clear Filter">
<Icon sx={{cursor: "pointer"}} onClick={() => setShowClearFiltersWarning(true)}>clear</Icon>
</Tooltip>
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(true)} onKeyPress={(e) => handleClearFiltersAction(e)}>
<DialogTitle id="alert-dialog-title">Confirm</DialogTitle>
<DialogContent>
<DialogContentText>Are you sure you want to remove all conditions from the current filter?</DialogContentText>
</DialogContent>
<DialogActions>
<QCancelButton label="No" disabled={false} onClickHandler={() => setShowClearFiltersWarning(true)} />
<QSaveButton label="Yes" iconName="check" disabled={false} onClickHandler={() => handleClearFiltersAction(null, true)} />
</DialogActions>
</Dialog>
<Button
onClick={(e) => openFilterBuilder(e)}
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 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 && <span className="filterBuilderXIcon"><XIcon shade="accent" position="default" onClick={() => setShowClearFiltersWarning(true)} /></span>
}
</>
)
}
</div>
<Box sx={{fontSize: "1rem"}} whiteSpace="nowrap" display="flex" ml={0.25} flexShrink={1} flexGrow={1} alignItems="center">
Current Filter:
</Tooltip>
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(false)} onKeyPress={(e) => handleClearFiltersAction(e)}>
<DialogTitle id="alert-dialog-title">Confirm</DialogTitle>
<DialogContent>
<DialogContentText>Are you sure you want to remove all conditions from the current filter?</DialogContentText>
</DialogContent>
<DialogActions>
<QCancelButton label="No" disabled={false} onClickHandler={() => setShowClearFiltersWarning(false)} />
<QSaveButton label="Yes" iconName="check" disabled={false} onClickHandler={() => handleClearFiltersAction(null, true)} />
</DialogActions>
</Dialog>
</Box>
<Box pr={"0.5rem"}>
{sortMenuComponent}
</Box>
</Box>
<Box whiteSpace="nowrap" display="flex" flexShrink={1} flexGrow={1} alignItems="center">
{
<Box display="inline-block" border="1px solid gray" borderRadius="0.5rem" whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis" width="100px" flexShrink={1} flexGrow={1} sx={{fontSize: "1rem"}} minHeight={"2rem"} p={0.25} ml={0.5}>
<Box
display="inline-block"
borderTop={`1px solid ${borderGray}`}
borderRadius="0 0 0.75rem 0.75rem"
width="100%"
sx={{fontSize: "1rem", background: "#FFFFFF"}}
minHeight={"2.5rem"}
p={"0.5rem"}
pb={0} // comes from the elements inside
boxShadow={"inset 0px 0px 4px 2px #EFEFED"}
>
{queryToAdvancedString()}
</Box>
}
</Box>
</>
}
</Box>
<Box display="flex" alignItems="center">
{
metaData && tableMetaData &&
<Box px={1} display="flex" alignItems="center">
<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>
@ -668,4 +805,4 @@ export function getDefaultQuickFilterFieldNames(table: QTableMetaData): string[]
return (defaultQuickFilterFieldNames);
}
export default BasicAndAdvancedQueryControls;
export default BasicAndAdvancedQueryControls;

View File

@ -0,0 +1,726 @@
/*
* 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";
return (
<>
<Button onClick={openMenu} {...buttonProps}>
{buttonChildren}
</Button>
<Menu
anchorEl={menuAnchorElement}
anchorOrigin={{vertical: "bottom", horizontal: "left"}}
transformOrigin={{vertical: "top", horizontal: "left"}}
open={menuAnchorElement != null}
onClose={closeMenu}
onKeyDown={keyDown} // this is added here so arrow-key-up/down events don't make the whole menu become "focused" (blue outline). it works.
keepMounted
>
<Box width={isModeToggle ? "305px" : "265px"} borderRadius={2} className={`fieldListMenuBody fieldListMenuBody-${idPrefix}`}>
{
heading &&
<Box px={1} py={0.5} fontWeight={"700"}>
{heading}
</Box>
}
<Box p={1} pt={0.5}>
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}}} />
{
searchText != "" && <IconButton sx={{position: "absolute", right: "0.5rem", top: "0.5rem"}} onClick={() =>
{
updateSearch(null);
document.getElementById(textFieldId).focus();
}}><Icon fontSize="small">close</Icon></IconButton>
}
</Box>
<Box maxHeight={"445px"} overflow="auto" mr={"-0.5rem"} sx={{scrollbarGutter: "stable"}}>
<List sx={{px: "0.5rem", cursor: "default"}}>
{
fieldsByTableToShow.map((tableWithFields) =>
{
let headerContents = null;
const headerTable = tableWithFields.table || tableMetaData;
if(tableWithFields.table || showTableHeaderEvenIfNoExposedJoins)
{
headerContents = (<b>{headerTable.label} Fields</b>);
}
if(isModeToggle)
{
headerContents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", fontWeight: "500 !important"}}}
control={<Switch
size="small"
sx={{top: "1px"}}
checked={tableToggleStates[headerTable.name]}
onChange={(event) => handleTableToggle(event, headerTable)}
/>}
label={<span style={{marginTop: "0.25rem", display: "inline-block"}}><b>{headerTable.label} Fields</b>&nbsp;<span style={{fontWeight: 400}}>({tableToggleCounts[headerTable.name]})</span></span>} />)
}
if(isModeToggle)
{
headerContents = (
<>
<IconButton
onClick={() => toggleCollapsedTable(headerTable.name)}
sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}}
disableRipple={true}
>
<Icon sx={{fontSize: "1.5rem !important", position: "relative", top: "2px"}}>{collapsedTables[headerTable.name] ? "expand_less" : "expand_more"}</Icon>
</IconButton>
{headerContents}
</>
)
}
let marginLeft = "unset";
if(isModeToggle)
{
marginLeft = "-1rem";
}
return (
<React.Fragment key={tableWithFields.table?.name ?? "theTable"}>
<>
{headerContents && <ListItem sx={{position: "sticky", top: "0", backgroundColor: "#FFFFFF", zIndex: 1, padding: listItemPadding, ml: marginLeft, display: "flex", alignItems: "flex-start"}}>{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", ...style}}
onMouseOver={(event) => handleMouseOver(event, field, tableWithFields.table)}
{...onClick}
>{contents}</ListItem>;
})
}
</>
</React.Fragment>
);
})
}
{
index == -1 && <ListItem sx={{p: "0.5rem"}}><i>No fields found.</i></ListItem>
}
</List>
</Box>
</Box>
</Menu>
</>
);
}

View File

@ -203,7 +203,7 @@ FilterCriteriaRow.defaultProps =
{
};
export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string}
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.";

View File

@ -37,6 +37,7 @@ import QContext from "QContext";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import {getDefaultCriteriaValue, getOperatorOptions, 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";
@ -62,8 +63,17 @@ QuickFilter.defaultProps =
let seedId = new Date().getTime() % 173237;
export const quickFilterButtonStyles = {
fontSize: "0.75rem", color: "#757575", textTransform: "none", borderRadius: "2rem", border: "1px solid #757575",
minWidth: "3.5rem", minHeight: "auto", padding: "0.375rem 0.625rem", whiteSpace: "nowrap"
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"
}
/*******************************************************************************
@ -439,23 +449,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
}
}
/////////////////////////////////////////////////////////////////////////////////////
// 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 //
/////////////////////////////////////////////////////////////////////////////////////
let xIcon = <span />;
if(criteriaIsValid || handleRemoveQuickFilterField)
{
xIcon = <span style={{position: "relative"}}><IconButton sx={{
fontSize: "0.75rem",
border: "1px solid gray",
padding: "0",
background: "#f0f2f5 !important",
position: "absolute",
left: "-1.125rem",
}} onClick={xClicked}><Icon>close</Icon></IconButton></span>
}
//////////////////////////////
// return the button & menu //
//////////////////////////////
@ -463,7 +456,13 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
return (
<>
{button}
{xIcon}
{
/////////////////////////////////////////////////////////////////////////////////////
// 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) && <XIcon shade={criteriaIsValid ? "accent" : "default"} position="forQuickFilter" onClick={xClicked} />
}
{
isOpen && <Menu open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={closeMenu} sx={{overflow: "visible"}}>
<Box display="inline-block" width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="operatorColumn">
@ -488,7 +487,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
operatorOption={operatorSelectedValue}
criteria={criteria}
field={fieldMetaData}
table={tableMetaData} // todo - joins?
table={tableForField}
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
initiallyOpenMultiValuePvs={true} // todo - maybe not?
/>

View File

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

View File

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

View File

@ -114,6 +114,59 @@ export default class QQueryColumns
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;
}
}
}
/*******************************************************************************
**
*******************************************************************************/

View File

@ -33,7 +33,7 @@ import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QC
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Alert, Collapse, Typography} from "@mui/material";
import {Alert, Collapse, Menu, Typography} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
@ -44,21 +44,22 @@ import LinearProgress from "@mui/material/LinearProgress";
import MenuItem from "@mui/material/MenuItem";
import Modal from "@mui/material/Modal";
import Tooltip from "@mui/material/Tooltip";
import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} from "@mui/x-data-grid-pro";
import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnHeaderParams, GridColumnHeaderSortIconProps, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} from "@mui/x-data-grid-pro";
import {GridRowModel} from "@mui/x-data-grid/models/gridRows";
import FormData from "form-data";
import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react";
import {useLocation, useNavigate, useSearchParams} from "react-router-dom";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QCreateNewButton} from "qqq/components/buttons/DefaultButtons";
import MenuButton from "qqq/components/buttons/MenuButton";
import {GotoRecordButton} from "qqq/components/misc/GotoRecordDialog";
import SavedViews from "qqq/components/misc/SavedViews";
import BasicAndAdvancedQueryControls from "qqq/components/query/BasicAndAdvancedQueryControls";
import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel";
import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel";
import CustomPaginationComponent from "qqq/components/query/CustomPaginationComponent";
import ExportMenuItem from "qqq/components/query/ExportMenuItem";
import FieldListMenu from "qqq/components/query/FieldListMenu";
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import QueryScreenActionMenu from "qqq/components/query/QueryScreenActionMenu";
import SelectionSubsetDialog from "qqq/components/query/SelectionSubsetDialog";
@ -74,6 +75,7 @@ import DataGridUtils from "qqq/utils/DataGridUtils";
import Client from "qqq/utils/qqq/Client";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
@ -246,14 +248,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const [pageState, setPageState] = useState("initial" as PageState)
///////////////////////////////////////////////////
// state used by the custom column-chooser panel //
///////////////////////////////////////////////////
const initialColumnChooserOpenGroups = {} as { [name: string]: boolean };
initialColumnChooserOpenGroups[tableName] = true;
const [columnChooserOpenGroups, setColumnChooserOpenGroups] = useState(initialColumnChooserOpenGroups);
const [columnChooserFilterText, setColumnChooserFilterText] = useState("");
/////////////////////////////////
// meta-data and derived state //
/////////////////////////////////
@ -285,6 +279,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
const [currentSavedView, setCurrentSavedView] = useState(null as QRecord);
const [viewIdInLocation, setViewIdInLocation] = useState(null as number);
const [loadingSavedView, setLoadingSavedView] = useState(false);
const [exportMenuAnchorElement, setExportMenuAnchorElement] = useState(null);
const [tableDefaultView, setTableDefaultView] = useState(new RecordQueryView());
/////////////////////////////////////////////////////
// state related to avoiding accidental row clicks //
@ -342,7 +338,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
/////////////////////////////
// page context references //
/////////////////////////////
const {setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext);
const {accentColor, accentColorLight, setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext);
//////////////////////////////////////////////////////////////////
// we use our own header - so clear out the context page header //
//////////////////////////////////////////////////////////////////
setPageHeader(null);
//////////////////////
// ole' faithful... //
@ -416,6 +417,97 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
return (false);
}
/*******************************************************************************
**
*******************************************************************************/
const prepQueryFilterForBackend = (sourceFilter: QQueryFilter) =>
{
const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.booleanOperator);
for (let i = 0; i < sourceFilter?.criteria?.length; i++)
{
const criteria = sourceFilter.criteria[i];
const {criteriaIsValid} = validateCriteria(criteria, null);
if (criteriaIsValid)
{
if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK)
{
///////////////////////////////////////////////////////////////////////////////////////////
// do this to avoid submitting an empty-string argument for blank/not-blank operators... //
///////////////////////////////////////////////////////////////////////////////////////////
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, []));
}
else
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName)
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field)));
}
}
}
filterForBackend.skip = pageNumber * rowsPerPage;
filterForBackend.limit = rowsPerPage;
return filterForBackend;
}
/*******************************************************************************
**
*******************************************************************************/
function openExportMenu(event: any)
{
setExportMenuAnchorElement(event.currentTarget);
}
/*******************************************************************************
**
*******************************************************************************/
function closeExportMenu()
{
setExportMenuAnchorElement(null);
}
///////////////////////////////////////////
// build the export menu, for the header //
///////////////////////////////////////////
let exportMenu = <></>
try
{
const exportMenuItemRestProps =
{
tableMetaData: tableMetaData,
totalRecords: totalRecords,
columnsModel: columnsModel,
columnVisibilityModel: columnVisibilityModel,
queryFilter: prepQueryFilterForBackend(queryFilter)
}
exportMenu = (<>
<IconButton sx={{p: 0, fontSize: "0.75rem", mb: 1, color: colors.secondary.main, fontVariationSettings: "'wght' 100"}} onClick={openExportMenu}><Icon fontSize="small">save_alt</Icon></IconButton>
<Menu
anchorEl={exportMenuAnchorElement}
anchorOrigin={{vertical: "bottom", horizontal: "center"}}
transformOrigin={{vertical: "top", horizontal: "center"}}
open={exportMenuAnchorElement != null}
onClose={closeExportMenu}
sx={{top: "0.5rem"}}
keepMounted>
<ExportMenuItem format="csv" {...exportMenuItemRestProps} />
<ExportMenuItem format="xlsx" {...exportMenuItemRestProps} />
<ExportMenuItem format="json" {...exportMenuItemRestProps} />
</Menu>
</>);
}
catch(e)
{
console.log("Error preparing export menu for page header: " + e);
}
/*******************************************************************************
**
*******************************************************************************/
@ -459,9 +551,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
return(
<div>
{label}
{label} {exportMenu}
<CustomWidthTooltip title={tooltipHTML}>
<IconButton sx={{p: 0, fontSize: "0.5rem", mb: 1, color: "#9f9f9f", fontVariationSettings: "'wght' 100"}}><Icon fontSize="small">emergency</Icon></IconButton>
<IconButton sx={{ml: "0.5rem", p: 0, fontSize: "0.5rem", mb: 1, color: "#9f9f9f", fontVariationSettings: "'wght' 100"}}><Icon fontSize="small">emergency</Icon></IconButton>
</CustomWidthTooltip>
{tableVariant && getTableVariantHeader(tableVariant)}
</div>);
@ -470,7 +562,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{
return (
<div>
{label}
{label} {exportMenu}
{tableVariant && getTableVariantHeader(tableVariant)}
</div>);
}
@ -569,7 +661,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
// in case page-state has already advanced to "ready" (e.g., and we're dealing with a user //
// hitting back & forth between filters), then do a load of the new saved-view right here //
/////////////////////////////////////////////////////////////////////////////////////////////
if(pageState == "ready")
if (pageState == "ready")
{
handleSavedViewChange(currentSavedViewId);
}
@ -593,6 +685,19 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
}
}, [location]);
/*******************************************************************************
** set the current view in state & local-storage - but do NOT update any
** child-state data.
*******************************************************************************/
const doSetView = (view: RecordQueryView): void =>
{
setView(view);
setViewAsJson(JSON.stringify(view));
localStorage.setItem(viewLocalStorageKey, JSON.stringify(view));
}
/*******************************************************************************
**
*******************************************************************************/
@ -607,6 +712,40 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
forceUpdate();
};
/*******************************************************************************
** function called by columns menu to turn a column on or off
*******************************************************************************/
const handleChangeOneColumnVisibility = (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) =>
{
///////////////////////////////////////
// set the field's value in the view //
///////////////////////////////////////
let fieldName = field.name;
if(table && table.name != tableMetaData.name)
{
fieldName = `${table.name}.${field.name}`;
}
view.queryColumns.setIsVisible(fieldName, newValue)
/////////////////////
// update the grid //
/////////////////////
setColumnVisibilityModel(queryColumns.toColumnVisibilityModel());
/////////////////////////////////////////////////
// update the view (e.g., write local storage) //
/////////////////////////////////////////////////
doSetView(view)
///////////////////
// ole' faithful //
///////////////////
forceUpdate();
}
/*******************************************************************************
**
*******************************************************************************/
@ -656,43 +795,32 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
/*******************************************************************************
**
** return array of table names that need ... added to query
*******************************************************************************/
const prepQueryFilterForBackend = (sourceFilter: QQueryFilter) =>
const ensureOrderBysFromJoinTablesAreVisibleTables = (queryFilter: QQueryFilter, visibleJoinTablesParam?: Set<string>): string[] =>
{
const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.booleanOperator);
for (let i = 0; i < sourceFilter?.criteria?.length; i++)
const rs: string[] = [];
const vjtToUse = visibleJoinTablesParam ?? visibleJoinTables;
for (let i = 0; i < queryFilter?.orderBys?.length; i++)
{
const criteria = sourceFilter.criteria[i];
const {criteriaIsValid} = validateCriteria(criteria, null);
if (criteriaIsValid)
const fieldName = queryFilter.orderBys[i].fieldName;
if(fieldName.indexOf(".") > -1)
{
if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK)
const joinTableName = fieldName.replaceAll(/\..*/g, "");
if(!vjtToUse.has(joinTableName))
{
///////////////////////////////////////////////////////////////////////////////////////////
// do this to avoid submitting an empty-string argument for blank/not-blank operators... //
///////////////////////////////////////////////////////////////////////////////////////////
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, []));
}
else
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName)
filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field)));
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
handleChangeOneColumnVisibility(field, fieldTable, true);
rs.push(fieldTable.name);
}
}
}
filterForBackend.skip = pageNumber * rowsPerPage;
filterForBackend.limit = rowsPerPage;
// FilterUtils.convertFilterPossibleValuesToIds(filterForBackend);
// todo - expressions?
// todo - utc
return filterForBackend;
return (rs);
}
/*******************************************************************************
** This is the method that actually executes a query to update the data in the table.
*******************************************************************************/
@ -729,6 +857,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
if (tableMetaData?.exposedJoins)
{
const visibleJoinTables = getVisibleJoinTables();
const tablesToAdd = ensureOrderBysFromJoinTablesAreVisibleTables(queryFilter, visibleJoinTables);
tablesToAdd?.forEach(t => visibleJoinTables.add(t));
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
}
@ -1062,15 +1194,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
doSetQueryFilter(queryFilter);
};
/*******************************************************************************
** set the current view in state & local-storage - but do NOT update any
** child-state data.
**
*******************************************************************************/
const doSetView = (view: RecordQueryView): void =>
const handleColumnHeaderClick = (params: GridColumnHeaderParams, event: MuiEvent, details: GridCallbackDetails): void =>
{
setView(view);
setViewAsJson(JSON.stringify(view));
localStorage.setItem(viewLocalStorageKey, JSON.stringify(view));
event.defaultMuiPrevented = true;
}
@ -1112,6 +1242,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{
console.log(`Setting a new query filter: ${JSON.stringify(queryFilter)}`);
///////////////////////////////////////////////////
// when we have a new filter, go back to page 0. //
///////////////////////////////////////////////////
setPageNumber(0);
///////////////////////////////////////////////////
// in case there's no orderBys, set default here //
///////////////////////////////////////////////////
@ -1121,6 +1256,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
view.queryFilter = queryFilter;
}
////////////////////////////////////////////////////////////////////////////////////////////////
// in case the order-by is from a join table, and that table doesn't have any visible fields, //
// then activate the order-by field itself //
////////////////////////////////////////////////////////////////////////////////////////////////
ensureOrderBysFromJoinTablesAreVisibleTables(queryFilter);
//////////////////////////////
// set the filter state var //
//////////////////////////////
setQueryFilter(queryFilter);
///////////////////////////////////////////////////////
@ -1423,6 +1567,22 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
}
}
/*******************************************************************************
**
*******************************************************************************/
const buildTableDefaultView = (): RecordQueryView =>
{
const newDefaultView = new RecordQueryView();
newDefaultView.queryFilter = new QQueryFilter([], [new QFilterOrderBy(tableMetaData.primaryKeyField, false)]);
newDefaultView.queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData);
newDefaultView.viewIdentity = "empty";
newDefaultView.rowsPerPage = defaultRowsPerPage;
newDefaultView.quickFilterFieldNames = [];
newDefaultView.mode = defaultMode;
return newDefaultView;
}
/*******************************************************************************
** event handler for SavedViews component, to handle user selecting a view
** (or clearing / selecting new)
@ -1453,18 +1613,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
setCurrentSavedView(null);
localStorage.removeItem(currentSavedViewLocalStorageKey);
/////////////////////////////////////////////////////
// go back to a default query filter for the table //
/////////////////////////////////////////////////////
doSetQueryFilter(new QQueryFilter());
const queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData);
doSetQueryColumns(queryColumns)
/////////////////////////////////////////////////////
// also reset the (user-added) quick-filter fields //
/////////////////////////////////////////////////////
doSetQuickFilterFieldNames([]);
///////////////////////////////////////////////
// activate a new default view for the table //
///////////////////////////////////////////////
activateView(buildTableDefaultView())
}
}
@ -1848,36 +2000,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
}
};
//////////////////////////////////////////////////////////////////
// props that get passed into all of the ExportMenuItem's below //
//////////////////////////////////////////////////////////////////
const exportMenuItemRestProps =
{
tableMetaData: tableMetaData,
totalRecords: totalRecords,
columnsModel: columnsModel,
columnVisibilityModel: columnVisibilityModel,
queryFilter: prepQueryFilterForBackend(queryFilter)
}
return (
<GridToolbarContainer>
<div>
<Button id="refresh-button" onClick={() => updateTable("refresh button")} startIcon={<Icon>refresh</Icon>} sx={{pr: "1.25rem"}}>
Refresh
</Button>
<Button id="refresh-button" onClick={() => updateTable("refresh button")} startIcon={<Icon>refresh</Icon>} sx={{pl: "1rem", pr: "0.5rem", minWidth: "unset"}}></Button>
</div>
{/* @ts-ignore */}
<GridToolbarColumnsButton nonce={undefined} />
<div style={{position: "relative"}}>
{/* @ts-ignore */}
<GridToolbarDensitySelector nonce={undefined} />
{/* @ts-ignore */}
<GridToolbarExportContainer nonce={undefined}>
<ExportMenuItem format="csv" {...exportMenuItemRestProps} />
<ExportMenuItem format="xlsx" {...exportMenuItemRestProps} />
<ExportMenuItem format="json" {...exportMenuItemRestProps} />
</GridToolbarExportContainer>
</div>
<div style={{zIndex: 10}}>
@ -1990,15 +2120,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
);
}
/*******************************************************************************
** maybe something to do with how page header is in a context, but, it didn't
** work to check pageLoadingState.isLoadingSlow inside an element that we put
** in the page header, so, this works instead.
*******************************************************************************/
const setPageHeaderToLoadingSlow = (): void =>
{
setPageHeader("Loading...")
}
/////////////////////////////////////////////////////////////////////////////////
// use this to make changes to the queryFilter more likely to re-run the query //
@ -2024,12 +2145,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
setPageState("loadingMetaData");
pageLoadingState.setLoading();
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// reset the page header to blank, and tell the pageLoadingState object that if it becomes slow, to show 'Loading' //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
setPageHeader("");
pageLoadingState.setUponSlowCallback(setPageHeaderToLoadingSlow);
(async () =>
{
const metaData = await qController.loadMetaData();
@ -2056,6 +2171,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
(async () =>
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// now that we know the table - build a default view - initially, only used by SavedViews component, for showing if there's anything to be saved. //
// but also used when user selects new-view from the view menu //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const newDefaultView = buildTableDefaultView();
setTableDefaultView(newDefaultView);
//////////////////////////////////////////////////////////////////////////////////////////////
// once we've loaded meta data, let's check the location to see if we should open a process //
//////////////////////////////////////////////////////////////////////////////////////////////
@ -2212,7 +2334,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
(async () =>
{
const visibleJoinTables = getVisibleJoinTables();
setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, tableVariant));
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// todo - we used to be able to set "warnings" here (i think, like, for if a field got deleted from a table... //
@ -2362,11 +2483,135 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
return (getLoadingScreen());
}
let savedViewsComponent = null;
if(metaData && metaData.processes.has("querySavedView"))
{
savedViewsComponent = (<SavedViews qController={qController} metaData={metaData} tableMetaData={tableMetaData} view={view} viewAsJson={viewAsJson} currentSavedView={currentSavedView} tableDefaultView={tableDefaultView} viewOnChangeCallback={handleSavedViewChange} loadingSavedView={loadingSavedView} />);
}
/*******************************************************************************
**
*******************************************************************************/
const buildColumnMenu = () =>
{
//////////////////////////////////////////
// default (no saved view, and "clean") //
//////////////////////////////////////////
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
////////////////////////////////////////////////////////////////////////////////////////////////////////////
// diff the current view with either the current saved one, if there's one active, else the table default //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView;
const viewDiffs: string[] = [];
SavedViewUtils.diffColumns(tableMetaData, baseView, view, viewDiffs)
if(viewDiffs.length == 0 && currentSavedView)
{
/////////////////////////////////////////////////////////////////
// if 's a saved view, and it's "clean", show it in main style //
/////////////////////////////////////////////////////////////////
buttonBackground = accentColor;
buttonBorder = accentColor;
buttonColor = "#FFFFFF";
}
else if(viewDiffs.length > 0)
{
///////////////////////////////////////////////////
// else if there are diffs, show alt/light style //
///////////////////////////////////////////////////
buttonBackground = accentColorLight;
buttonBorder = accentColorLight;
buttonColor = accentColor;
}
const columnMenuButtonStyles = {
borderRadius: "0.75rem",
border: `1px solid ${buttonBorder}`,
color: buttonColor,
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
p: "0.5rem",
backgroundColor: buttonBackground,
"&:focus:not(:hover)": {
color: buttonColor,
backgroundColor: buttonBackground,
},
"&:hover": {
color: buttonColor,
backgroundColor: buttonBackground,
}
}
return (<Box order="2">
<FieldListMenu
idPrefix="columns"
tableMetaData={tableMetaData}
showTableHeaderEvenIfNoExposedJoins={true}
placeholder="Search Fields"
buttonProps={{sx: columnMenuButtonStyles}}
buttonChildren={<><Icon sx={{mr: "0.5rem"}}>view_week_outline</Icon> Columns ({view.queryColumns.getVisibleColumnCount()}) <Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon></>}
isModeToggle={true}
toggleStates={view.queryColumns.getVisibilityToggleStates()}
handleToggleField={handleChangeOneColumnVisibility}
/>
</Box>);
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////
// these numbers help set the height of the grid (so page won't scroll) based on spcae above & below it //
//////////////////////////////////////////////////////////////////////////////////////////////////////////
let spaceBelowGrid = 40;
let spaceAboveGrid = 205;
if(tableMetaData?.usesVariants)
{
spaceAboveGrid += 30;
}
if(mode == "advanced")
{
spaceAboveGrid += 60;
}
////////////////////////
// main screen render //
////////////////////////
return (
<BaseLayout>
<Box display="flex" justifyContent="space-between">
<Box>
<Typography textTransform="capitalize" variant="h3" noWrap>
{pageLoadingState.isLoading() && ""}
{pageLoadingState.isLoadingSlow() && "Loading..."}
{pageLoadingState.isNotLoading() && getPageHeader(tableMetaData, visibleJoinTables, tableVariant)}
</Typography>
</Box>
<Box>
<GotoRecordButton metaData={metaData} tableMetaData={tableMetaData} />
<Box display="inline-block" width="150px">
{
tableMetaData &&
<QueryScreenActionMenu
metaData={metaData}
tableMetaData={tableMetaData}
tableProcesses={tableProcesses}
bulkLoadClicked={bulkLoadClicked}
bulkEditClicked={bulkEditClicked}
bulkDeleteClicked={bulkDeleteClicked}
processClicked={processClicked}
/>
}
</Box>
{
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
<QCreateNewButton tablePath={metaData?.getTablePathByName(tableName)} />
}
</Box>
</Box>
<div className="recordQuery">
{/*
// see code in ExportMenuItem that would use this
@ -2411,34 +2656,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
</Collapse>
) : null
}
<Box display="flex" justifyContent="flex-end" alignItems="flex-start" mb={2}>
<Box display="flex" marginRight="auto">
{
metaData && metaData.processes.has("querySavedView") &&
<SavedViews qController={qController} metaData={metaData} tableMetaData={tableMetaData} view={view} viewAsJson={viewAsJson} currentSavedView={currentSavedView} viewOnChangeCallback={handleSavedViewChange} loadingSavedView={loadingSavedView} />
}
</Box>
<GotoRecordButton metaData={metaData} tableMetaData={tableMetaData} />
<Box display="flex" width="150px">
{
tableMetaData &&
<QueryScreenActionMenu
metaData={metaData}
tableMetaData={tableMetaData}
tableProcesses={tableProcesses}
bulkLoadClicked={bulkLoadClicked}
bulkEditClicked={bulkEditClicked}
bulkDeleteClicked={bulkDeleteClicked}
processClicked={processClicked}
/>
}
</Box>
{
table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission &&
<QCreateNewButton tablePath={metaData?.getTablePathByName(tableName)} />
}
</Box>
{
metaData && tableMetaData &&
@ -2454,6 +2671,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
gridApiRef={gridApiRef}
mode={mode}
setMode={doSetMode}
savedViewsComponent={savedViewsComponent}
columnMenuComponent={buildColumnMenu()}
/>
}
@ -2466,20 +2685,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
Pagination: CustomPagination,
LoadingOverlay: CustomLoadingOverlay,
ColumnMenu: CustomColumnMenu,
ColumnsPanel: CustomColumnsPanel,
FilterPanel: CustomFilterPanel,
// @ts-ignore - this turns these off, whether TS likes it or not...
ColumnsPanel: "", ColumnSortedDescendingIcon: "", ColumnSortedAscendingIcon: "", ColumnUnsortedIcon: "",
ColumnHeaderFilterIconButton: CustomColumnHeaderFilterIconButton,
}}
componentsProps={{
columnsPanel:
{
tableMetaData: tableMetaData,
metaData: metaData,
initialOpenedGroups: columnChooserOpenGroups,
openGroupsChanger: setColumnChooserOpenGroups,
initialFilterText: columnChooserFilterText,
filterTextChanger: setColumnChooserFilterText
},
filterPanel:
{
tableMetaData: tableMetaData,
@ -2522,12 +2733,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
onSelectionModelChange={handleSelectionChanged}
onSortModelChange={handleSortChange}
sortingOrder={["asc", "desc"]}
sortModel={columnSortModel}
onColumnHeaderClick={handleColumnHeaderClick}
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
getRowId={(row) => row.__rowIndex}
selectionModel={rowSelectionModel}
hideFooterSelectedRowCount={true}
sx={{border: 0, height: tableMetaData?.usesVariants ? "calc(100vh - 300px)" : "calc(100vh - 270px)"}}
sx={{border: 0, height: `calc(100vh - ${spaceAboveGrid + spaceBelowGrid}px)`}}
/>
</Box>
</Card>
@ -2548,7 +2759,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{
setTableVariantPromptOpen(false);
setTableVariant(value);
setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, value));
}} />
}

View File

@ -29,6 +29,13 @@
min-height: calc(100vh - 450px) !important;
}
/* we want to leave columns w/ the sortable attribute (so they have it in the column menu),
but we've turned off the click-to-sort function, so remove hand cursor */
.recordQuery .MuiDataGrid-columnHeader--sortable
{
cursor: default !important;
}
/* Disable red outlines on clicked cells */
.MuiDataGrid-cell:focus,
.MuiDataGrid-columnHeader:focus,
@ -402,7 +409,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
margin-right: 8px;
}
.custom-columns-panel .MuiSwitch-thumb
.custom-columns-panel .MuiSwitch-thumb,
.fieldListMenuBody .MuiSwitch-thumb
{
width: 15px !important;
height: 15px !important;
@ -428,7 +436,7 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
{
/* 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, 276px) !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 */

View File

@ -30,6 +30,7 @@ import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFil
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";
@ -539,9 +540,14 @@ class FilterUtils
if(styled)
{
return (<>
<b>{fieldLabel}</b> {FilterUtils.operatorToHumanString(criteria, field)} <span style={{color: "#0062FF"}}>{valuesString}</span>&nbsp;
</>);
return (
<Box display="inline" whiteSpace="nowrap" color="#FFFFFF" mb={"0.5rem"}>
<Box display="inline" p="0.125rem" pl="0.5rem" sx={{background: "#0062FF"}} borderRadius="0.5rem 0 0 0.5rem">{fieldLabel} </Box>
<Box display="inline" p="0.125rem" sx={{background: "#757575"}} borderRadius={valuesString ? "0" : "0 0.5rem 0.5rem 0"}> {FilterUtils.operatorToHumanString(criteria, field)} </Box>
{valuesString && <Box display="inline" p="0.125rem" pr="0.5rem" sx={{background: "#009971"}} borderRadius="0 0.5rem 0.5rem 0"> {valuesString}</Box>}
&nbsp;
</Box>
)
}
else
{

View File

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

View File

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

View File

@ -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;
@ -196,7 +196,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 +204,7 @@ public class QSeleniumLib
.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER)));
LOG.debug("Navigated to [" + path + "]. Breadcrumb Header: " + header.getText());
assertEquals(headerText, header.getText());
assertThat(header.getText()).contains(expectedHeaderText);
}

View File

@ -52,7 +52,7 @@ public class QueryScreenLib
*******************************************************************************/
public WebElement assertFilterButtonBadge(int valueInBadge)
{
return qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", String.valueOf(valueInBadge));
return qSeleniumLib.waitForSelectorContaining(".filterBuilderCountBadge", String.valueOf(valueInBadge));
}
@ -62,7 +62,7 @@ public class QueryScreenLib
*******************************************************************************/
public void assertNoFilterButtonBadge(int valueInBadge)
{
qSeleniumLib.waitForSelectorContainingToNotExist(".MuiBadge-root", String.valueOf(valueInBadge));
qSeleniumLib.waitForSelectorContainingToNotExist(".filterBuilderCountBadge", String.valueOf(valueInBadge));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -76,7 +76,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 //

View File

@ -71,7 +71,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
////////////////////////////////
// put table in advanced mode //
////////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.gotoAdvancedMode();
////////////////////////////////////////
@ -79,7 +79,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
////////////////////////////////////////
String filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
@ -90,7 +90,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
///////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
@ -103,7 +103,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
//////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.IN, 1, 2)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
@ -116,7 +116,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
/////////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS))));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
@ -129,7 +129,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("firstName", QCriteriaOperator.STARTS_WITH, "Dar"))
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, ThisOrLastPeriod.this_(ChronoUnit.YEARS))));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(2);
queryScreenLib.clickFilterButton();
@ -152,7 +152,7 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
//////////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertFilterButtonBadge(1);
queryScreenLib.clickFilterButton();
@ -162,7 +162,8 @@ public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest
////////////////
// remove one //
////////////////
qSeleniumLib.waitForSelectorContaining(".MuiIcon-root", "close").click();
qSeleniumLib.tryMultiple(3, () -> qSeleniumLib.waitForSelector(".filterBuilderXIcon BUTTON").click());
qSeleniumLib.waitForSelectorContaining("BUTTON", "Yes").click();
queryScreenLib.assertNoFilterButtonBadge(1);
// qSeleniumLib.waitForever();

View File

@ -73,7 +73,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
////////////////////////////////////////
String filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("annualSalary");
queryScreenLib.clickQuickFilterButton("annualSalary");
@ -84,7 +84,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
///////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("annualSalary");
queryScreenLib.clickQuickFilterButton("annualSalary");
@ -97,7 +97,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
//////////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("homeCityId");
queryScreenLib.clickQuickFilterButton("homeCityId");
@ -109,7 +109,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
//////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.IN, 1, 2)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("homeCityId");
queryScreenLib.clickQuickFilterButton("homeCityId");
@ -122,7 +122,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
/////////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS))));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("createDate");
queryScreenLib.clickQuickFilterButton("createDate");
@ -135,7 +135,7 @@ public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("firstName", QCriteriaOperator.STARTS_WITH, "Dar"))
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, ThisOrLastPeriod.this_(ChronoUnit.YEARS))));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("firstName");
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("createDate");

View File

@ -61,7 +61,7 @@ public class QueryScreenTest extends QBaseSeleniumTest
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.gotoAdvancedMode();
queryScreenLib.clickFilterButton();
@ -86,13 +86,13 @@ public class QueryScreenTest extends QBaseSeleniumTest
///////////////////////////////////////
qSeleniumLib.waitForSeconds(1); // todo grr.
qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER).click();
qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", "1");
qSeleniumLib.waitForSelectorContaining(".filterBuilderCountBadge", "1");
///////////////////////////////////////////////////////////////////
// click the 'x' clear icon, then yes, then expect another query //
///////////////////////////////////////////////////////////////////
qSeleniumJavalin.beginCapture();
qSeleniumLib.tryMultiple(3, () -> qSeleniumLib.waitForSelector("#clearFiltersButton").click());
qSeleniumLib.tryMultiple(3, () -> qSeleniumLib.waitForSelector(".filterBuilderXIcon BUTTON").click());
qSeleniumLib.waitForSelectorContaining("BUTTON", "Yes").click();
////////////////////////////////////////////////////////////////////
@ -115,7 +115,7 @@ public class QueryScreenTest extends QBaseSeleniumTest
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
queryScreenLib.gotoAdvancedMode();
queryScreenLib.clickFilterButton();

View File

@ -22,10 +22,15 @@
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebElement;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
@ -66,9 +71,9 @@ public class SavedViewsTest extends QBaseSeleniumTest
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
qSeleniumLib.waitForSelectorContaining("BUTTON", "Saved Views").click();
qSeleniumLib.waitForSelectorContaining("BUTTON", "Views").click();
qSeleniumLib.waitForSelectorContaining("LI", "Some People");
////////////////////////////////////////
@ -85,7 +90,7 @@ public class SavedViewsTest extends QBaseSeleniumTest
/////////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("LI", "Some People").click();
qSeleniumLib.waitForCondition("Current URL should have view id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2"));
qSeleniumLib.waitForSelectorContaining("DIV", "Current View: Some People");
qSeleniumLib.waitForSelectorContaining("BUTTON", "Some People");
//////////////////////////////
// click into a view screen //
@ -100,19 +105,20 @@ public class SavedViewsTest extends QBaseSeleniumTest
///////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("A", "Person").click();
qSeleniumLib.waitForCondition("Current URL should have View id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2"));
qSeleniumLib.waitForSelectorContaining("DIV", "Current View: Some People");
qSeleniumLib.waitForSelectorContaining("BUTTON", "Some People");
queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("firstName");
//////////////////////
// modify the query //
//////////////////////
/* todo - right now - this is changed - but - working through it with Views story... revisit before merge!
queryScreenLib.clickFilterButton();
queryScreenLib.addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or");
qSeleniumLib.waitForSelectorContaining("H3", "Person").click();
qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People")
.findElement(By.cssSelector("CIRCLE"));
qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "2");
queryScreenLib.clickQuickFilterButton("lastName");
WebElement valueInput = qSeleniumLib.waitForSelector(".filterValuesColumn INPUT");
valueInput.click();
valueInput.sendKeys("Kelkhoff");
qSeleniumLib.waitForMillis(100);
qSeleniumLib.clickBackdrop();
qSeleniumLib.waitForSelectorContaining("DIV", "Unsaved Changes");
//////////////////////////////
// click into a view screen //
@ -127,16 +133,15 @@ public class SavedViewsTest extends QBaseSeleniumTest
qSeleniumJavalin.beginCapture();
qSeleniumLib.waitForSelectorContaining("A", "Person").click();
qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2"));
qSeleniumLib.waitForSelectorContaining("DIV", "Current View: Some People")
.findElement(By.cssSelector("CIRCLE"));
qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "2");
qSeleniumLib.waitForSelectorContaining("BUTTON", "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 View in the URL //
////////////////////////////////////////////////////
//////////////////////////////////////////////////
String filter = """
{
"criteria":
@ -149,9 +154,8 @@ public class SavedViewsTest extends QBaseSeleniumTest
]
}
""".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 View");
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8), "Person");
qSeleniumLib.waitForSelectorContaining("BUTTON", "Save View As");
//////////////////////////////
// click into a view screen //
@ -166,11 +170,10 @@ public class SavedViewsTest extends QBaseSeleniumTest
qSeleniumJavalin.beginCapture();
qSeleniumLib.waitForSelectorContaining("A", "Person").click();
qSeleniumLib.waitForCondition("Current URL should not have filter id", () -> !driver.getCurrentUrl().endsWith("/person/savedView/2"));
qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1");
qSeleniumLib.waitForSelectorContaining("BUTTON", "Save View As");
capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query");
assertTrue(capturedContext.getBody().matches("(?s).*id.*LESS_THAN.*10.*"));
qSeleniumJavalin.endCapture();
*/
}
}

View File

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