diff --git a/pom.xml b/pom.xml index 13b545a..3b1d74f 100644 --- a/pom.xml +++ b/pom.xml @@ -66,7 +66,7 @@ com.kingsrook.qqq qqq-backend-core - 0.17.0-SNAPSHOT + feature-CE-798-quick-filters-20240123.205854-1 org.slf4j diff --git a/src/App.tsx b/src/App.tsx index 48578b1..269bb62 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -355,7 +355,7 @@ export default function App() routeList.push({ name: `${app.label}`, key: app.name, - route: `${path}/savedFilter/:id`, + route: `${path}/savedView/:id`, component: , }); @@ -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() 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), diff --git a/src/QContext.tsx b/src/QContext.tsx index b90413b..797c970 100644 --- a/src/QContext.tsx +++ b/src/QContext.tsx @@ -19,9 +19,7 @@ * along with this program. If not, see . */ -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: {}, diff --git a/src/main/java/Placeholder.java b/src/main/java/Placeholder.java deleted file mode 100755 index e3ca68b..0000000 --- a/src/main/java/Placeholder.java +++ /dev/null @@ -1,10 +0,0 @@ -/******************************************************************************* - ** Placeholder class, because maven really wants some source under src/main? - *******************************************************************************/ -public class Placeholder -{ - public void f() - { - - } -} diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java index 6ce1abd..d40cdb0 100644 --- a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java @@ -22,17 +22,23 @@ package com.kingsrook.qqq.frontend.materialdashboard.model.metadata; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; /******************************************************************************* - ** + ** table-level meta-data for this module (handled as QSupplementalTableMetaData) *******************************************************************************/ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData { private List> gotoFieldNames; - + private List defaultQuickFilterFieldNames; /******************************************************************************* @@ -86,4 +92,73 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData return (this); } + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public void validate(QInstance qInstance, QTableMetaData tableMetaData, QInstanceValidator qInstanceValidator) + { + super.validate(qInstance, tableMetaData, qInstanceValidator); + + String prefix = "MaterialDashboardTableMetaData supplementalTableMetaData for table [" + tableMetaData.getName() + "] "; + + for(List gotoFieldNameSubList : CollectionUtils.nonNullList(gotoFieldNames)) + { + qInstanceValidator.assertCondition(!gotoFieldNameSubList.isEmpty(), prefix + "has an empty gotoFieldNames list"); + validateListOfFieldNames(tableMetaData, gotoFieldNameSubList, qInstanceValidator, prefix + "gotoFieldNames: "); + } + validateListOfFieldNames(tableMetaData, defaultQuickFilterFieldNames, qInstanceValidator, prefix + "defaultQuickFilterFieldNames: "); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void validateListOfFieldNames(QTableMetaData tableMetaData, List fieldNames, QInstanceValidator qInstanceValidator, String prefix) + { + Set usedNames = new HashSet<>(); + for(String fieldName : CollectionUtils.nonNullList(fieldNames)) + { + if(qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldName), prefix + " unrecognized field name: " + fieldName)) + { + qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + " has a duplicated field name: " + fieldName); + usedNames.add(fieldName); + } + } + } + + + + /******************************************************************************* + ** Getter for defaultQuickFilterFieldNames + *******************************************************************************/ + public List getDefaultQuickFilterFieldNames() + { + return (this.defaultQuickFilterFieldNames); + } + + + + /******************************************************************************* + ** Setter for defaultQuickFilterFieldNames + *******************************************************************************/ + public void setDefaultQuickFilterFieldNames(List defaultQuickFilterFieldNames) + { + this.defaultQuickFilterFieldNames = defaultQuickFilterFieldNames; + } + + + + /******************************************************************************* + ** Fluent setter for defaultQuickFilterFieldNames + *******************************************************************************/ + public MaterialDashboardTableMetaData withDefaultQuickFilterFieldNames(List defaultQuickFilterFieldNames) + { + this.defaultQuickFilterFieldNames = defaultQuickFilterFieldNames; + return (this); + } + } diff --git a/src/qqq/components/buttons/DefaultButtons.tsx b/src/qqq/components/buttons/DefaultButtons.tsx index f3e10fe..23a65cc 100644 --- a/src/qqq/components/buttons/DefaultButtons.tsx +++ b/src/qqq/components/buttons/DefaultButtons.tsx @@ -37,7 +37,7 @@ interface QCreateNewButtonProps export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element { return ( - + add}> Create New @@ -73,13 +73,17 @@ export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveBu interface QDeleteButtonProps { onClickHandler: any + disabled?: boolean } -export function QDeleteButton({onClickHandler}: QDeleteButtonProps): JSX.Element +QDeleteButton.defaultProps = { + disabled: false +}; +export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): JSX.Element { return ( - delete}> + delete} disabled={disabled}> Delete @@ -123,24 +127,6 @@ export function QActionsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonP ); } -export function QSavedFiltersMenuButton({isOpen, onClickHandler}: QActionsMenuButtonProps): JSX.Element -{ - return ( - - filter_alt} - > - saved filters  - keyboard_arrow_down - - - ); -} - interface QCancelButtonProps { onClickHandler: any; diff --git a/src/qqq/components/forms/DynamicSelect.tsx b/src/qqq/components/forms/DynamicSelect.tsx index 6c40a0e..005dfc7 100644 --- a/src/qqq/components/forms/DynamicSelect.tsx +++ b/src/qqq/components/forms/DynamicSelect.tsx @@ -51,6 +51,7 @@ interface Props bulkEditSwitchChangeHandler?: any; otherValues?: Map; variant: "standard" | "outlined"; + initiallyOpen: boolean; } DynamicSelect.defaultProps = { @@ -66,6 +67,7 @@ DynamicSelect.defaultProps = { bulkEditMode: false, otherValues: new Map(), variant: "outlined", + initiallyOpen: false, bulkEditSwitchChangeHandler: () => { }, @@ -73,12 +75,13 @@ DynamicSelect.defaultProps = { const qController = Client.getInstance(); -function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant}: Props) +function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen}: Props) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(initiallyOpen); const [options, setOptions] = useState([]); const [searchTerm, setSearchTerm] = useState(null); const [firstRender, setFirstRender] = useState(true); + const [otherValuesWhenResultsWereLoaded, setOtherValuesWhenResultsWereLoaded] = useState(JSON.stringify(Object.fromEntries((otherValues)))) const {inputBorderColor} = colors; //////////////////////////////////////////////////////////////////////////////////////////////// @@ -113,7 +116,14 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe { // console.log("First render, so not searching..."); setFirstRender(false); - return; + + /* + if(!initiallyOpen) + { + console.log("returning because not initially open?"); + return; + } + */ } // console.log("Use effect for searchTerm - searching!"); @@ -146,6 +156,24 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe }; }, [ searchTerm ]); + // todo - finish... call it in onOpen? + const reloadIfOtherValuesAreChanged = () => + { + if(JSON.stringify(Object.fromEntries(otherValues)) != otherValuesWhenResultsWereLoaded) + { + (async () => + { + setLoading(true); + setOptions([]); + console.log("Refreshing possible values..."); + const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, fieldName, searchTerm ?? "", null, otherValues); + setLoading(false); + setOptions([ ...results ]); + setOtherValuesWhenResultsWereLoaded(JSON.stringify(Object.fromEntries(otherValues))); + })(); + } + } + const inputChanged = (event: React.SyntheticEvent, value: string, reason: string) => { // console.log(`input changed. Reason: ${reason}, setting search term to ${value}`); @@ -293,9 +321,14 @@ function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabe { setOpen(false); }} - isOptionEqualToValue={(option, value) => option.id === value.id} + isOptionEqualToValue={(option, value) => value !== null && value !== undefined && option.id === value.id} getOptionLabel={(option) => { + if(option === null || option === undefined) + { + return (""); + } + // @ts-ignore if(option && option.length) { diff --git a/src/qqq/components/horseshoe/Breadcrumbs.tsx b/src/qqq/components/horseshoe/Breadcrumbs.tsx index b31bb76..cc396bd 100644 --- a/src/qqq/components/horseshoe/Breadcrumbs.tsx +++ b/src/qqq/components/horseshoe/Breadcrumbs.tsx @@ -70,7 +70,7 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element } const routes: string[] | any = route.slice(0, -1); - const {pageHeader, pathToLabelMap, branding} = useContext(QContext); + const {pathToLabelMap, branding} = useContext(QContext); const fullPathToLabel = (fullPath: string, route: string): string => { @@ -92,7 +92,18 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element let accumulatedPath = ""; for (let i = 0; i < routes.length; i++) { - if(routes[i] === "savedFilter") + //////////////////////////////////////////////////////// + // avoid showing "saved view" as a breadcrumb element // + //////////////////////////////////////////////////////// + if(routes[i] === "savedView") + { + continue; + } + + /////////////////////////////////////////////////////////////////////// + // avoid showing the table name if it's the element before savedView // + /////////////////////////////////////////////////////////////////////// + if(i < routes.length - 1 && routes[i+1] == "savedView") { continue; } @@ -138,15 +149,6 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element ))} - - {pageHeader} - ); } diff --git a/src/qqq/components/horseshoe/NavBar.tsx b/src/qqq/components/horseshoe/NavBar.tsx index 1669564..fd7cfac 100644 --- a/src/qqq/components/horseshoe/NavBar.tsx +++ b/src/qqq/components/horseshoe/NavBar.tsx @@ -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 > navbarRow(theme, {isMini})}> - + menu {isMini ? null : ( navbarRow(theme, {isMini})}> - + {renderHistory()} )} + { + pageHeader && + + + {pageHeader} + + + } ); } diff --git a/src/qqq/components/horseshoe/Styles.ts b/src/qqq/components/horseshoe/Styles.ts index c9c87c1..5e56745 100644 --- a/src/qqq/components/horseshoe/Styles.ts +++ b/src/qqq/components/horseshoe/Styles.ts @@ -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", diff --git a/src/qqq/components/misc/FieldAutoComplete.tsx b/src/qqq/components/misc/FieldAutoComplete.tsx new file mode 100644 index 0000000..4f789ae --- /dev/null +++ b/src/qqq/components/misc/FieldAutoComplete.tsx @@ -0,0 +1,158 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; +import React, {ReactNode} from "react"; + +interface FieldAutoCompleteProps +{ + id: string; + metaData: QInstance; + tableMetaData: QTableMetaData; + handleFieldChange: (event: any, newValue: any, reason: string) => void; + defaultValue?: {field: QFieldMetaData, table: QTableMetaData, fieldName: string}; + autoFocus?: boolean; + forceOpen?: boolean; + hiddenFieldNames?: string[]; +} + +FieldAutoComplete.defaultProps = + { + defaultValue: null, + autoFocus: false, + forceOpen: null, + hiddenFieldNames: [] + }; + +function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[]) +{ + const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label)); + for (let i = 0; i < sortedFields.length; i++) + { + const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name; + + if(hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1) + { + continue; + } + + fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName}); + } +} + +export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element +{ + const fieldOptions: any[] = []; + makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames); + let fieldsGroupBy = null; + + if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0) + { + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const exposedJoin = tableMetaData.exposedJoins[i]; + if (metaData.tables.has(exposedJoin.joinTable.name)) + { + fieldsGroupBy = (option: any) => `${option.table.label} fields`; + makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames); + } + } + } + + + function getFieldOptionLabel(option: any) + { + ///////////////////////////////////////////////////////////////////////////////////////// + // note - we're using renderFieldOption below for the actual select-box options, which // + // are always jut field label (as they are under groupings that show their table name) // + ///////////////////////////////////////////////////////////////////////////////////////// + if (option && option.field && option.table) + { + if (option.table.name == tableMetaData.name) + { + return (option.field.label); + } + else + { + return (option.table.label + ": " + option.field.label); + } + } + + return (""); + } + + + ////////////////////////////////////////////////////////////////////////////////////////////// + // for options, we only want the field label (contrast with what we show in the input box, // + // which comes out of getFieldOptionLabel, which is the table-label prefix for join fields) // + ////////////////////////////////////////////////////////////////////////////////////////////// + function renderFieldOption(props: React.HTMLAttributes, option: any, state: AutocompleteRenderOptionState): ReactNode + { + let label = ""; + if (option && option.field) + { + label = (option.field.label); + } + + return (
  • {label}
  • ); + } + + + function isFieldOptionEqual(option: any, value: any) + { + return option.fieldName === value.fieldName; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // seems like, if we always add the open attribute, then if its false or null, then the autocomplete // + // doesn't open at all... so, only add the attribute at all, if forceOpen is true // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + const alsoOpen: {[key: string]: any} = {} + if(forceOpen) + { + alsoOpen["open"] = forceOpen; + } + + return ( + ()} + // @ts-ignore + defaultValue={defaultValue} + options={fieldOptions} + onChange={handleFieldChange} + isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)} + groupBy={fieldsGroupBy} + getOptionLabel={(option) => getFieldOptionLabel(option)} + renderOption={(props, option, state) => renderFieldOption(props, option, state)} + autoSelect={true} + autoHighlight={true} + slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} + {...alsoOpen} + /> + + ); +} diff --git a/src/qqq/components/misc/RecordSidebar.tsx b/src/qqq/components/misc/RecordSidebar.tsx index 25b6ac3..fffa5fa 100644 --- a/src/qqq/components/misc/RecordSidebar.tsx +++ b/src/qqq/components/misc/RecordSidebar.tsx @@ -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 ( - + { sidebarEntries ? sidebarEntries.map((entry: SidebarEntry, key: number) => ( diff --git a/src/qqq/components/misc/SavedFilters.tsx b/src/qqq/components/misc/SavedFilters.tsx deleted file mode 100644 index eead528..0000000 --- a/src/qqq/components/misc/SavedFilters.tsx +++ /dev/null @@ -1,511 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; -import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; -import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; -import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete"; -import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; -import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; -import {FiberManualRecord} from "@mui/icons-material"; -import {Alert} from "@mui/material"; -import Box from "@mui/material/Box"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import Divider from "@mui/material/Divider"; -import Icon from "@mui/material/Icon"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import TextField from "@mui/material/TextField"; -import Tooltip from "@mui/material/Tooltip"; -import Typography from "@mui/material/Typography"; -import {GridFilterModel, GridSortItem} from "@mui/x-data-grid-pro"; -import FormData from "form-data"; -import React, {useEffect, useRef, useState} from "react"; -import {useLocation, useNavigate} from "react-router-dom"; -import {QCancelButton, QDeleteButton, QSaveButton, QSavedFiltersMenuButton} from "qqq/components/buttons/DefaultButtons"; -import FilterUtils from "qqq/utils/qqq/FilterUtils"; - -interface Props -{ - qController: QController; - metaData: QInstance; - tableMetaData: QTableMetaData; - currentSavedFilter: QRecord; - filterModel?: GridFilterModel; - columnSortModel?: GridSortItem[]; - filterOnChangeCallback?: (selectedSavedFilterId: number) => void; -} - -function SavedFilters({qController, metaData, tableMetaData, currentSavedFilter, filterModel, columnSortModel, filterOnChangeCallback}: Props): JSX.Element -{ - const navigate = useNavigate(); - - const [savedFilters, setSavedFilters] = useState([] as QRecord[]); - const [savedFiltersMenu, setSavedFiltersMenu] = useState(null); - const [savedFiltersHaveLoaded, setSavedFiltersHaveLoaded] = useState(false); - const [filterIsModified, setFilterIsModified] = useState(false); - - const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false); - const [isSaveFilterAs, setIsSaveFilterAs] = useState(false); - const [isRenameFilter, setIsRenameFilter] = useState(false); - const [isDeleteFilter, setIsDeleteFilter] = useState(false); - const [savedFilterNameInputValue, setSavedFilterNameInputValue] = useState(null as string); - const [popupAlertContent, setPopupAlertContent] = useState(""); - - const anchorRef = useRef(null); - const location = useLocation(); - const [saveOptionsOpen, setSaveOptionsOpen] = useState(false); - - const SAVE_OPTION = "Save..."; - const DUPLICATE_OPTION = "Duplicate..."; - const RENAME_OPTION = "Rename..."; - const DELETE_OPTION = "Delete..."; - const CLEAR_OPTION = "Clear Current Filter"; - const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION]; - - const openSavedFiltersMenu = (event: any) => setSavedFiltersMenu(event.currentTarget); - const closeSavedFiltersMenu = () => setSavedFiltersMenu(null); - - ////////////////////////////////////////////////////////////////////////// - // load filters on first run, then monitor location or metadata changes // - ////////////////////////////////////////////////////////////////////////// - useEffect(() => - { - loadSavedFilters() - .then(() => - { - if (currentSavedFilter != null) - { - let qFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel); - setFilterIsModified(JSON.stringify(qFilter) !== currentSavedFilter.values.get("filterJson")); - } - - setSavedFiltersHaveLoaded(true); - }); - }, [location , tableMetaData, currentSavedFilter, filterModel, columnSortModel]) - - - - /******************************************************************************* - ** make request to load all saved filters from backend - *******************************************************************************/ - async function loadSavedFilters() - { - if (! tableMetaData) - { - return; - } - - const formData = new FormData(); - formData.append("tableName", tableMetaData.name); - - let savedFilters = await makeSavedFilterRequest("querySavedFilter", formData); - setSavedFilters(savedFilters); - } - - - - /******************************************************************************* - ** fired when a saved record is clicked from the dropdown - *******************************************************************************/ - const handleSavedFilterRecordOnClick = async (record: QRecord) => - { - setSaveFilterPopupOpen(false); - closeSavedFiltersMenu(); - filterOnChangeCallback(record.values.get("id")); - navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedFilter/${record.values.get("id")}`); - }; - - - - /******************************************************************************* - ** fired when a save option is selected from the save... button/dropdown combo - *******************************************************************************/ - const handleDropdownOptionClick = (optionName: string) => - { - setSaveOptionsOpen(false); - setPopupAlertContent(null); - closeSavedFiltersMenu(); - setSaveFilterPopupOpen(true); - setIsSaveFilterAs(false); - setIsRenameFilter(false); - setIsDeleteFilter(false) - - switch(optionName) - { - case SAVE_OPTION: - break; - case DUPLICATE_OPTION: - setIsSaveFilterAs(true); - break; - case CLEAR_OPTION: - setSaveFilterPopupOpen(false) - filterOnChangeCallback(null); - navigate(metaData.getTablePathByName(tableMetaData.name)); - break; - case RENAME_OPTION: - if(currentSavedFilter != null) - { - setSavedFilterNameInputValue(currentSavedFilter.values.get("label")); - } - setIsRenameFilter(true); - break; - case DELETE_OPTION: - setIsDeleteFilter(true) - break; - } - } - - - - /******************************************************************************* - ** fired when save or delete button saved on confirmation dialogs - *******************************************************************************/ - async function handleFilterDialogButtonOnClick() - { - try - { - const formData = new FormData(); - if (isDeleteFilter) - { - formData.append("id", currentSavedFilter.values.get("id")); - await makeSavedFilterRequest("deleteSavedFilter", formData); - await(async() => - { - handleDropdownOptionClick(CLEAR_OPTION); - })(); - } - else - { - formData.append("tableName", tableMetaData.name); - formData.append("filterJson", JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel)))); - - if (isSaveFilterAs || isRenameFilter || currentSavedFilter == null) - { - formData.append("label", savedFilterNameInputValue); - if(currentSavedFilter != null && isRenameFilter) - { - formData.append("id", currentSavedFilter.values.get("id")); - } - } - else - { - formData.append("id", currentSavedFilter.values.get("id")); - formData.append("label", currentSavedFilter?.values.get("label")); - } - const recordList = await makeSavedFilterRequest("storeSavedFilter", formData); - await(async() => - { - if (recordList && recordList.length > 0) - { - setSavedFiltersHaveLoaded(false); - loadSavedFilters(); - handleSavedFilterRecordOnClick(recordList[0]); - } - })(); - } - } - catch (e: any) - { - setPopupAlertContent(JSON.stringify(e.message)); - } - } - - - - /******************************************************************************* - ** hides/shows the save options - *******************************************************************************/ - const handleToggleSaveOptions = () => - { - setSaveOptionsOpen((prevOpen) => !prevOpen); - }; - - - - /******************************************************************************* - ** closes save options menu (on clickaway) - *******************************************************************************/ - const handleSaveOptionsMenuClose = (event: Event) => - { - if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) - { - return; - } - - setSaveOptionsOpen(false); - }; - - - - /******************************************************************************* - ** stores the current dialog input text to state - *******************************************************************************/ - const handleSaveFilterInputChange = (event: React.ChangeEvent) => - { - setSavedFilterNameInputValue(event.target.value); - }; - - - - /******************************************************************************* - ** closes current dialog - *******************************************************************************/ - const handleSaveFilterPopupClose = () => - { - setSaveFilterPopupOpen(false); - }; - - - - /******************************************************************************* - ** make a request to the backend for various savedFilter processes - *******************************************************************************/ - async function makeSavedFilterRequest(processName: string, formData: FormData): Promise - { - ///////////////////////// - // fetch saved filters // - ///////////////////////// - let savedFilters = [] as QRecord[] - try - { - ////////////////////////////////////////////////////////////////// - // we don't want this job to go async, so, pass a large timeout // - ////////////////////////////////////////////////////////////////// - formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000); - const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders()); - if (processResult instanceof QJobError) - { - const jobError = processResult as QJobError; - throw(jobError.error); - } - else - { - const result = processResult as QJobComplete; - if(result.values.savedFilterList) - { - for (let i = 0; i < result.values.savedFilterList.length; i++) - { - const qRecord = new QRecord(result.values.savedFilterList[i]); - savedFilters.push(qRecord); - } - } - } - } - catch (e) - { - throw(e); - } - - return (savedFilters); - } - - const hasStorePermission = metaData?.processes.has("storeSavedFilter"); - const hasDeletePermission = metaData?.processes.has("deleteSavedFilter"); - const hasQueryPermission = metaData?.processes.has("querySavedFilter"); - - const renderSavedFiltersMenu = tableMetaData && ( - - Filter Actions - { - hasStorePermission && - handleDropdownOptionClick(SAVE_OPTION)}> - save - Save... - - } - { - hasStorePermission && - handleDropdownOptionClick(RENAME_OPTION)}> - edit - Rename... - - } - { - hasStorePermission && - handleDropdownOptionClick(DUPLICATE_OPTION)}> - content_copy - Duplicate... - - } - { - hasDeletePermission && - handleDropdownOptionClick(DELETE_OPTION)}> - delete - Delete... - - } - { - handleDropdownOptionClick(CLEAR_OPTION)}> - clear - Clear Current Filter - - } - - Your Filters - { - savedFilters && savedFilters.length > 0 ? ( - savedFilters.map((record: QRecord, index: number) => - handleSavedFilterRecordOnClick(record)}> - {record.values.get("label")} - - ) - ): ( - - No filters have been saved for this table. - - ) - } - - ); - - return ( - hasQueryPermission && tableMetaData ? ( - - - {renderSavedFiltersMenu} - - - { - savedFiltersHaveLoaded && currentSavedFilter && ( - Current Filter:  - - {currentSavedFilter.values.get("label")} - { - filterIsModified && ( - - - - ) - } - - - ) - } - - - { - - { - if (e.key == "Enter") - { - handleFilterDialogButtonOnClick(); - } - }} - > - { - currentSavedFilter ? ( - isDeleteFilter ? ( - Delete Filter - ) : ( - isSaveFilterAs ? ( - Save Filter As - ):( - isRenameFilter ? ( - Rename Filter - ):( - Update Existing Filter - ) - ) - ) - ):( - Save New Filter - ) - } - - { - (! currentSavedFilter || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? ( - - { - isSaveFilterAs ? ( - Enter a name for this new saved filter. - ):( - Enter a new name for this saved filter. - ) - } - - { - event.target.select(); - }} - /> - - ):( - isDeleteFilter ? ( - Are you sure you want to delete the filter {`'${currentSavedFilter?.values.get("label")}'`}? - ):( - Are you sure you want to update the filter {`'${currentSavedFilter?.values.get("label")}'`} with the current filter criteria? - ) - ) - } - {popupAlertContent ? ( - - {popupAlertContent} - - ) : ("")} - - - - { - isDeleteFilter ? - - : - - } - - - } - - ) : null - ); -} - -export default SavedFilters; diff --git a/src/qqq/components/misc/SavedViews.tsx b/src/qqq/components/misc/SavedViews.tsx new file mode 100644 index 0000000..59dcc07 --- /dev/null +++ b/src/qqq/components/misc/SavedViews.tsx @@ -0,0 +1,682 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete"; +import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; +import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; +import {Alert, Button, Link} from "@mui/material"; +import Box from "@mui/material/Box"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import Divider from "@mui/material/Divider"; +import Icon from "@mui/material/Icon"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import {TooltipProps} from "@mui/material/Tooltip/Tooltip"; +import FormData from "form-data"; +import React, {useContext, useEffect, useRef, useState} from "react"; +import {useLocation, useNavigate} from "react-router-dom"; +import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; +import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import RecordQueryView from "qqq/models/query/RecordQueryView"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils"; + +interface Props +{ + qController: QController; + metaData: QInstance; + tableMetaData: QTableMetaData; + currentSavedView: QRecord; + tableDefaultView: RecordQueryView; + view?: RecordQueryView; + viewAsJson?: string; + viewOnChangeCallback?: (selectedSavedViewId: number) => void; + loadingSavedView: boolean +} + +function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element +{ + const navigate = useNavigate(); + + const [savedViews, setSavedViews] = useState([] as QRecord[]); + const [savedViewsMenu, setSavedViewsMenu] = useState(null); + const [savedViewsHaveLoaded, setSavedViewsHaveLoaded] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false); + const [isSaveFilterAs, setIsSaveFilterAs] = useState(false); + const [isRenameFilter, setIsRenameFilter] = useState(false); + const [isDeleteFilter, setIsDeleteFilter] = useState(false); + const [savedViewNameInputValue, setSavedViewNameInputValue] = useState(null as string); + const [popupAlertContent, setPopupAlertContent] = useState(""); + + const anchorRef = useRef(null); + const location = useLocation(); + const [saveOptionsOpen, setSaveOptionsOpen] = useState(false); + + const SAVE_OPTION = "Save..."; + const DUPLICATE_OPTION = "Duplicate..."; + const RENAME_OPTION = "Rename..."; + const DELETE_OPTION = "Delete..."; + const CLEAR_OPTION = "New View"; + const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION]; + + const {accentColor, accentColorLight} = useContext(QContext); + + const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget); + const closeSavedViewsMenu = () => setSavedViewsMenu(null); + + ////////////////////////////////////////////////////////////////////////// + // load filters on first run, then monitor location or metadata changes // + ////////////////////////////////////////////////////////////////////////// + useEffect(() => + { + loadSavedViews() + .then(() => + { + setSavedViewsHaveLoaded(true); + }); + }, [location, tableMetaData]) + + + const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView; + const viewDiffs = SavedViewUtils.diffViews(tableMetaData, baseView, view); + let viewIsModified = false; + if(viewDiffs.length > 0) + { + viewIsModified = true; + } + + /******************************************************************************* + ** make request to load all saved filters from backend + *******************************************************************************/ + async function loadSavedViews() + { + if (! tableMetaData) + { + return; + } + + const formData = new FormData(); + formData.append("tableName", tableMetaData.name); + + let savedViews = await makeSavedViewRequest("querySavedView", formData); + setSavedViews(savedViews); + } + + + + /******************************************************************************* + ** fired when a saved record is clicked from the dropdown + *******************************************************************************/ + const handleSavedViewRecordOnClick = async (record: QRecord) => + { + setSaveFilterPopupOpen(false); + closeSavedViewsMenu(); + viewOnChangeCallback(record.values.get("id")); + navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`); + }; + + + + /******************************************************************************* + ** fired when a save option is selected from the save... button/dropdown combo + *******************************************************************************/ + const handleDropdownOptionClick = (optionName: string) => + { + setSaveOptionsOpen(false); + setPopupAlertContent(""); + closeSavedViewsMenu(); + setSaveFilterPopupOpen(true); + setIsSaveFilterAs(false); + setIsRenameFilter(false); + setIsDeleteFilter(false) + + switch(optionName) + { + case SAVE_OPTION: + if(currentSavedView == null) + { + setSavedViewNameInputValue(""); + } + break; + case DUPLICATE_OPTION: + setSavedViewNameInputValue(""); + setIsSaveFilterAs(true); + break; + case CLEAR_OPTION: + setSaveFilterPopupOpen(false) + viewOnChangeCallback(null); + navigate(metaData.getTablePathByName(tableMetaData.name)); + break; + case RENAME_OPTION: + if(currentSavedView != null) + { + setSavedViewNameInputValue(currentSavedView.values.get("label")); + } + setIsRenameFilter(true); + break; + case DELETE_OPTION: + setIsDeleteFilter(true) + break; + } + } + + + + /******************************************************************************* + ** fired when save or delete button saved on confirmation dialogs + *******************************************************************************/ + async function handleFilterDialogButtonOnClick() + { + try + { + setPopupAlertContent(""); + setIsSubmitting(true); + + const formData = new FormData(); + if (isDeleteFilter) + { + formData.append("id", currentSavedView.values.get("id")); + await makeSavedViewRequest("deleteSavedView", formData); + + setSaveFilterPopupOpen(false); + setSaveOptionsOpen(false); + + await(async() => + { + handleDropdownOptionClick(CLEAR_OPTION); + })(); + } + else + { + formData.append("tableName", tableMetaData.name); + + ///////////////////////////////////////////////////////////////////////////////////////////////// + // clone view via json serialization/deserialization // + // then replace the viewJson in it with a copy that has had its possible values changed to ids // + // then stringify that for the backend // + ///////////////////////////////////////////////////////////////////////////////////////////////// + const viewObject = JSON.parse(JSON.stringify(view)); + viewObject.queryFilter = JSON.parse(JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(viewObject.queryFilter))); + formData.append("viewJson", JSON.stringify(viewObject)); + + if (isSaveFilterAs || isRenameFilter || currentSavedView == null) + { + formData.append("label", savedViewNameInputValue); + if(currentSavedView != null && isRenameFilter) + { + formData.append("id", currentSavedView.values.get("id")); + } + } + else + { + formData.append("id", currentSavedView.values.get("id")); + formData.append("label", currentSavedView?.values.get("label")); + } + const recordList = await makeSavedViewRequest("storeSavedView", formData); + await(async() => + { + if (recordList && recordList.length > 0) + { + setSavedViewsHaveLoaded(false); + loadSavedViews(); + handleSavedViewRecordOnClick(recordList[0]); + } + })(); + } + + setSaveFilterPopupOpen(false); + setSaveOptionsOpen(false); + } + catch (e: any) + { + let message = JSON.stringify(e); + if(typeof e == "string") + { + message = e; + } + else if(typeof e == "object" && e.message) + { + message = e.message; + } + + setPopupAlertContent(message); + console.log(`Setting error: ${message}`); + } + finally + { + setIsSubmitting(false); + } + } + + + + /******************************************************************************* + ** hides/shows the save options + *******************************************************************************/ + const handleToggleSaveOptions = () => + { + setSaveOptionsOpen((prevOpen) => !prevOpen); + }; + + + + /******************************************************************************* + ** closes save options menu (on clickaway) + *******************************************************************************/ + const handleSaveOptionsMenuClose = (event: Event) => + { + if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) + { + return; + } + + setSaveOptionsOpen(false); + }; + + + + /******************************************************************************* + ** stores the current dialog input text to state + *******************************************************************************/ + const handleSaveFilterInputChange = (event: React.ChangeEvent) => + { + setSavedViewNameInputValue(event.target.value); + }; + + + + /******************************************************************************* + ** closes current dialog + *******************************************************************************/ + const handleSaveFilterPopupClose = () => + { + setSaveFilterPopupOpen(false); + }; + + + + /******************************************************************************* + ** make a request to the backend for various savedView processes + *******************************************************************************/ + async function makeSavedViewRequest(processName: string, formData: FormData): Promise + { + ///////////////////////// + // fetch saved filters // + ///////////////////////// + let savedViews = [] as QRecord[] + try + { + ////////////////////////////////////////////////////////////////// + // we don't want this job to go async, so, pass a large timeout // + ////////////////////////////////////////////////////////////////// + formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000); + const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders()); + if (processResult instanceof QJobError) + { + const jobError = processResult as QJobError; + throw(jobError.error); + } + else + { + const result = processResult as QJobComplete; + if(result.values.savedViewList) + { + for (let i = 0; i < result.values.savedViewList.length; i++) + { + const qRecord = new QRecord(result.values.savedViewList[i]); + savedViews.push(qRecord); + } + } + } + } + catch (e) + { + throw(e); + } + + return (savedViews); + } + + const hasStorePermission = metaData?.processes.has("storeSavedView"); + const hasDeletePermission = metaData?.processes.has("deleteSavedView"); + const hasQueryPermission = metaData?.processes.has("querySavedView"); + + const tooltipMaxWidth = (maxWidth: string) => + { + return ({slotProps: { + tooltip: { + sx: { + maxWidth: maxWidth + } + } + }}) + } + + const menuTooltipAttribs = {...tooltipMaxWidth("250px"), placement: "left", enterDelay: 1000} as TooltipProps; + + const renderSavedViewsMenu = tableMetaData && ( + + View Actions + { + hasStorePermission && + Save your current filters, columns and settings, for quick re-use at a later time.

    You will be prompted to enter a name if you choose this option.}> + handleDropdownOptionClick(SAVE_OPTION)}> + save + {currentSavedView ? "Save..." : "Save As..."} + +
    + } + { + hasStorePermission && currentSavedView != null && + + handleDropdownOptionClick(RENAME_OPTION)}> + edit + Rename... + + + } + { + hasStorePermission && currentSavedView != null && + + handleDropdownOptionClick(DUPLICATE_OPTION)}> + content_copy + Save As... + + + } + { + hasStorePermission && currentSavedView != null && + + handleDropdownOptionClick(DELETE_OPTION)}> + delete + Delete... + + + } + { + + handleDropdownOptionClick(CLEAR_OPTION)}> + monitor + New View + + + } + + Your Saved Views + { + savedViews && savedViews.length > 0 ? ( + savedViews.map((record: QRecord, index: number) => + handleSavedViewRecordOnClick(record)}> + {record.values.get("label")} + + ) + ): ( + + You do not have any saved views for this table. + + ) + } +
    + ); + + let buttonText = "Views"; + let buttonBackground = "none"; + let buttonBorder = colors.grayLines.main; + let buttonColor = colors.gray.main; + + if(currentSavedView) + { + if (viewIsModified) + { + buttonBackground = accentColorLight; + buttonBorder = buttonBackground; + buttonColor = accentColor; + } + else + { + buttonBackground = accentColor; + buttonBorder = buttonBackground; + buttonColor = "#FFFFFF"; + } + } + + const buttonStyles = { + border: `1px solid ${buttonBorder}`, + backgroundColor: buttonBackground, + color: buttonColor, + "&:focus:not(:hover)": { + color: buttonColor, + backgroundColor: buttonBackground, + }, + "&:hover": { + color: buttonColor, + backgroundColor: buttonBackground, + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + function isSaveButtonDisabled(): boolean + { + if(isSubmitting) + { + return (true); + } + + const haveInputText = (savedViewNameInputValue != null && savedViewNameInputValue.trim() != "") + + if(isSaveFilterAs || isRenameFilter || currentSavedView == null) + { + if(!haveInputText) + { + return (true); + } + } + + return (false); + } + + const linkButtonStyle = { + minWidth: "unset", + textTransform: "none", + fontSize: "0.875rem", + fontWeight: "500", + padding: "0.5rem" + }; + + return ( + hasQueryPermission && tableMetaData ? ( + <> + + + {renderSavedViewsMenu} + + + + { + !currentSavedView && viewIsModified && <> + + Unsaved Changes +
      + { + viewDiffs.map((s: string, i: number) =>
    • {s}
    • ) + } +
    + }> + +
    + + {/* vertical rule */} + + + + + } + { + currentSavedView && viewIsModified && <> + + Unsaved Changes +
      + { + viewDiffs.map((s: string, i: number) =>
    • {s}
    • ) + } +
    }> + {viewDiffs.length} Unsaved Change{viewDiffs.length == 1 ? "" : "s"} +
    + + + + {/* vertical rule */} + + + + + } + +
    + { + + { + //////////////////////////////////////////////////// + // make user actually hit delete button // + // but for other modes, let Enter submit the form // + //////////////////////////////////////////////////// + if (e.key == "Enter" && !isDeleteFilter) + { + handleFilterDialogButtonOnClick(); + } + }} + > + { + currentSavedView ? ( + isDeleteFilter ? ( + Delete View + ) : ( + isSaveFilterAs ? ( + Save View As + ):( + isRenameFilter ? ( + Rename View + ):( + Update Existing View + ) + ) + ) + ):( + Save New View + ) + } + + {popupAlertContent ? ( + + setPopupAlertContent("")}>{popupAlertContent} + + ) : ("")} + { + (! currentSavedView || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? ( + + { + isSaveFilterAs ? ( + Enter a name for this new saved view. + ):( + Enter a new name for this saved view. + ) + } + + { + event.target.select(); + }} + /> + + ):( + isDeleteFilter ? ( + Are you sure you want to delete the view {`'${currentSavedView?.values.get("label")}'`}? + ):( + Are you sure you want to update the view {`'${currentSavedView?.values.get("label")}'`}? + ) + ) + } + + + + { + isDeleteFilter ? + + : + + } + + + } + + ) : null + ); +} + +export default SavedViews; diff --git a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx new file mode 100644 index 0000000..e947bbf --- /dev/null +++ b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx @@ -0,0 +1,837 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection"; +import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import {Badge, ToggleButton, ToggleButtonGroup} from "@mui/material"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import Icon from "@mui/material/Icon"; +import Tooltip from "@mui/material/Tooltip"; +import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro"; +import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react"; +import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; +import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; +import FieldListMenu from "qqq/components/query/FieldListMenu"; +import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; +import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter"; +import XIcon from "qqq/components/query/XIcon"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import TableUtils from "qqq/utils/qqq/TableUtils"; + +interface BasicAndAdvancedQueryControlsProps +{ + metaData: QInstance; + tableMetaData: QTableMetaData; + + savedViewsComponent: JSX.Element; + columnMenuComponent: JSX.Element; + + quickFilterFieldNames: string[]; + setQuickFilterFieldNames: (names: string[]) => void; + + queryFilter: QQueryFilter; + setQueryFilter: (queryFilter: QQueryFilter) => void; + + gridApiRef: React.MutableRefObject; + + ///////////////////////////////////////////////////////////////////////////////////////////// + // this prop is used as a way to recognize changes in the query filter internal structure, // + // since the queryFilter object (reference) doesn't get updated // + ///////////////////////////////////////////////////////////////////////////////////////////// + queryFilterJSON: string; + + mode: string; + setMode: (mode: string) => void; +} + +let debounceTimeout: string | number | NodeJS.Timeout; + +/******************************************************************************* + ** Component to provide the basic & advanced query-filter controls for the + ** RecordQueryOrig screen. + ** + ** Done as a forwardRef, so RecordQueryOrig can call some functions, e.g., when user + ** does things on that screen, that we need to know about in here. + *******************************************************************************/ +const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) => +{ + const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props + + ///////////////////// + // state variables // + ///////////////////// + const [defaultQuickFilterFieldNames, setDefaultQuickFilterFieldNames] = useState(getDefaultQuickFilterFieldNames(tableMetaData)); + const [defaultQuickFilterFieldNameMap, setDefaultQuickFilterFieldNameMap] = useState(Object.fromEntries(defaultQuickFilterFieldNames.map(k => [k, true]))); + const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null) + const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0); + const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false); + const [mouseOverElement, setMouseOverElement] = useState(null as string); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + const {accentColor} = useContext(QContext); + + ////////////////////////////////////////////////////////////////////////////////// + // make some functions available to our parent - so it can tell us to do things // + ////////////////////////////////////////////////////////////////////////////////// + useImperativeHandle(ref, () => + { + return { + ensureAllFilterCriteriaAreActiveQuickFilters(currentFilter: QQueryFilter, reason: string) + { + ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, currentFilter, reason); + }, + addField(fieldName: string) + { + addQuickFilterField({fieldName: fieldName}, "columnMenu"); + }, + getCurrentMode() + { + return (mode); + } + } + }); + + + /******************************************************************************* + ** + *******************************************************************************/ + function handleMouseOverElement(name: string) + { + setMouseOverElement(name); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function handleMouseOutElement() + { + setMouseOverElement(null); + } + + + /******************************************************************************* + ** for a given field, set its default operator for quick-filter dropdowns. + *******************************************************************************/ + function getDefaultOperatorForField(field: QFieldMetaData) + { + // todo - sometimes i want contains instead of equals on strings (client.name, for example...) + let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS; + if (field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE) + { + defaultOperator = QCriteriaOperator.GREATER_THAN; + } + else if (field?.type == QFieldType.BOOLEAN) + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // for booleans, if we set a default, since none of them have values, then they are ALWAYS selected, which isn't what we want. // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + defaultOperator = null; + } + return defaultOperator; + } + + + /******************************************************************************* + ** Callback passed into the QuickFilter component, to update the criteria + ** after user makes changes to it or to clear it out. + *******************************************************************************/ + const updateQuickCriteria = (newCriteria: QFilterCriteria, needDebounce = false, doClearCriteria = false) => + { + let found = false; + let foundIndex = null; + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + if(queryFilter.criteria[i].fieldName == newCriteria.fieldName) + { + queryFilter.criteria[i] = newCriteria; + found = true; + foundIndex = i; + break; + } + } + + if(doClearCriteria) + { + if(found) + { + queryFilter.criteria.splice(foundIndex, 1); + setQueryFilter(queryFilter); + } + return; + } + + if(!found) + { + if(!queryFilter.criteria) + { + queryFilter.criteria = []; + } + queryFilter.criteria.push(newCriteria); + found = true; + } + + if(found) + { + clearTimeout(debounceTimeout) + debounceTimeout = setTimeout(() => + { + setQueryFilter(queryFilter); + }, needDebounce ? 500 : 1); + + forceUpdate(); + } + }; + + + /******************************************************************************* + ** Get the QFilterCriteriaWithId object to pass in to the QuickFilter component + ** for a given field name. + *******************************************************************************/ + const getQuickCriteriaParam = (fieldName: string): QFilterCriteriaWithId | null | "tooComplex" => + { + const matches: QFilterCriteriaWithId[] = []; + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + if(queryFilter.criteria[i].fieldName == fieldName) + { + matches.push(queryFilter.criteria[i] as QFilterCriteriaWithId); + } + } + + if(matches.length == 0) + { + return (null); + } + else if(matches.length == 1) + { + return (matches[0]); + } + else + { + return "tooComplex"; + } + }; + + + /******************************************************************************* + ** Event handler for QuickFilter component, to remove a quick filter field from + ** the screen. + *******************************************************************************/ + const handleRemoveQuickFilterField = (fieldName: string): void => + { + const index = quickFilterFieldNames.indexOf(fieldName) + if(index >= 0) + { + ////////////////////////////////////// + // remove this field from the query // + ////////////////////////////////////// + const criteria = new QFilterCriteria(fieldName, null, []); + updateQuickCriteria(criteria, false, true); + + quickFilterFieldNames.splice(index, 1); + setQuickFilterFieldNames(quickFilterFieldNames); + } + }; + + + /******************************************************************************* + ** Event handler for button that opens the add-quick-filter menu + *******************************************************************************/ + const openAddQuickFilterMenu = (event: any) => + { + setAddQuickFilterMenu(event.currentTarget); + setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1); + } + + + /******************************************************************************* + ** Handle closing the add-quick-filter menu + *******************************************************************************/ + const closeAddQuickFilterMenu = () => + { + setAddQuickFilterMenu(null); + } + + + /******************************************************************************* + ** Add a quick-filter field to the screen, from either the user selecting one, + ** or from a new query being activated, etc. + *******************************************************************************/ + const addQuickFilterField = (newValue: any, reason: "blur" | "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | "columnMenu" | "activatedView" | string) => + { + console.log(`Adding quick filter field as: ${JSON.stringify(newValue)}`); + if (reason == "blur") + { + ////////////////////////////////////////////////////////////////// + // this keeps a click out of the menu from selecting the option // + ////////////////////////////////////////////////////////////////// + return; + } + + const fieldName = newValue ? newValue.fieldName : null; + if (fieldName) + { + if(defaultQuickFilterFieldNameMap[fieldName]) + { + return; + } + + if (quickFilterFieldNames.indexOf(fieldName) == -1) + { + ///////////////////////////////// + // add the field if we need to // + ///////////////////////////////// + quickFilterFieldNames.push(fieldName); + setQuickFilterFieldNames(quickFilterFieldNames); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // only do this when user has added the field (e.g., not when adding it because of a selected view or filter-in-url) // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected" && reason != "activatedView") + { + setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5); + } + } + else if(reason == "columnMenu") + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if field was already on-screen, but user clicked an option from the columnMenu, then open the quick-filter field // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5); + } + + closeAddQuickFilterMenu(); + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + const handleFieldListMenuSelection = (field: QFieldMetaData, table: QTableMetaData): void => + { + let fullFieldName = field.name; + if(table && table.name != tableMetaData.name) + { + fullFieldName = `${table.name}.${field.name}`; + } + + addQuickFilterField({fieldName: fullFieldName}, "selectedFromAddFilterMenu"); + } + + + /******************************************************************************* + ** event handler for the Filter Builder button - e.g., opens the parent's grid's + ** filter panel + *******************************************************************************/ + const openFilterBuilder = (e: React.MouseEvent | React.MouseEvent) => + { + gridApiRef.current.showFilterPanel(); + }; + + + /******************************************************************************* + ** event handler for the clear-filters modal + *******************************************************************************/ + const handleClearFiltersAction = (event: React.KeyboardEvent, isYesButton: boolean = false) => + { + if (isYesButton || event.key == "Enter") + { + setShowClearFiltersWarning(false); + setQueryFilter(new QQueryFilter([], queryFilter.orderBys)); + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + const removeCriteriaByIndex = (index: number) => + { + queryFilter.criteria.splice(index, 1); + setQueryFilter(queryFilter); + } + + + /******************************************************************************* + ** format the current query as a string for showing on-screen as a preview. + *******************************************************************************/ + const queryToAdvancedString = () => + { + if(queryFilter == null || !queryFilter.criteria) + { + return (); + } + + let counter = 0; + + return ( + + {queryFilter.criteria.map((criteria, i) => + { + const {criteriaIsValid} = validateCriteria(criteria, null); + if(criteriaIsValid) + { + counter++; + + return ( + handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}> + {counter > 1 ? {queryFilter.booleanOperator}  : } + {FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)} + {mouseOverElement == `queryPreview-${i}` && removeCriteriaByIndex(i)} />} + + ); + } + else + { + return (); + } + })} + + ); + }; + + + /******************************************************************************* + ** event handler for toggling between modes - basic & advanced. + *******************************************************************************/ + const modeToggleClicked = (newValue: string | null) => + { + if (newValue) + { + if(newValue == "basic") + { + //////////////////////////////////////////////////////////////////////////////// + // we're always allowed to go to advanced - // + // but if we're trying to go to basic, make sure the filter isn't too complex // + //////////////////////////////////////////////////////////////////////////////// + const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); + if (!canFilterWorkAsBasic) + { + console.log("Query cannot work as basic - so - not allowing toggle to basic.") + return; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // when going to basic, make sure all fields in the current query are active as quick-filters // + //////////////////////////////////////////////////////////////////////////////////////////////// + if (queryFilter && queryFilter.criteria) + { + ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "modeToggleClicked", "basic"); + } + } + + ////////////////////////////////////////////////////////////////////////////////////// + // note - this is a callback to the parent - as it is responsible for this state... // + ////////////////////////////////////////////////////////////////////////////////////// + setMode(newValue); + } + }; + + + /******************************************************************************* + ** make sure that all fields in the current query are on-screen as quick-filters + ** (that is, if the query can be basic) + *******************************************************************************/ + const ensureAllFilterCriteriaAreActiveQuickFilters = (tableMetaData: QTableMetaData, queryFilter: QQueryFilter, reason: "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | string, newMode?: string) => + { + if(!tableMetaData || !queryFilter) + { + return; + } + + const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); + if (!canFilterWorkAsBasic) + { + console.log("query is too complex for basic - so - switching to advanced"); + modeToggleClicked("advanced"); + forceUpdate(); + return; + } + + const modeToUse = newMode ?? mode; + if(modeToUse == "basic") + { + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const criteria = queryFilter.criteria[i]; + if (criteria && criteria.fieldName) + { + addQuickFilterField(criteria, reason); + } + } + } + } + + + /******************************************************************************* + ** count how many valid criteria are in the query - for showing badge + *******************************************************************************/ + const countValidCriteria = (queryFilter: QQueryFilter): number => + { + let count = 0; + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const {criteriaIsValid} = validateCriteria(queryFilter.criteria[i], null); + if(criteriaIsValid) + { + count++; + } + } + return count; + } + + + /******************************************************************************* + ** Event handler for setting the sort from that menu + *******************************************************************************/ + const handleSetSort = (field: QFieldMetaData, table: QTableMetaData, isAscending: boolean = true): void => + { + const fullFieldName = table && table.name != tableMetaData.name ? `${table.name}.${field.name}` : field.name; + queryFilter.orderBys = [new QFilterOrderBy(fullFieldName, isAscending)] + + setQueryFilter(queryFilter); + forceUpdate(); + } + + + /******************************************************************************* + ** event handler for a click on a field's up or down arrow in the sort menu + *******************************************************************************/ + const handleSetSortArrowClick = (field: QFieldMetaData, table: QTableMetaData, event: any): void => + { + event.stopPropagation(); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // make sure this is an event handler for one of our icons (not something else in the dom here in our end-adornments) // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const isAscending = event.target.innerHTML == "arrow_upward"; + const isDescending = event.target.innerHTML == "arrow_downward"; + if(isAscending || isDescending) + { + handleSetSort(field, table, isAscending); + } + } + + + /******************************************************************************* + ** event handler for clicking the current sort up/down arrow, to toggle direction. + *******************************************************************************/ + function toggleSortDirection(event: React.MouseEvent): 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} {orderBy.isAscending ? "arrow_upward" : "arrow_downward"} + } + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // this is being used as a version of like forcing that we get re-rendered if the query filter changes... // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + const [lastIndex, setLastIndex] = useState(queryFilterJSON); + if(queryFilterJSON != lastIndex) + { + ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "defaultFilterLoaded"); + setLastIndex(queryFilterJSON); + } + + /////////////////////////////////////////////////// + // set some status flags based on current filter // + /////////////////////////////////////////////////// + const hasValidFilters = queryFilter && countValidCriteria(queryFilter) > 0; + const {canFilterWorkAsBasic, reasonsWhyItCannot} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); + let reasonWhyBasicIsDisabled = null; + if(reasonsWhyItCannot && reasonsWhyItCannot.length > 0) + { + reasonWhyBasicIsDisabled = <> + Your current Filter cannot be managed using Basic mode because: +
      + {reasonsWhyItCannot.map((reason, i) =>
    • {reason}
    • )} +
    + + } + + const borderGray = colors.grayLines.main; + + const sortMenuComponent = ( + arrow_upwardarrow_downward
    } + handleAdornmentClick={handleSetSortArrowClick} + />); + + const filterBuilderMouseEvents = + { + onMouseOver: () => handleMouseOverElement("filterBuilderButton"), + onMouseOut: () => handleMouseOutElement() + }; + + return ( + + + {/* First row: Saved Views button (with Columns button in the middle of it), then space-between, then basic|advanced toggle */} + + + {savedViewsComponent} + {columnMenuComponent} + + + + modeToggleClicked(newValue)} + size="small" + sx={{pl: 0.5, width: "10rem"}} + > + Basic + Advanced + + + + + + {/* Second row: Basic or advanced mode - with sort-by control on the right (of each) */} + + { + /////////////////////////////////////////////////////////////////////////////////// + // basic mode - wrapping-list of fields & add-field button, then sort-by control // + /////////////////////////////////////////////////////////////////////////////////// + mode == "basic" && + + + <> + { + tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) => + { + const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = getDefaultOperatorForField(field); + + return (); + }) + } + {/* vertical rule */} + + { + tableMetaData && quickFilterFieldNames?.map((fieldName) => + { + const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = getDefaultOperatorForField(field); + + return (defaultQuickFilterFieldNameMap[fieldName] ? null : ); + }) + } + { + tableMetaData && add)}} + buttonChildren={"Add Filter"} + isModeSelectOne={true} + handleSelectedField={handleFieldListMenuSelection} + /> + } + + + + {sortMenuComponent} + + + } + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // advanced mode - 2 rows - one for Filter Builder button & sort control, 2nd row for the filter-detail box // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + metaData && tableMetaData && mode == "advanced" && + + + + + <> + + { + hasValidFilters && mouseOverElement == "filterBuilderButton" && setShowClearFiltersWarning(true)} /> + } + + + setShowClearFiltersWarning(false)} onKeyPress={(e) => handleClearFiltersAction(e)}> + Confirm + + Are you sure you want to remove all conditions from the current filter? + + + setShowClearFiltersWarning(false)} /> + handleClearFiltersAction(null, true)} /> + + + + + {sortMenuComponent} + + + + { + + {queryToAdvancedString()} + + } + + + } + + + ); +}); + +export function getDefaultQuickFilterFieldNames(table: QTableMetaData): string[] +{ + const defaultQuickFilterFieldNames: string[] = []; + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // check if there's materialDashboard tableMetaData, and if it has defaultQuickFilterFieldNames // + ////////////////////////////////////////////////////////////////////////////////////////////////// + const mdbMetaData = table?.supplementalTableMetaData?.get("materialDashboard"); + if (mdbMetaData) + { + if (mdbMetaData?.defaultQuickFilterFieldNames?.length) + { + for (let i = 0; i < mdbMetaData.defaultQuickFilterFieldNames.length; i++) + { + defaultQuickFilterFieldNames.push(mdbMetaData.defaultQuickFilterFieldNames[i]); + } + } + } + + ///////////////////////////////////////////// + // if still none, then look for T1 section // + ///////////////////////////////////////////// + if (defaultQuickFilterFieldNames.length == 0) + { + if (table.sections) + { + const t1Sections = table.sections.filter((s: QTableSection) => s.tier == "T1"); + if (t1Sections.length) + { + for (let i = 0; i < t1Sections.length; i++) + { + if (t1Sections[i].fieldNames) + { + for (let j = 0; j < t1Sections[i].fieldNames.length; j++) + { + defaultQuickFilterFieldNames.push(t1Sections[i].fieldNames[j]); + } + } + } + } + } + } + + return (defaultQuickFilterFieldNames); +} + +export default BasicAndAdvancedQueryControls; diff --git a/src/qqq/components/query/CustomPaginationComponent.tsx b/src/qqq/components/query/CustomPaginationComponent.tsx new file mode 100644 index 0000000..c6eccfc --- /dev/null +++ b/src/qqq/components/query/CustomPaginationComponent.tsx @@ -0,0 +1,122 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {TablePagination} from "@mui/material"; +import Box from "@mui/material/Box"; +import Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; +import {GridRowsProp} from "@mui/x-data-grid-pro"; +import React from "react"; +import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +interface CustomPaginationProps +{ + tableMetaData: QTableMetaData; + rows: GridRowsProp[]; + totalRecords: number; + distinctRecords: number; + pageNumber: number; + rowsPerPage: number; + loading: boolean; + isJoinMany: boolean; + handlePageChange: (value: number) => void; + handleRowsPerPageChange: (value: number) => void; +} + +/******************************************************************************* + ** DataGrid custom component - for pagination! + *******************************************************************************/ +export default function CustomPaginationComponent({tableMetaData, rows, totalRecords, distinctRecords, pageNumber, rowsPerPage, loading, isJoinMany, handlePageChange, handleRowsPerPageChange}: CustomPaginationProps): JSX.Element +{ + // @ts-ignore + const defaultLabelDisplayedRows = ({from, to, count}) => + { + const tooltipHTML = <> + The number of rows shown on this screen may be greater than the number of {tableMetaData?.label} records + that match your query, because you have included fields from other tables which may have + more than one record associated with each {tableMetaData?.label}. + + let distinctPart = isJoinMany ? ( +  ({ValueUtils.safeToLocaleString(distinctRecords)} distinct + info_outlined + + ) + ) : <>; + + if (tableMetaData && !tableMetaData.capabilities.has(Capability.TABLE_COUNT)) + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // to avoid a non-countable table showing (this is what data-grid did) 91-100 even if there were only 95 records, // + // we'll do this... not quite good enough, but better than the original // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (rows.length > 0 && rows.length < to - from) + { + to = from + rows.length; + } + return (`Showing ${from.toLocaleString()} to ${to.toLocaleString()}`); + } + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // treat -1 as the sentinel that it's set as below -- remember, we did that so that 'to' would have a value in here when there's no count. // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (count !== null && count !== undefined && count !== -1) + { + if (count === 0) + { + return (loading ? "Counting..." : "No rows"); + } + + return + Showing {from.toLocaleString()} to {to.toLocaleString()} of + { + count == -1 ? + <>more than {to.toLocaleString()} + : <> {count.toLocaleString()}{distinctPart} + } + ; + } + else + { + return ("Counting..."); + } + }; + + + return ( + handlePageChange(value)} + onRowsPerPageChange={(event) => handleRowsPerPageChange(Number(event.target.value))} + labelDisplayedRows={defaultLabelDisplayedRows} + /> + ); + +} diff --git a/src/qqq/components/query/ExportMenuItem.tsx b/src/qqq/components/query/ExportMenuItem.tsx new file mode 100644 index 0000000..3afd384 --- /dev/null +++ b/src/qqq/components/query/ExportMenuItem.tsx @@ -0,0 +1,131 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import MenuItem from "@mui/material/MenuItem"; +import {GridColDef, GridExportMenuItemProps} from "@mui/x-data-grid-pro"; +import React from "react"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +interface QExportMenuItemProps extends GridExportMenuItemProps<{}> +{ + tableMetaData: QTableMetaData; + totalRecords: number + columnsModel: GridColDef[]; + columnVisibilityModel: { [index: string]: boolean }; + queryFilter: QQueryFilter; + format: string; +} + +/******************************************************************************* + ** Component to serve as an item in the Export menu + *******************************************************************************/ +export default function ExportMenuItem(props: QExportMenuItemProps) +{ + const {format, tableMetaData, totalRecords, columnsModel, columnVisibilityModel, queryFilter, hideMenu} = props; + + return ( + + { + /////////////////////////////////////////////////////////////////////////////// + // build the list of visible fields. note, not doing them in-order (in case // + // the user did drag & drop), because column order model isn't right yet // + // so just doing them to match columns (which were pKey, then sorted) // + /////////////////////////////////////////////////////////////////////////////// + const visibleFields: string[] = []; + columnsModel.forEach((gridColumn) => + { + const fieldName = gridColumn.field; + if (columnVisibilityModel[fieldName] !== false) + { + visibleFields.push(fieldName); + } + }); + + ////////////////////////////////////// + // construct the url for the export // + ////////////////////////////////////// + const dateString = ValueUtils.formatDateTimeForFileName(new Date()); + const filename = `${tableMetaData.label} Export ${dateString}.${format}`; + const url = `/data/${tableMetaData.name}/export/${filename}`; + + const encodedFilterJSON = encodeURIComponent(JSON.stringify(queryFilter)); + + ////////////////////////////////////////////////////////////////////////////////////// + // open a window (tab) with a little page that says the file is being generated. // + // then have that page load the url for the export. // + // If there's an error, it'll appear in that window. else, the file will download. // + ////////////////////////////////////////////////////////////////////////////////////// + const exportWindow = window.open("", "_blank"); + exportWindow.document.write(` + + + ${filename} + + + + Generating file ${filename}${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}... +
    + + +
    + + `); + + /* + // todo - probably better - generate the report in an iframe... + // only open question is, giving user immediate feedback, and knowing when the stream has started and/or stopped + // maybe a busy-loop that would check iframe's url (e.g., after posting should change, maybe?) + const iframe = document.getElementById("exportIFrame"); + const form = iframe.querySelector("form"); + form.action = url; + form.target = "exportIFrame"; + (iframe.querySelector("#authorizationInput") as HTMLInputElement).value = qController.getAuthorizationHeaderValue(); + form.submit(); + */ + + /////////////////////////////////////////// + // Hide the export menu after the export // + /////////////////////////////////////////// + hideMenu?.(); + }} + > + Export + {` ${format.toUpperCase()}`} +
    + ); +} + diff --git a/src/qqq/components/query/FieldListMenu.tsx b/src/qqq/components/query/FieldListMenu.tsx new file mode 100644 index 0000000..2436d57 --- /dev/null +++ b/src/qqq/components/query/FieldListMenu.tsx @@ -0,0 +1,736 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +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) => 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 | React.MouseEvent | React.MouseEvent, 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) + { + 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, 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, 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) + { + console.log("In doHandleAdornmentClick"); + closeMenu(); + handleAdornmentClick(field, table, event); + } + + + let index = -1; + const textFieldId = `field-list-dropdown-${idPrefix}-textField`; + let listItemPadding = isModeToggle ? "0.125rem": "0.5rem"; + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // for z-indexes, we set each table header to i+1, then the fields in that table to i (so they go behind it) // + // then we increment i by 2 for the next table (so the next header goes above the previous header) // + // this fixes a thing where, if one table's name wrapped to 2 lines, then when the next table below it would // + // come up, if it was only 1 line, then the second line from the previous one would bleed through. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + let zIndex = 1; + + return ( + <> + + + + { + heading && + + {heading} + + } + + + { + searchText != "" && + { + updateSearch(null); + document.getElementById(textFieldId).focus(); + }}>close + } + + + + { + fieldsByTableToShow.map((tableWithFields) => + { + let headerContents = null; + const headerTable = tableWithFields.table || tableMetaData; + if(tableWithFields.table || showTableHeaderEvenIfNoExposedJoins) + { + headerContents = ({headerTable.label} Fields); + } + + if(isModeToggle) + { + headerContents = ( handleTableToggle(event, headerTable)} + />} + label={{headerTable.label} Fields ({tableToggleCounts[headerTable.name]})} />) + } + + if(isModeToggle) + { + headerContents = ( + <> + toggleCollapsedTable(headerTable.name)} + sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}} + disableRipple={true} + > + {collapsedTables[headerTable.name] ? "expand_less" : "expand_more"} + + {headerContents} + + ) + } + + let marginLeft = "unset"; + if(isModeToggle) + { + marginLeft = "-1rem"; + } + + zIndex += 2; + + return ( + + <> + {headerContents && {headerContents}} + { + tableWithFields.fields.map((field) => + { + index++; + const key = `${tableWithFields.table?.name}-${field.name}` + + if(collapsedTables[headerTable.name]) + { + return (); + } + + 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 = + {label} + doHandleAdornmentClick(field, tableWithFields.table, event)}> + {fieldEndAdornment} + + ; + } + + let contents = <>{label}; + let paddingLeft = "0.5rem"; + + if (isModeToggle) + { + contents = ( handleFieldToggle(event, field, tableWithFields.table)} + />} + label={label} />); + paddingLeft = "2.5rem"; + } + + return handleMouseOver(event, field, tableWithFields.table)} + {...onClick} + >{contents}; + }) + } + + + ); + }) + } + { + index == -1 && No fields found. + } + + + + + + ); +} diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index 364d52f..e170d00 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -24,7 +24,7 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; -import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete"; +import Autocomplete from "@mui/material/Autocomplete"; import Box from "@mui/material/Box"; import FormControl from "@mui/material/FormControl/FormControl"; import Icon from "@mui/material/Icon/Icon"; @@ -34,6 +34,7 @@ import Select, {SelectChangeEvent} from "@mui/material/Select/Select"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import React, {ReactNode, SyntheticEvent, useState} from "react"; +import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; @@ -52,6 +53,27 @@ export enum ValueMode PVS_MULTI = "PVS_MULTI", } +export const getValueModeRequiredCount = (valueMode: ValueMode): number => +{ + switch (valueMode) + { + case ValueMode.NONE: + return (0); + case ValueMode.SINGLE: + case ValueMode.SINGLE_DATE: + case ValueMode.SINGLE_DATE_TIME: + case ValueMode.PVS_SINGLE: + return (1); + case ValueMode.DOUBLE: + case ValueMode.DOUBLE_DATE: + case ValueMode.DOUBLE_DATE_TIME: + return (2); + case ValueMode.MULTI: + case ValueMode.PVS_MULTI: + return (null); + } +} + export interface OperatorOption { label: string; @@ -177,16 +199,71 @@ interface FilterCriteriaRowProps updateBooleanOperator: (newValue: string) => void; } -FilterCriteriaRow.defaultProps = {}; - -function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean) -{ - const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label)); - for (let i = 0; i < sortedFields.length; i++) +FilterCriteriaRow.defaultProps = { - const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name; - fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName}); + }; + +export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue?: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string} +{ + let criteriaIsValid = true; + let criteriaStatusTooltip = "This condition is fully defined and is part of your filter."; + + function isNotSet(value: any) + { + return (value === null || value == undefined || String(value).trim() === ""); } + + if(!criteria) + { + criteriaIsValid = false; + criteriaStatusTooltip = "This condition is not defined."; + return {criteriaIsValid, criteriaStatusTooltip}; + } + + if (!criteria.fieldName) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must select a field to begin to define this condition."; + } + else if (!criteria.operator) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must select an operator to continue to define this condition."; + } + else + { + if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK) + { + ////////////////////////////////// + // don't need to look at values // + ////////////////////////////////// + } + else if (criteria.operator == QCriteriaOperator.BETWEEN || criteria.operator == QCriteriaOperator.NOT_BETWEEN) + { + if (criteria.values.length < 2 || isNotSet(criteria.values[0]) || isNotSet(criteria.values[1])) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter two values to complete the definition of this condition."; + } + } + else if (criteria.operator == QCriteriaOperator.IN || criteria.operator == QCriteriaOperator.NOT_IN) + { + if (criteria.values.length < 1 || isNotSet(criteria.values[0])) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition."; + } + } + else + { + if (!criteria.values || isNotSet(criteria.values[0])) + { + criteriaIsValid = false; + criteriaStatusTooltip = "You must enter a value to complete the definition of this condition."; + } + } + } + return {criteriaIsValid, criteriaStatusTooltip}; } export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator}: FilterCriteriaRowProps): JSX.Element @@ -195,27 +272,6 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, const [operatorSelectedValue, setOperatorSelectedValue] = useState(null as OperatorOption); const [operatorInputValue, setOperatorInputValue] = useState(""); - /////////////////////////////////////////////////////////////// - // set up the array of options for the fields Autocomplete // - // also, a groupBy function, in case there are exposed joins // - /////////////////////////////////////////////////////////////// - const fieldOptions: any[] = []; - makeFieldOptionsForTable(tableMetaData, fieldOptions, false); - let fieldsGroupBy = null; - - if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0) - { - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) - { - const exposedJoin = tableMetaData.exposedJoins[i]; - if (metaData.tables.has(exposedJoin.joinTable.name)) - { - fieldsGroupBy = (option: any) => `${option.table.label} fields`; - makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true); - } - } - } - //////////////////////////////////////////////////////////// // set up array of options for operator dropdown // // only call the function to do it if we have a field set // @@ -332,6 +388,24 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, { criteria.values = newValue.implicitValues; } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // we've seen cases where switching operators can sometimes put a null in as the first value... // + // that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. // + ////////////////////////////////////////////////////////////////////////////////////////////////// + if(criteria.values && criteria.values.length == 1 && criteria.values[0] == null) + { + criteria.values = []; + } + + if(newValue.valueMode) + { + const requiredValueCount = getValueModeRequiredCount(newValue.valueMode); + if(requiredValueCount != null && criteria.values.length > requiredValueCount) + { + criteria.values.splice(requiredValueCount); + } + } } else { @@ -383,111 +457,19 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, return (false); }; - function isFieldOptionEqual(option: any, value: any) - { - return option.fieldName === value.fieldName; - } - - function getFieldOptionLabel(option: any) - { - ///////////////////////////////////////////////////////////////////////////////////////// - // note - we're using renderFieldOption below for the actual select-box options, which // - // are always jut field label (as they are under groupings that show their table name) // - ///////////////////////////////////////////////////////////////////////////////////////// - if(option && option.field && option.table) - { - if(option.table.name == tableMetaData.name) - { - return (option.field.label); - } - else - { - return (option.table.label + ": " + option.field.label); - } - } - - return (""); - } - - ////////////////////////////////////////////////////////////////////////////////////////////// - // for options, we only want the field label (contrast with what we show in the input box, // - // which comes out of getFieldOptionLabel, which is the table-label prefix for join fields) // - ////////////////////////////////////////////////////////////////////////////////////////////// - function renderFieldOption(props: React.HTMLAttributes, option: any, state: AutocompleteRenderOptionState): ReactNode - { - let label = "" - if(option && option.field) - { - label = (option.field.label); - } - - return (
  • {label}
  • ); - } - function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption) { return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues)); } - let criteriaIsValid = true; - let criteriaStatusTooltip = "This condition is fully defined and is part of your filter."; + const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue); - function isNotSet(value: any) - { - return (value === null || value == undefined || String(value).trim() === ""); - } - - if(!criteria.fieldName) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must select a field to begin to define this condition."; - } - else if(!criteria.operator) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must select an operator to continue to define this condition."; - } - else - { - if(operatorSelectedValue) - { - if (operatorSelectedValue.valueMode == ValueMode.NONE || operatorSelectedValue.implicitValues) - { - ////////////////////////////////// - // don't need to look at values // - ////////////////////////////////// - } - else if(operatorSelectedValue.valueMode == ValueMode.DOUBLE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE_TIME) - { - if(criteria.values.length < 2 || isNotSet(criteria.values[0]) || isNotSet(criteria.values[1])) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must enter two values to complete the definition of this condition."; - } - } - else if(operatorSelectedValue.valueMode == ValueMode.MULTI || operatorSelectedValue.valueMode == ValueMode.PVS_MULTI) - { - if(criteria.values.length < 1 || isNotSet(criteria.values[0])) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition."; - } - } - else - { - if(!criteria.values || isNotSet(criteria.values[0])) - { - criteriaIsValid = false; - criteriaStatusTooltip = "You must enter a value to complete the definition of this condition."; - } - } - } - } + const tooltipEnterDelay = 750; return ( - + - + close @@ -502,24 +484,10 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, : } - ()} - // @ts-ignore - defaultValue={defaultFieldValue} - options={fieldOptions} - onChange={handleFieldChange} - isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)} - groupBy={fieldsGroupBy} - getOptionLabel={(option) => getFieldOptionLabel(option)} - renderOption={(props, option, state) => renderFieldOption(props, option, state)} - autoSelect={true} - autoHighlight={true} - slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} - /> + - + ()} @@ -546,8 +514,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)} /> - - + + { criteriaIsValid ? check diff --git a/src/qqq/components/query/FilterCriteriaRowValues.tsx b/src/qqq/components/query/FilterCriteriaRowValues.tsx index 51cfa3a..90806be 100644 --- a/src/qqq/components/query/FilterCriteriaRowValues.tsx +++ b/src/qqq/components/query/FilterCriteriaRowValues.tsx @@ -44,9 +44,13 @@ interface Props field: QFieldMetaData; table: QTableMetaData; valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void; + initiallyOpenMultiValuePvs?: boolean } -FilterCriteriaRowValues.defaultProps = {}; +FilterCriteriaRowValues.defaultProps = + { + initiallyOpenMultiValuePvs: false + }; export const getTypeForTextField = (field: QFieldMetaData): string => { @@ -110,16 +114,17 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth + autoFocus={true} />; }; -function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler}: Props): JSX.Element +function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler, initiallyOpenMultiValuePvs}: Props): JSX.Element { const [, forceUpdate] = useReducer((x) => x + 1, 0); if (!operatorOption) { - return
    ; + return null; } function saveNewPasterValues(newValues: any[]) @@ -148,7 +153,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC switch (operatorOption.valueMode) { case ValueMode.NONE: - return
    ; + return null; case ValueMode.SINGLE: return makeTextField(field, criteria, valueChangeHandler); case ValueMode.SINGLE_DATE: @@ -241,6 +246,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC isMultiple fieldLabel="Values" initialValues={initialValues} + initiallyOpen={false /*initiallyOpenMultiValuePvs*/} inForm={false} onChange={(value: any) => valueChangeHandler(null, "all", value)} variant="standard" diff --git a/src/qqq/components/query/QueryScreenActionMenu.tsx b/src/qqq/components/query/QueryScreenActionMenu.tsx new file mode 100644 index 0000000..73147a4 --- /dev/null +++ b/src/qqq/components/query/QueryScreenActionMenu.tsx @@ -0,0 +1,134 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability"; +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import Divider from "@mui/material/Divider"; +import Icon from "@mui/material/Icon"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import React, {useState} from "react"; +import {useNavigate} from "react-router-dom"; +import {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons"; + +interface QueryScreenActionMenuProps +{ + metaData: QInstance; + tableMetaData: QTableMetaData; + tableProcesses: QProcessMetaData[]; + bulkLoadClicked: () => void; + bulkEditClicked: () => void; + bulkDeleteClicked: () => void; + processClicked: (process: QProcessMetaData) => void; +} + +QueryScreenActionMenu.defaultProps = { +}; + +export default function QueryScreenActionMenu({metaData, tableMetaData, tableProcesses, bulkLoadClicked, bulkEditClicked, bulkDeleteClicked, processClicked}: QueryScreenActionMenuProps): JSX.Element +{ + const [anchorElement, setAnchorElement] = useState(null) + + const navigate = useNavigate(); + + const openActionsMenu = (event: any) => + { + setAnchorElement(event.currentTarget); + } + + const closeActionsMenu = () => + { + setAnchorElement(null); + } + + const pushDividerIfNeeded = (menuItems: JSX.Element[]) => + { + if (menuItems.length > 0) + { + menuItems.push(); + } + }; + + const runSomething = (handler: () => void) => + { + closeActionsMenu(); + handler(); + } + + const menuItems: JSX.Element[] = []; + if (tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission) + { + menuItems.push( runSomething(bulkLoadClicked)}>library_addBulk Load); + } + if (tableMetaData.capabilities.has(Capability.TABLE_UPDATE) && tableMetaData.editPermission) + { + menuItems.push( runSomething(bulkEditClicked)}>editBulk Edit); + } + if (tableMetaData.capabilities.has(Capability.TABLE_DELETE) && tableMetaData.deletePermission) + { + menuItems.push( runSomething(bulkDeleteClicked)}>deleteBulk Delete); + } + + const runRecordScriptProcess = metaData?.processes.get("runRecordScript"); + if (runRecordScriptProcess) + { + const process = runRecordScriptProcess; + menuItems.push( runSomething(() => processClicked(process))}>{process.iconName ?? "arrow_forward"}{process.label}); + } + + menuItems.push( navigate(`${metaData.getTablePathByName(tableMetaData.name)}/dev`)}>codeDeveloper Mode); + + if (tableProcesses && tableProcesses.length) + { + pushDividerIfNeeded(menuItems); + } + + tableProcesses.sort((a, b) => a.label.localeCompare(b.label)); + tableProcesses.map((process) => + { + menuItems.push( runSomething(() => processClicked(process))}>{process.iconName ?? "arrow_forward"}{process.label}); + }); + + if (menuItems.length === 0) + { + menuItems.push(blockNo actions available); + } + + return ( + <> + + + {menuItems} + + + ) +} diff --git a/src/qqq/components/query/QuickFilter.tsx b/src/qqq/components/query/QuickFilter.tsx new file mode 100644 index 0000000..d73ded5 --- /dev/null +++ b/src/qqq/components/query/QuickFilter.tsx @@ -0,0 +1,511 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {Tooltip} from "@mui/material"; +import Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Menu from "@mui/material/Menu"; +import TextField from "@mui/material/TextField"; +import React, {SyntheticEvent, useContext, useState} from "react"; +import QContext from "QContext"; +import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; +import {getDefaultCriteriaValue, getOperatorOptions, getValueModeRequiredCount, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow"; +import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; +import XIcon from "qqq/components/query/XIcon"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import TableUtils from "qqq/utils/qqq/TableUtils"; + +export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex"; + +interface QuickFilterProps +{ + tableMetaData: QTableMetaData; + fullFieldName: string; + fieldMetaData: QFieldMetaData; + criteriaParam: CriteriaParamType; + updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean, doRemoveCriteria: boolean) => void; + defaultOperator?: QCriteriaOperator; + handleRemoveQuickFilterField?: (fieldName: string) => void; +} + +QuickFilter.defaultProps = + { + defaultOperator: QCriteriaOperator.EQUALS, + handleRemoveQuickFilterField: null + }; + +let seedId = new Date().getTime() % 173237; + +export const quickFilterButtonStyles = { + fontSize: "0.75rem", + fontWeight: 600, + color: "#757575", + textTransform: "none", + borderRadius: "2rem", + border: "1px solid #757575", + minWidth: "3.5rem", + minHeight: "auto", + padding: "0.375rem 0.625rem", whiteSpace: "nowrap", + marginBottom: "0.5rem" +} + +/******************************************************************************* + ** Test if a CriteriaParamType represents an actual query criteria - or, if it's + ** null or the "tooComplex" placeholder. + *******************************************************************************/ +const criteriaParamIsCriteria = (param: CriteriaParamType): boolean => +{ + return (param != null && param != "tooComplex"); +}; + +/******************************************************************************* + ** Test of an OperatorOption equals a query Criteria - that is - that the + ** operators within them are equal - AND - if the OperatorOption has implicit + ** values (e.g., the booleans), then those options equal the criteria's options. + *******************************************************************************/ +const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteria: QFilterCriteriaWithId): boolean => +{ + if(operatorOption.value == criteria.operator) + { + if(operatorOption.implicitValues) + { + if(JSON.stringify(operatorOption.implicitValues) == JSON.stringify(criteria.values)) + { + return (true); + } + else + { + return (false); + } + } + + return (true); + } + + return (false); +} + + +/******************************************************************************* + ** Get the object to use as the selected OperatorOption (e.g., value for that + ** autocomplete), given an array of options, the query's active criteria in this + ** field, and the default operator to use for this field + *******************************************************************************/ +const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption => +{ + if(criteria) + { + const filteredOptions = operatorOptions.filter(o => doesOperatorOptionEqualCriteria(o, criteria)); + if(filteredOptions.length > 0) + { + return (filteredOptions[0]); + } + } + + const filteredOptions = operatorOptions.filter(o => o.value == defaultOperator); + if(filteredOptions.length > 0) + { + return (filteredOptions[0]); + } + + return (null); +} + +/******************************************************************************* + ** Component to render a QuickFilter - that is - a button, with a Menu under it, + ** with Operator and Value controls. + *******************************************************************************/ +export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField}: QuickFilterProps): JSX.Element +{ + const operatorOptions = fieldMetaData ? getOperatorOptions(tableMetaData, fullFieldName) : []; + const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName); + + const [isOpen, setIsOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const [isMouseOver, setIsMouseOver] = useState(false); + + const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? criteriaParam as QFilterCriteriaWithId : null); + const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId); + + const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator)); + const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label); + + const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue); + + const {accentColor} = useContext(QContext); + + + /******************************************************************************* + ** + *******************************************************************************/ + function handleMouseOverElement() + { + setIsMouseOver(true); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function handleMouseOutElement() + { + setIsMouseOver(false); + } + + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // handle a change to the criteria from outside this component (e.g., the prop isn't the same as the state) // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (criteriaParamIsCriteria(criteriaParam) && JSON.stringify(criteriaParam) !== JSON.stringify(criteria)) + { + const newCriteria = criteriaParam as QFilterCriteriaWithId; + setCriteria(newCriteria); + const operatorOption = operatorOptions.filter(o => o.value == newCriteria.operator)[0]; + setOperatorSelectedValue(operatorOption); + setOperatorInputValue(operatorOption.label); + } + + /******************************************************************************* + ** Test if we need to construct a new criteria object + *******************************************************************************/ + const criteriaNeedsReset = (): boolean => + { + if(criteria != null && criteriaParam == null) + { + const defaultOperatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0]; + if(criteria.operator !== defaultOperatorOption?.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue())) + { + return (true); + } + } + + return (false); + } + + /******************************************************************************* + ** Construct a new criteria object - resetting the values tied to the oprator + ** autocomplete at the same time. + *******************************************************************************/ + const makeNewCriteria = (): QFilterCriteria => + { + const operatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0]; + const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption?.value, getDefaultCriteriaValue()); + criteria.id = id; + setOperatorSelectedValue(operatorOption); + setOperatorInputValue(operatorOption?.label); + setCriteria(criteria); + return(criteria); + } + + /******************************************************************************* + ** event handler to open the menu in response to the button being clicked. + *******************************************************************************/ + const handleOpenMenu = (event: any) => + { + setIsOpen(!isOpen); + setAnchorEl(event.currentTarget); + + setTimeout(() => + { + const element = document.getElementById("value-" + criteria.id); + element?.focus(); + }) + }; + + /******************************************************************************* + ** handler for the Menu when being closed + *******************************************************************************/ + const closeMenu = () => + { + setIsOpen(false); + setAnchorEl(null); + }; + + /******************************************************************************* + ** event handler for operator Autocomplete having its value changed + *******************************************************************************/ + const handleOperatorChange = (event: any, newValue: any, reason: string) => + { + criteria.operator = newValue ? newValue.value : null; + + if (newValue) + { + setOperatorSelectedValue(newValue); + setOperatorInputValue(newValue.label); + + if (newValue.implicitValues) + { + criteria.values = newValue.implicitValues; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // we've seen cases where switching operators can sometimes put a null in as the first value... // + // that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. // + ////////////////////////////////////////////////////////////////////////////////////////////////// + if(criteria.values && criteria.values.length == 1 && criteria.values[0] == null) + { + criteria.values = []; + } + + if(newValue.valueMode) + { + const requiredValueCount = getValueModeRequiredCount(newValue.valueMode); + if(requiredValueCount != null && criteria.values.length > requiredValueCount) + { + criteria.values.splice(requiredValueCount); + } + } + } + else + { + setOperatorSelectedValue(null); + setOperatorInputValue(""); + } + + updateCriteria(criteria, false, false); + }; + + /******************************************************************************* + ** implementation of isOptionEqualToValue for Autocomplete - compares both the + ** value (e.g., what operator it is) and the implicitValues within the option + *******************************************************************************/ + function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption) + { + return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues)); + } + + /******************************************************************************* + ** event handler for the value field (of all types), when it changes + *******************************************************************************/ + const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) => + { + // @ts-ignore + const value = newValue !== undefined ? newValue : event ? event.target.value : null; + + if (!criteria.values) + { + criteria.values = []; + } + + if (valueIndex == "all") + { + criteria.values = value; + } + else + { + criteria.values[valueIndex] = value; + } + + updateCriteria(criteria, true, false); + }; + + /******************************************************************************* + ** a noop event handler, e.g., for a too-complex + *******************************************************************************/ + const noop = () => + { + }; + + /******************************************************************************* + ** event handler that responds to 'x' button that removes the criteria from the + ** quick-filter, resetting it to a new filter. + *******************************************************************************/ + const resetCriteria = (e: React.MouseEvent) => + { + if(criteriaIsValid) + { + e.stopPropagation(); + const newCriteria = makeNewCriteria(); + updateCriteria(newCriteria, false, true); + } + } + + /******************************************************************************* + ** event handler for clicking the (x) icon that turns off this quick filter field. + ** hands off control to the function that was passed in (e.g., from RecordQueryOrig). + *******************************************************************************/ + const handleTurningOffQuickFilterField = () => + { + closeMenu() + if(handleRemoveQuickFilterField) + { + handleRemoveQuickFilterField(criteria?.fieldName); + } + } + + //////////////////////////////////////////////////////////////////////////////////// + // if no field was input (e.g., record-query is still loading), return null early // + //////////////////////////////////////////////////////////////////////////////////// + if(!fieldMetaData) + { + return (null); + } + + ////////////////////////////////////////////////////////////////////////////////////////// + // if there should be a selected value in the operator autocomplete, and it's different // + // from the last selected one, then set the state vars that control that autocomplete // + ////////////////////////////////////////////////////////////////////////////////////////// + const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator); + if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue)) + { + setOperatorSelectedValue(maybeNewOperatorSelectedValue) + setOperatorInputValue(maybeNewOperatorSelectedValue?.label) + } + + ///////////////////////////////////////////////////////////////////////////////////// + // if there wasn't a criteria, or we need to reset it (make a new one), then do so // + ///////////////////////////////////////////////////////////////////////////////////// + if (criteria == null || criteriaNeedsReset()) + { + makeNewCriteria(); + } + + ///////////////////////// + // build up the button // + ///////////////////////// + const tooComplex = criteriaParam == "tooComplex"; + const tooltipEnterDelay = 500; + + let buttonAdditionalStyles: any = {}; + let buttonContent = {tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label} + let buttonClassName = "filterNotActive"; + if (criteriaIsValid) + { + buttonAdditionalStyles.backgroundColor = accentColor + " !important"; + buttonAdditionalStyles.borderColor = accentColor + " !important"; + buttonAdditionalStyles.color = "white !important"; + buttonClassName = "filterActive"; + + let valuesString = FilterUtils.getValuesString(fieldMetaData, criteria, 1, "+N"); + + /////////////////////////////////////////// + // don't show the Equals or In operators // + /////////////////////////////////////////// + let operatorString = (<>{operatorSelectedValue.label} ); + if(operatorSelectedValue.value == QCriteriaOperator.EQUALS || operatorSelectedValue.value == QCriteriaOperator.IN) + { + operatorString = (<>) + } + + buttonContent = (<>{buttonContent}: {operatorString}{valuesString}); + } + + const mouseEvents = + { + onMouseOver: () => handleMouseOverElement(), + onMouseOut: () => handleMouseOutElement() + }; + + let button = fieldMetaData && ; + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if the criteria on this field is the "tooComplex" sentinel, then wrap the button in a tooltip stating such, and return early. // + // note this was part of original design on this widget, but later deprecated... // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (tooComplex) + { + //////////////////////////////////////////////////////////////////////////// + // wrap button in span, so disabled button doesn't cause disabled tooltip // + //////////////////////////////////////////////////////////////////////////// + return ( + + {button} + + ); + } + + /******************************************************************************* + ** event handler for 'x' button - either resets the criteria or turns off the field. + *******************************************************************************/ + const xClicked = (e: React.MouseEvent) => + { + e.stopPropagation(); + if(criteriaIsValid) + { + resetCriteria(e); + } + else + { + handleTurningOffQuickFilterField(); + } + } + + ////////////////////////////// + // return the button & menu // + ////////////////////////////// + const widthAndMaxWidth = 250 + return ( + <> + {button} + { + ///////////////////////////////////////////////////////////////////////////////////// + // only show the 'x' if it's to clear out a valid criteria on the field, // + // or if we were given a callback to remove the quick-filter field from the screen // + ///////////////////////////////////////////////////////////////////////////////////// + (criteriaIsValid || handleRemoveQuickFilterField) && isMouseOver && + } + { + isOpen && + + ()} + options={operatorOptions} + value={operatorSelectedValue as any} + inputValue={operatorInputValue} + onChange={handleOperatorChange} + onInputChange={(e, value) => setOperatorInputValue(value)} + isOptionEqualToValue={(option, value) => isOperatorOptionEqual(option, value)} + getOptionLabel={(option: any) => option.label} + autoSelect={true} + autoHighlight={true} + disableClearable + slotProps={{popper: {style: {padding: 0, maxHeight: "unset", width: "250px"}}}} + /> + + + handleValueChange(event, valueIndex, newValue)} + initiallyOpenMultiValuePvs={true} // todo - maybe not? + /> + + + } + + ); +} diff --git a/src/qqq/components/query/SelectionSubsetDialog.tsx b/src/qqq/components/query/SelectionSubsetDialog.tsx new file mode 100644 index 0000000..d601f1c --- /dev/null +++ b/src/qqq/components/query/SelectionSubsetDialog.tsx @@ -0,0 +1,74 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import TextField from "@mui/material/TextField"; +import React, {useState} from "react"; +import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; + + +/******************************************************************************* + ** Component that is the dialog for the user to enter the selection-subset + *******************************************************************************/ +export default function SelectionSubsetDialog(props: { isOpen: boolean; initialValue: number; closeHandler: (value?: number) => void }) +{ + const [value, setValue] = useState(props.initialValue); + + const handleChange = (newValue: string) => + { + setValue(parseInt(newValue)); + }; + + const keyPressed = (e: React.KeyboardEvent) => + { + if (e.key == "Enter" && value) + { + props.closeHandler(value); + } + }; + + return ( + props.closeHandler()} onKeyPress={(e) => keyPressed(e)}> + Subset of the Query Result + + How many records do you want to select? + handleChange(e.target.value)} + value={value} + sx={{width: "100%"}} + onFocus={event => event.target.select()} + /> + + + props.closeHandler()} /> + props.closeHandler(value)} /> + + + ); +} + diff --git a/src/qqq/components/query/TableVariantDialog.tsx b/src/qqq/components/query/TableVariantDialog.tsx new file mode 100644 index 0000000..b40efa5 --- /dev/null +++ b/src/qqq/components/query/TableVariantDialog.tsx @@ -0,0 +1,122 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant"; +import Autocomplete from "@mui/material/Autocomplete"; +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import TextField from "@mui/material/TextField"; +import React, {useEffect, useState} from "react"; +import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery"; +import Client from "qqq/utils/qqq/Client"; + +const qController = Client.getInstance(); + +/******************************************************************************* + ** Component that is the dialog for the user to select a variant on tables with variant backends // + *******************************************************************************/ +export default function TableVariantDialog(props: { isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void }) +{ + const [value, setValue] = useState(null); + const [dropDownOpen, setDropDownOpen] = useState(false); + const [variants, setVariants] = useState(null); + + const handleVariantChange = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) => + { + const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${props.table.name}`; + if (value != null) + { + localStorage.setItem(tableVariantLocalStorageKey, JSON.stringify(value)); + } + else + { + localStorage.removeItem(tableVariantLocalStorageKey); + } + props.closeHandler(value); + }; + + const keyPressed = (e: React.KeyboardEvent) => + { + if (e.key == "Enter" && value) + { + props.closeHandler(value); + } + }; + + useEffect(() => + { + console.log("queryVariants"); + try + { + (async () => + { + const variants = await qController.tableVariants(props.table.name); + console.log(JSON.stringify(variants)); + setVariants(variants); + })(); + } + catch (e) + { + console.log(e); + } + }, []); + + + return variants && ( + keyPressed(e)}> + {props.table.variantTableLabel} + + Select the {props.table.variantTableLabel} to be used on this table: + + { + setDropDownOpen(true); + }} + onClose={() => + { + setDropDownOpen(false); + }} + // @ts-ignore + onChange={handleVariantChange} + isOptionEqualToValue={(option, value) => option.id === value.id} + options={variants} + renderInput={(params) => } + getOptionLabel={(option) => + { + if (typeof option == "object") + { + return (option as QTableVariant).name; + } + return option; + }} + /> + + + ); +} + diff --git a/src/qqq/components/query/XIcon.tsx b/src/qqq/components/query/XIcon.tsx new file mode 100644 index 0000000..1972595 --- /dev/null +++ b/src/qqq/components/query/XIcon.tsx @@ -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 . + */ + + +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) => void; + position: "forQuickFilter" | "forAdvancedQueryPreview" | "default"; + shade: "default" | "accent" | "accentLight" +} + +XIcon.defaultProps = { + position: "default", + shade: "default" +}; + +export default function XIcon({onClick, position, shade}: XIconProps): JSX.Element +{ + const {accentColor, accentColorLight} = useContext(QContext) + + ////////////////////////// + // for default position // + ////////////////////////// + let rest: any = { + top: "-0.75rem", + left: "-0.5rem", + } + + if(position == "forQuickFilter") + { + rest = { + left: "-1.125rem", + } + } + else if(position == "forAdvancedQueryPreview") + { + rest = { + top: "-0.5rem", + left: "-0.75rem", + } + } + + let color; + switch (shade) + { + case "default": + color = colors.gray.main; + break; + case "accent": + color = accentColor; + break; + case "accentLight": + color = accentColorLight; + break; + } + + return ( + close + ) +} diff --git a/src/qqq/context/index.tsx b/src/qqq/context/index.tsx index e7ea69b..4e1ed7d 100644 --- a/src/qqq/context/index.tsx +++ b/src/qqq/context/index.tsx @@ -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", diff --git a/src/qqq/models/LoadingState.ts b/src/qqq/models/LoadingState.ts index 39d7d27..5a564f2 100644 --- a/src/qqq/models/LoadingState.ts +++ b/src/qqq/models/LoadingState.ts @@ -41,25 +41,45 @@ ** {myLoadingState.isNotLoading() && myData && ... ** - In your template, before your "slow loading" view, check for `myLoadingState.isLoadingSlow()`, e.g. ** {myLoadingState.isLoadingSlow() && } + ** + ** In addition, you can also supply a callback to run "upon slow" (e.g., when + ** moving into the slow state). *******************************************************************************/ export class LoadingState { private state: "notLoading" | "loading" | "slow" private slowTimeout: any; - private forceUpdate: () => void + private forceUpdate: () => void; + private uponSlowCallback: () => void; constructor(forceUpdate: () => void, initialState: "notLoading" | "loading" | "slow" = "notLoading") { this.forceUpdate = forceUpdate; this.state = initialState; + + if(initialState == "loading") + { + this.setLoading(); + } + else if(initialState == "notLoading") + { + this.setNotLoading(); + } } public setLoading() { + clearTimeout(this.slowTimeout); this.state = "loading"; this.slowTimeout = setTimeout(() => { this.state = "slow"; + + if(this.uponSlowCallback) + { + this.uponSlowCallback(); + } + this.forceUpdate(); }, 1000); } @@ -85,4 +105,14 @@ export class LoadingState return (this.state == "notLoading"); } + public getState(): string + { + return (this.state); + } + + public setUponSlowCallback(value: any) + { + this.uponSlowCallback = value; + } + } \ No newline at end of file diff --git a/src/qqq/models/query/QQueryColumns.ts b/src/qqq/models/query/QQueryColumns.ts new file mode 100644 index 0000000..2a7f2c6 --- /dev/null +++ b/src/qqq/models/query/QQueryColumns.ts @@ -0,0 +1,406 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {GridPinnedColumns} from "@mui/x-data-grid-pro"; +import quickSightChart from "qqq/components/widgets/misc/QuickSightChart"; +import DataGridUtils from "qqq/utils/DataGridUtils"; +import TableUtils from "qqq/utils/qqq/TableUtils"; + +/******************************************************************************* + ** member object + *******************************************************************************/ +interface Column +{ + name: string; + isVisible: boolean; + width: number; + pinned?: "left" | "right"; +} + +/******************************************************************************* + ** Model for all info we'll store about columns on a query screen. + *******************************************************************************/ +export default class QQueryColumns +{ + columns: Column[] = []; + + /******************************************************************************* + ** factory function - build a QQueryColumns object from JSON (string or parsed object). + ** + ** input json is must look like if you JSON.stringify this class - that is: + ** {columns: [{name:"",isVisible:true,width:0,pinned:"left"},{}...]} + *******************************************************************************/ + public static buildFromJSON = (json: string | any): QQueryColumns => + { + const queryColumns = new QQueryColumns(); + + if (typeof json == "string") + { + json = JSON.parse(json); + } + + queryColumns.columns = json.columns; + + return (queryColumns); + }; + + + /******************************************************************************* + ** factory function - build a default QQueryColumns object for a table + ** + *******************************************************************************/ + public static buildDefaultForTable = (table: QTableMetaData): QQueryColumns => + { + const queryColumns = new QQueryColumns(); + + queryColumns.columns = []; + queryColumns.columns.push({name: "__check__", isVisible: true, width: 100, pinned: "left"}); + + const fields = this.getSortedFieldsFromTable(table); + fields.forEach((field) => + { + const column: Column = {name: field.name, isVisible: true, width: DataGridUtils.getColumnWidthForField(field, table)}; + queryColumns.columns.push(column); + + if (field.name == table.primaryKeyField) + { + column.pinned = "left"; + } + }); + + table.exposedJoins?.forEach((exposedJoin) => + { + const joinFields = this.getSortedFieldsFromTable(exposedJoin.joinTable); + joinFields.forEach((field) => + { + const column: Column = {name: `${exposedJoin.joinTable.name}.${field.name}`, isVisible: false, width: DataGridUtils.getColumnWidthForField(field, null)}; + queryColumns.columns.push(column); + }); + }); + + return (queryColumns); + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public addColumnForNewField = (table: QTableMetaData, fieldName: string, defaultVisibilityIfInMainTable: boolean): void => + { + const [field, tableForField] = TableUtils.getFieldAndTable(table, fieldName) + + let column: Column; + if(tableForField.name == table.name) + { + column = {name: field.name, isVisible: defaultVisibilityIfInMainTable, width: DataGridUtils.getColumnWidthForField(field, table)}; + } + else + { + column = {name: `${tableForField.name}.${field.name}`, isVisible: false, width: DataGridUtils.getColumnWidthForField(field, null)}; + } + + this.columns.push(column); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public deleteColumnForOldField = (table: QTableMetaData, fieldName: string): void => + { + for (let i = 0; i < this.columns.length; i++) + { + if(this.columns[i].name == fieldName) + { + this.columns.splice(i, 1); + return; + } + } + + console.log(`Couldn't find column to be deleted, for name [${fieldName}]`); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + private static getSortedFieldsFromTable(table: QTableMetaData) + { + const fields = [...table.fields.values()]; + fields.sort((a: QFieldMetaData, b: QFieldMetaData) => + { + return a.name.localeCompare(b.name); + }); + return fields; + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public getVisibleColumnCount(): number + { + let rs = 0; + for (let i = 0; i < this.columns.length; i++) + { + if(this.columns[i].name == "__check__") + { + continue; + } + + if(this.columns[i].isVisible) + { + rs++; + } + } + return (rs); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public getVisibilityToggleStates(): { [name: string]: boolean } + { + const rs: {[name: string]: boolean} = {}; + for (let i = 0; i < this.columns.length; i++) + { + rs[this.columns[i].name] = this.columns[i].isVisible; + } + return (rs); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public setIsVisible(name: string, isVisible: boolean) + { + for (let i = 0; i < this.columns.length; i++) + { + if(this.columns[i].name == name) + { + this.columns[i].isVisible = isVisible; + break; + } + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public updateVisibility = (visibilityModel: { [name: string]: boolean }): void => + { + for (let i = 0; i < this.columns.length; i++) + { + const name = this.columns[i].name; + this.columns[i].isVisible = visibilityModel[name]; + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public updateColumnOrder = (names: string[]): void => + { + const newColumns: Column[] = []; + const rest: Column[] = []; + + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + const index = names.indexOf(column.name); + if (index > -1) + { + newColumns[index] = column; + } + else + { + rest.push(column); + } + } + + this.columns = [...newColumns, ...rest]; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public updateColumnWidth = (name: string, width: number): void => + { + for (let i = 0; i < this.columns.length; i++) + { + if (this.columns[i].name == name) + { + this.columns[i].width = width; + } + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public setPinnedLeftColumns = (names: string[]): void => + { + const leftPins: Column[] = []; + const rest: Column[] = []; + + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + const pinIndex = names ? names.indexOf(column.name) : -1; + if (pinIndex > -1) + { + column.pinned = "left"; + leftPins[pinIndex] = column; + } + else + { + if (column.pinned == "left") + { + column.pinned = undefined; + } + rest.push(column); + } + } + + this.columns = [...leftPins, ...rest]; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public setPinnedRightColumns = (names: string[]): void => + { + const rightPins: Column[] = []; + const rest: Column[] = []; + + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + const pinIndex = names ? names.indexOf(column.name) : -1; + if (pinIndex > -1) + { + column.pinned = "right"; + rightPins[pinIndex] = column; + } + else + { + if (column.pinned == "right") + { + column.pinned = undefined; + } + rest.push(column); + } + } + + this.columns = [...rest, ...rightPins]; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public getColumnSortValues = (): { [name: string]: number } => + { + const sortValues: { [name: string]: number } = {}; + for (let i = 0; i < this.columns.length; i++) + { + sortValues[this.columns[i].name] = i; + } + return sortValues; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public getColumnWidths = (): { [name: string]: number } => + { + const widths: { [name: string]: number } = {}; + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + widths[column.name] = column.width; + } + return widths; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public toGridPinnedColumns = (): GridPinnedColumns => + { + const gridPinnedColumns: GridPinnedColumns = {left: [], right: []}; + + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + if (column.pinned == "left") + { + gridPinnedColumns.left.push(column.name); + } + else if (column.pinned == "right") + { + gridPinnedColumns.right.push(column.name); + } + } + + return gridPinnedColumns; + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public toColumnVisibilityModel = (): { [index: string]: boolean } => + { + const columnVisibilityModel: { [index: string]: boolean } = {}; + + for (let i = 0; i < this.columns.length; i++) + { + const column = this.columns[i]; + columnVisibilityModel[column.name] = column.isVisible; + } + + return columnVisibilityModel; + }; + +} + + +/******************************************************************************* + ** subclass of QQueryColumns - used as a marker, to indicate that the table + ** isn't yet loaded, so it just a placeholder. + *******************************************************************************/ +export class PreLoadQueryColumns extends QQueryColumns +{ +} + diff --git a/src/qqq/models/query/RecordQueryView.ts b/src/qqq/models/query/RecordQueryView.ts new file mode 100644 index 0000000..a705603 --- /dev/null +++ b/src/qqq/models/query/RecordQueryView.ts @@ -0,0 +1,100 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import QQueryColumns, {PreLoadQueryColumns} from "qqq/models/query/QQueryColumns"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; + + +/******************************************************************************* + ** Model to represent the full "view" that is active on the RecordQuery screen + ** (and accordingly, can be saved as a saved view). + *******************************************************************************/ +export default class RecordQueryView +{ + queryFilter: QQueryFilter; // contains orderBys + queryColumns: QQueryColumns; // contains on/off, sequence, widths, and pins + viewIdentity: string; // url vs. saved vs. ad-hoc, plus "noncey" stuff? not very used... + rowsPerPage: number; + quickFilterFieldNames: string[]; + mode: string; + // variant? + + /******************************************************************************* + ** + *******************************************************************************/ + constructor() + { + } + + + /******************************************************************************* + ** factory function - build a RecordQueryView object from JSON (string or parsed object). + ** + ** input json is must look like if you JSON.stringify this class - that is: + ** {queryFilter: {}, queryColumns: {}, etc...} + *******************************************************************************/ + public static buildFromJSON = (json: string | any): RecordQueryView => + { + const view = new RecordQueryView(); + + if (typeof json == "string") + { + json = JSON.parse(json); + } + + view.queryFilter = json.queryFilter as QQueryFilter; + + ////////////////////////////////////////////////////////////////////////////////////////// + // it's important that some criteria values exist as expression objects - so - do that. // + ////////////////////////////////////////////////////////////////////////////////////////// + for (let i = 0; i < view.queryFilter?.criteria?.length; i++) + { + const criteria = view.queryFilter.criteria[i] + for (let j = 0; j < criteria?.values?.length; j++) + { + const value = criteria.values[j]; + const expression = FilterUtils.gridCriteriaValueToExpression(value); + if(expression) + { + criteria.values[j] = expression; + } + } + } + + if(json.queryColumns) + { + view.queryColumns = QQueryColumns.buildFromJSON(json.queryColumns); + } + else + { + view.queryColumns = new PreLoadQueryColumns(); + } + + view.viewIdentity = json.viewIdentity; + view.rowsPerPage = json.rowsPerPage; + view.quickFilterFieldNames = json.quickFilterFieldNames; + view.mode = json.mode; + + return (view); + }; + +} \ No newline at end of file diff --git a/src/qqq/pages/apps/Home.tsx b/src/qqq/pages/apps/Home.tsx index c3e0775..bb53faf 100644 --- a/src/qqq/pages/apps/Home.tsx +++ b/src/qqq/pages/apps/Home.tsx @@ -34,6 +34,7 @@ import Grid from "@mui/material/Grid"; import React, {useContext, useEffect, useState} from "react"; import {Link, useLocation} from "react-router-dom"; import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; import MDTypography from "qqq/components/legacy/MDTypography"; import ProcessLinkCard from "qqq/components/processes/ProcessLinkCard"; import DashboardWidgets from "qqq/components/widgets/DashboardWidgets"; @@ -180,9 +181,6 @@ function AppHome({app}: Props): JSX.Element } }, [qInstance, location]); - const widgetCount = widgets ? widgets.length : 0; - - // eslint-disable-next-line no-nested-ternary const tileSizeLg = 3; const hasTablePermission = (tableName: string) => @@ -200,10 +198,63 @@ function AppHome({app}: Props): JSX.Element return reports.find(r => r.name === reportName && r.hasPermission); }; + const widgetCount = widgets ? widgets.length : 0; + const sectionCount = app.sections ? app.sections.length : 0; + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // if our app has no widgets or sections, but it does have child apps, then return those child apps // + ////////////////////////////////////////////////////////////////////////////////////////////////////// + if(widgetCount == 0 && sectionCount == 0 && childApps && childApps.length > 0) + { + return ( + + + + + + Apps + + + {childApps.map((childApp) => ( + + + + + + + {childApp.iconName || app.iconName} + + + + + {childApp.label} + + + + + + + ))} + + + + + + ) + } + return ( - {app.widgets && ( + {app.widgets && app.widgets.length > 0 && ( diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 3f7cbba..9d4f79a 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -29,60 +29,59 @@ import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTa 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 {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 {Alert, Collapse, TablePagination, Typography} from "@mui/material"; -import Autocomplete from "@mui/material/Autocomplete"; +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"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogContentText from "@mui/material/DialogContentText"; -import DialogTitle from "@mui/material/DialogTitle"; import Divider from "@mui/material/Divider"; import Icon from "@mui/material/Icon"; import IconButton from "@mui/material/IconButton"; import LinearProgress from "@mui/material/LinearProgress"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Modal from "@mui/material/Modal"; -import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector, useGridApiRef, GridPreferencePanelsValue, GridColumnResizeParams} from "@mui/x-data-grid-pro"; +import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnHeaderParams, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} from "@mui/x-data-grid-pro"; import {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 {QActionsMenuButton, QCancelButton, QCreateNewButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +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 SavedFilters from "qqq/components/misc/SavedFilters"; -import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; +import SavedViews from "qqq/components/misc/SavedViews"; +import BasicAndAdvancedQueryControls from "qqq/components/query/BasicAndAdvancedQueryControls"; 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"; +import TableVariantDialog from "qqq/components/query/TableVariantDialog"; import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import BaseLayout from "qqq/layouts/BaseLayout"; +import {LoadingState} from "qqq/models/LoadingState"; +import QQueryColumns, {PreLoadQueryColumns} from "qqq/models/query/QQueryColumns"; +import RecordQueryView from "qqq/models/query/RecordQueryView"; import ProcessRun from "qqq/pages/processes/ProcessRun"; import ColumnStats from "qqq/pages/records/query/ColumnStats"; 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"; -const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId"; -const COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT = "qqq.columnVisibility"; -const COLUMN_SORT_LOCAL_STORAGE_KEY_ROOT = "qqq.columnSort"; -const FILTER_LOCAL_STORAGE_KEY_ROOT = "qqq.filter"; -const ROWS_PER_PAGE_LOCAL_STORAGE_KEY_ROOT = "qqq.rowsPerPage"; -const PINNED_COLUMNS_LOCAL_STORAGE_KEY_ROOT = "qqq.pinnedColumns"; -const COLUMN_ORDERING_LOCAL_STORAGE_KEY_ROOT = "qqq.columnOrdering"; -const COLUMN_WIDTHS_LOCAL_STORAGE_KEY_ROOT = "qqq.columnWidths"; -const SEEN_JOIN_TABLES_LOCAL_STORAGE_KEY_ROOT = "qqq.seenJoinTables"; +const CURRENT_SAVED_VIEW_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedViewId"; const DENSITY_LOCAL_STORAGE_KEY_ROOT = "qqq.density"; +const VIEW_LOCAL_STORAGE_KEY_ROOT = "qqq.recordQueryView"; export const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant"; @@ -97,8 +96,30 @@ RecordQuery.defaultProps = { launchProcess: null }; +/////////////////////////////////////////////////////// +// define possible values for our pageState variable // +/////////////////////////////////////////////////////// +type PageState = "initial" | "loadingMetaData" | "loadedMetaData" | "loadingView" | "loadedView" | "preparingGrid" | "ready"; + const qController = Client.getInstance(); +/******************************************************************************* + ** function to produce standard version of the screen while we're "loading" + ** like the main table meta data etc. + *******************************************************************************/ +const getLoadingScreen = () => +{ + return ( +   + ); +} + + +/******************************************************************************* + ** QQQ Record Query Screen component. + ** + ** Yuge component. The best. Lots of very smart people are saying so. + *******************************************************************************/ function RecordQuery({table, launchProcess}: Props): JSX.Element { const tableName = table.name; @@ -108,9 +129,16 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [warningAlert, setWarningAlert] = useState(null as string); const [successAlert, setSuccessAlert] = useState(null as string); - const location = useLocation(); const navigate = useNavigate(); + const location = useLocation(); + const pathParts = location.pathname.replace(/\/+$/, "").split("/"); + const [firstRender, setFirstRender] = useState(true); + const [isFirstRenderAfterChangingTables, setIsFirstRenderAfterChangingTables] = useState(false); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // manage "state" being passed from some screens (like delete) into query screen - by grabbing, and then deleting // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if(location.state) { let state: any = location.state; @@ -129,135 +157,175 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element window.history.replaceState(state, ""); } - const pathParts = location.pathname.replace(/\/+$/, "").split("/"); - //////////////////////////////////////////// // look for defaults in the local storage // //////////////////////////////////////////// - const currentSavedFilterLocalStorageKey = `${CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const sortLocalStorageKey = `${COLUMN_SORT_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const rowsPerPageLocalStorageKey = `${ROWS_PER_PAGE_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const pinnedColumnsLocalStorageKey = `${PINNED_COLUMNS_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const columnOrderingLocalStorageKey = `${COLUMN_ORDERING_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const columnWidthsLocalStorageKey = `${COLUMN_WIDTHS_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const seenJoinTablesLocalStorageKey = `${SEEN_JOIN_TABLES_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const columnVisibilityLocalStorageKey = `${COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const filterLocalStorageKey = `${FILTER_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; + const currentSavedViewLocalStorageKey = `${CURRENT_SAVED_VIEW_ID_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; + const viewLocalStorageKey = `${VIEW_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; + + ///////////////////////////////////////////////////////////////////////////////////////////////// + // define some default values (e.g., to be used if nothing in local storage or no active view) // + ///////////////////////////////////////////////////////////////////////////////////////////////// let defaultSort = [] as GridSortItem[]; - let defaultVisibility = {} as { [index: string]: boolean }; - let didDefaultVisibilityComeFromLocalStorage = false; let defaultRowsPerPage = 10; let defaultDensity = "standard" as GridDensity; - let defaultPinnedColumns = {left: ["__check__", "id"]} as GridPinnedColumns; - let defaultColumnOrdering = null as string[]; - let defaultColumnWidths = {} as {[fieldName: string]: number}; - let seenJoinTables: {[tableName: string]: boolean} = {}; let defaultTableVariant: QTableVariant = null; + let defaultMode = "basic"; + let defaultQueryColumns: QQueryColumns = new PreLoadQueryColumns(); + let defaultView: RecordQueryView = null; - //////////////////////////////////////////////////////////////////////////////////// - // set the to be not per table (do as above if we want per table) at a later port // - //////////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////// + // set density not to be per-table // + ///////////////////////////////////// const densityLocalStorageKey = `${DENSITY_LOCAL_STORAGE_KEY_ROOT}`; - if (localStorage.getItem(sortLocalStorageKey)) + // only load things out of local storage on the first render + if(firstRender) { - defaultSort = JSON.parse(localStorage.getItem(sortLocalStorageKey)); - } - if (localStorage.getItem(columnVisibilityLocalStorageKey)) - { - defaultVisibility = JSON.parse(localStorage.getItem(columnVisibilityLocalStorageKey)); - didDefaultVisibilityComeFromLocalStorage = true; - } - if (localStorage.getItem(pinnedColumnsLocalStorageKey)) - { - defaultPinnedColumns = JSON.parse(localStorage.getItem(pinnedColumnsLocalStorageKey)); - } - if (localStorage.getItem(columnOrderingLocalStorageKey)) - { - defaultColumnOrdering = JSON.parse(localStorage.getItem(columnOrderingLocalStorageKey)); - } - if (localStorage.getItem(columnWidthsLocalStorageKey)) - { - defaultColumnWidths = JSON.parse(localStorage.getItem(columnWidthsLocalStorageKey)); - } - if (localStorage.getItem(rowsPerPageLocalStorageKey)) - { - defaultRowsPerPage = JSON.parse(localStorage.getItem(rowsPerPageLocalStorageKey)); - } - if (localStorage.getItem(densityLocalStorageKey)) - { - defaultDensity = JSON.parse(localStorage.getItem(densityLocalStorageKey)); - } - if (localStorage.getItem(seenJoinTablesLocalStorageKey)) - { - seenJoinTables = JSON.parse(localStorage.getItem(seenJoinTablesLocalStorageKey)); - } - if (localStorage.getItem(tableVariantLocalStorageKey)) - { - defaultTableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey)); + console.log("This is firstRender, so reading defaults from local storage..."); + if (localStorage.getItem(densityLocalStorageKey)) + { + defaultDensity = JSON.parse(localStorage.getItem(densityLocalStorageKey)); + } + if (localStorage.getItem(tableVariantLocalStorageKey)) + { + defaultTableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey)); + } + if (localStorage.getItem(viewLocalStorageKey)) + { + defaultView = RecordQueryView.buildFromJSON(localStorage.getItem(viewLocalStorageKey)); + } + + setFirstRender(false); } - const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel); - const [lastFetchedQFilterJSON, setLastFetchedQFilterJSON] = useState(""); + if(defaultView == null) + { + defaultView = new RecordQueryView(); + defaultView.queryFilter = new QQueryFilter(); + defaultView.queryColumns = defaultQueryColumns; + defaultView.viewIdentity = "empty"; + defaultView.rowsPerPage = defaultRowsPerPage; + // ... defaultView.quickFilterFieldNames = []; + defaultView.mode = defaultMode; + } + + ///////////////////////////////////////////////////////////////////////////////////////// + // in case the view is missing any of these attributes, give them a reasonable default // + ///////////////////////////////////////////////////////////////////////////////////////// + if(!defaultView.rowsPerPage) + { + defaultView.rowsPerPage = defaultRowsPerPage; + } + if(!defaultView.mode) + { + defaultView.mode = defaultMode; + } + if(!defaultView.quickFilterFieldNames) + { + defaultView.quickFilterFieldNames = []; + } + + /////////////////////////////////// + // state models for the DataGrid // + /////////////////////////////////// const [columnSortModel, setColumnSortModel] = useState(defaultSort); - const [queryFilter, setQueryFilter] = useState(new QQueryFilter()); - const [tableVariant, setTableVariant] = useState(defaultTableVariant); - - const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultVisibility); - const [shouldSetAllNewJoinFieldsToHidden, setShouldSetAllNewJoinFieldsToHidden] = useState(!didDefaultVisibilityComeFromLocalStorage) - const [visibleJoinTables, setVisibleJoinTables] = useState(new Set()); - const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage); + const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultQueryColumns.toColumnVisibilityModel()); + const [columnsModel, setColumnsModel] = useState([] as GridColDef[]); const [density, setDensity] = useState(defaultDensity); - const [pinnedColumns, setPinnedColumns] = useState(defaultPinnedColumns); + const [loading, setLoading] = useState(true); + const [pageNumber, setPageNumber] = useState(0); + const [pinnedColumns, setPinnedColumns] = useState(defaultQueryColumns.toGridPinnedColumns()); + const [rowSelectionModel, setRowSelectionModel] = useState([]); + const [rows, setRows] = useState([] as GridRowsProp[]); + const [rowsPerPage, setRowsPerPage] = useState(defaultView.rowsPerPage); + const [totalRecords, setTotalRecords] = useState(null); + const gridApiRef = useGridApiRef(); - const initialColumnChooserOpenGroups = {} as { [name: string]: boolean }; - initialColumnChooserOpenGroups[tableName] = true; - const [columnChooserOpenGroups, setColumnChooserOpenGroups] = useState(initialColumnChooserOpenGroups); - const [columnChooserFilterText, setColumnChooserFilterText] = useState(""); + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // state of the page - e.g., have we loaded meta data? what about the initial view? or are we ready to render records. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const [pageState, setPageState] = useState("initial" as PageState) - const [tableState, setTableState] = useState(""); + ///////////////////////////////// + // meta-data and derived state // + ///////////////////////////////// const [metaData, setMetaData] = useState(null as QInstance); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); - const [defaultFilterLoaded, setDefaultFilterLoaded] = useState(false); - const [actionsMenu, setActionsMenu] = useState(null); + const [tableLabel, setTableLabel] = useState(""); const [tableProcesses, setTableProcesses] = useState([] as QProcessMetaData[]); const [allTableProcesses, setAllTableProcesses] = useState([] as QProcessMetaData[]); - const [pageNumber, setPageNumber] = useState(0); - const [totalRecords, setTotalRecords] = useState(null); + + /////////////////////////////////////////// + // state of the view of the query screen // + /////////////////////////////////////////// + const [view, setView] = useState(defaultView) + const [viewAsJson, setViewAsJson] = useState(JSON.stringify(defaultView)) + const [queryFilter, setQueryFilter] = useState(defaultView.queryFilter); + const [queryColumns, setQueryColumns] = useState(defaultView.queryColumns); + const [lastFetchedQFilterJSON, setLastFetchedQFilterJSON] = useState(""); + const [lastFetchedVariant, setLastFetchedVariant] = useState(null); + const [tableVariant, setTableVariant] = useState(defaultTableVariant); + const [quickFilterFieldNames, setQuickFilterFieldNames] = useState(defaultView.quickFilterFieldNames); + + ////////////////////////////////////////////// + // misc state... needs grouped & documented // + ////////////////////////////////////////////// + const [visibleJoinTables, setVisibleJoinTables] = useState(new Set()); const [distinctRecords, setDistinctRecords] = useState(null); + const [tableVariantPromptOpen, setTableVariantPromptOpen] = useState(false); + const [alertContent, setAlertContent] = useState(""); + 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 // + ///////////////////////////////////////////////////// + const [gridMouseDownX, setGridMouseDownX] = useState(0); + const [gridMouseDownY, setGridMouseDownY] = useState(0); + const [gridPreferencesWindow, setGridPreferencesWindow] = useState(undefined); + + ///////////////////////////////////////////////////////////// + // state related to selecting records for using in actions // + ///////////////////////////////////////////////////////////// const [selectedIds, setSelectedIds] = useState([] as string[]); const [distinctRecordsOnPageCount, setDistinctRecordsOnPageCount] = useState(null as number); const [selectionSubsetSize, setSelectionSubsetSize] = useState(null as number); const [selectionSubsetSizePromptOpen, setSelectionSubsetSizePromptOpen] = useState(false); - const [tableVariantPromptOpen, setTableVariantPromptOpen] = useState(false); const [selectFullFilterState, setSelectFullFilterState] = useState("n/a" as "n/a" | "checked" | "filter" | "filterSubset"); - const [rowSelectionModel, setRowSelectionModel] = useState([]); - const [columnsModel, setColumnsModel] = useState([] as GridColDef[]); - const [rows, setRows] = useState([] as GridRowsProp[]); - const [loading, setLoading] = useState(true); - const [alertContent, setAlertContent] = useState(""); - const [tableLabel, setTableLabel] = useState(""); - const [gridMouseDownX, setGridMouseDownX] = useState(0); - const [gridMouseDownY, setGridMouseDownY] = useState(0); - const [gridPreferencesWindow, setGridPreferencesWindow] = useState(undefined); - const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false); - const [hasValidFilters, setHasValidFilters] = useState(false); - const [currentSavedFilter, setCurrentSavedFilter] = useState(null as QRecord); + ////////////////////////////// + // state used for processes // + ////////////////////////////// const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData); - const [launchingProcess, setLaunchingProcess] = useState(launchProcess); const [recordIdsForProcess, setRecordIdsForProcess] = useState([] as string[] | QQueryFilter); + + ///////////////////////////////////////// + // state used for column-stats feature // + ///////////////////////////////////////// const [columnStatsFieldName, setColumnStatsFieldName] = useState(null as string); const [columnStatsField, setColumnStatsField] = useState(null as QFieldMetaData); const [columnStatsFieldTableName, setColumnStatsFieldTableName] = useState(null as string) const [filterForColumnStats, setFilterForColumnStats] = useState(null as QQueryFilter); - const instance = useRef({timer: null}); + /////////////////////////////////////////////////// + // state used for basic/advanced query component // + /////////////////////////////////////////////////// + const [mode, setMode] = useState(defaultView.mode); + const basicAndAdvancedQueryControlsRef = useRef(); - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // use all these states to avoid showing results from an "old" query, that finishes loading after a newer one // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////// + // a timer used to help avoid accidental double-clicks // + ///////////////////////////////////////////////////////// + const timerInstance = useRef({timer: null}); + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // state used to avoid showing results from an "old" query, that finishes loading after a newer one // + ////////////////////////////////////////////////////////////////////////////////////////////////////// const [latestQueryId, setLatestQueryId] = useState(0); const [countResults, setCountResults] = useState({} as any); const [receivedCountTimestamp, setReceivedCountTimestamp] = useState(new Date()); @@ -267,228 +335,69 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [queryErrors, setQueryErrors] = useState({} as any); const [receivedQueryErrorTimestamp, setReceivedQueryErrorTimestamp] = useState(new Date()); - const {setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext); + ///////////////////////////// + // page context references // + ///////////////////////////// + const {accentColor, accentColorLight, setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext); + + ////////////////////////////////////////////////////////////////// + // we use our own header - so clear out the context page header // + ////////////////////////////////////////////////////////////////// + setPageHeader(null); + + ////////////////////// + // ole' faithful... // + ////////////////////// const [, forceUpdate] = useReducer((x) => x + 1, 0); - const openActionsMenu = (event: any) => setActionsMenu(event.currentTarget); - const closeActionsMenu = () => setActionsMenu(null); + /////////////////////////////////////////////////////////////////////////////////////////// + // add a LoadingState object, in case the initial loads (of meta data and view) are slow // + /////////////////////////////////////////////////////////////////////////////////////////// + const [pageLoadingState, _] = useState(new LoadingState(forceUpdate)) - const gridApiRef = useGridApiRef(); - - /////////////////////// - // Keyboard handling // - /////////////////////// - useEffect(() => + if(isFirstRenderAfterChangingTables) { - if(tableMetaData == null) - { - (async() => - { - const tableMetaData = await qController.loadTableMetaData(tableName); - setTableMetaData(tableMetaData); - })(); - } + setIsFirstRenderAfterChangingTables(false); - const down = (e: KeyboardEvent) => - { - const type = (e.target as any).type; - const validType = (type !== "text" && type !== "textarea" && type !== "input" && type !== "search"); - - if(validType && !dotMenuOpen && !keyboardHelpOpen && !activeModalProcess) - { - if (! e.metaKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) - { - e.preventDefault() - navigate(`${metaData?.getTablePathByName(tableName)}/create`); - } - else if (! e.metaKey && e.key === "r") - { - e.preventDefault() - updateTable(); - } - else if (! e.metaKey && e.key === "c") - { - e.preventDefault() - gridApiRef.current.showPreferences(GridPreferencePanelsValue.columns) - } - else if (! e.metaKey && e.key === "f") - { - e.preventDefault() - gridApiRef.current.showFilterPanel() - } - } - } - - document.addEventListener("keydown", down) - return () => - { - document.removeEventListener("keydown", down) - } - }, [dotMenuOpen, keyboardHelpOpen, metaData, activeModalProcess]) - - ///////////////////////////////////////////////////////////////////////////////////////// - // monitor location changes - if our url looks like a process, then open that process. // - ///////////////////////////////////////////////////////////////////////////////////////// - useEffect(() => - { - try - { - ///////////////////////////////////////////////////////////////// - // the path for a process looks like: .../table/process // - // so if our tableName is in the -2 index, try to open process // - ///////////////////////////////////////////////////////////////// - if (pathParts[pathParts.length - 2] === tableName) - { - const processName = pathParts[pathParts.length - 1]; - const processList = allTableProcesses.filter(p => p.name == processName); - if (processList.length > 0) - { - setActiveModalProcess(processList[0]); - return; - } - else if (metaData?.processes.has(processName)) - { - /////////////////////////////////////////////////////////////////////////////////////// - // check for generic processes - should this be a specific attribute on the process? // - /////////////////////////////////////////////////////////////////////////////////////// - setActiveModalProcess(metaData?.processes.get(processName)); - return; - } - else - { - console.log(`Couldn't find process named ${processName}`); - } - } - - ///////////////////////////////////////////////////////////////////// - // the path for a savedFilter looks like: .../table/savedFilter/32 // - // so if path has '/savedFilter/' get last parsed string // - ///////////////////////////////////////////////////////////////////// - let currentSavedFilterId = null as number; - if (location.pathname.indexOf("/savedFilter/") != -1) - { - const parts = location.pathname.split("/"); - currentSavedFilterId = Number.parseInt(parts[parts.length - 1]); - } - else if (!searchParams.has("filter")) - { - if (localStorage.getItem(currentSavedFilterLocalStorageKey)) - { - currentSavedFilterId = Number.parseInt(localStorage.getItem(currentSavedFilterLocalStorageKey)); - navigate(`${metaData.getTablePathByName(tableName)}/savedFilter/${currentSavedFilterId}`); - } - else - { - setCurrentSavedFilter(null); - } - } - - if (currentSavedFilterId != null) - { - (async () => - { - const formData = new FormData(); - formData.append("id", currentSavedFilterId); - formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000); - const processResult = await qController.processInit("querySavedFilter", formData, qController.defaultMultipartFormDataHeaders()); - if (processResult instanceof QJobError) - { - const jobError = processResult as QJobError; - console.error("Could not retrieve saved filter: " + jobError.userFacingError); - } - else - { - const result = processResult as QJobComplete; - const qRecord = new QRecord(result.values.savedFilterList[0]); - setCurrentSavedFilter(qRecord); - } - })(); - } - } - catch (e) - { - console.log(e); - } - - //////////////////////////////////////////////////////////////////////////////////// - // if we didn't open a process... not sure what we do in the table/query use-case // - //////////////////////////////////////////////////////////////////////////////////// - setActiveModalProcess(null); - - }, [location, tableMetaData]); - - function promptForTableVariantSelection() - { - setTableVariantPromptOpen(true); + console.log("This is the first render after changing tables - so - setting state based on 'defaults' from localStorage"); + setView(defaultView) } - const updateColumnVisibilityModel = () => - { - if (localStorage.getItem(columnVisibilityLocalStorageKey)) - { - const visibility = JSON.parse(localStorage.getItem(columnVisibilityLocalStorageKey)); - setColumnVisibilityModel(visibility); - didDefaultVisibilityComeFromLocalStorage = true; - } - } - - /////////////////////////////////////////////////////////////////////// - // any time these are out of sync, it means we need to reload things // - /////////////////////////////////////////////////////////////////////// - if (tableMetaData && tableMetaData.name !== tableName) - { - setTableMetaData(null); - setColumnSortModel([]); - updateColumnVisibilityModel(); - setColumnsModel([]); - setFilterModel({items: []}); - setQueryFilter(new QQueryFilter()); - setDefaultFilterLoaded(false); - setRows([]); - } - - ////////////////////////////////////////////////////////////////////////////////////////////////////// - // note - important to take tableMetaData as a param, even though it's a state var, as the // - // first time we call in here, we may not yet have set it in state (but will have fetched it async) // - // so we'll pass in the local version of it! // - ////////////////////////////////////////////////////////////////////////////////////////////////////// - const buildQFilter = (tableMetaData: QTableMetaData, filterModel: GridFilterModel, limit?: number) => - { - let filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, limit); - filter = FilterUtils.convertFilterPossibleValuesToIds(filter); - setHasValidFilters(filter.criteria && filter.criteria.length > 0); - return (filter); - }; - + /******************************************************************************* + ** utility function to get the names of any join tables which are active, + ** either as a visible column, or as a query criteria + *******************************************************************************/ const getVisibleJoinTables = (): Set => { const visibleJoinTables = new Set(); - columnsModel.forEach((gridColumn) => - { - const fieldName = gridColumn.field; - if (columnVisibilityModel[fieldName] !== false) - { - if (fieldName.indexOf(".") > -1) - { - visibleJoinTables.add(fieldName.split(".")[0]); - } - } - }); - filterModel.items.forEach((item) => + for (let i = 0; i < queryColumns?.columns.length; i++) { - // todo - some test if there is a value? see FilterUtils.buildQFilterFromGridFilter (re-use if needed) - - const fieldName = item.columnField; - if(fieldName.indexOf(".") > -1) + const column = queryColumns.columns[i]; + const fieldName = column.name; + if (column.isVisible && fieldName.indexOf(".") > -1) { visibleJoinTables.add(fieldName.split(".")[0]); } - }); + } + + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const criteria = queryFilter.criteria[i]; + const {criteriaIsValid} = validateCriteria(criteria, null); + const fieldName = criteria.fieldName; + if(criteriaIsValid && fieldName && fieldName.indexOf(".") > -1) + { + visibleJoinTables.add(fieldName.split(".")[0]); + } + } return (visibleJoinTables); }; + /******************************************************************************* + ** + *******************************************************************************/ const isJoinMany = (tableMetaData: QTableMetaData, visibleJoinTables: Set): boolean => { if (tableMetaData?.exposedJoins) @@ -508,10 +417,109 @@ 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 = (<> + save_alt + + + + + + ); + } + catch(e) + { + console.log("Error preparing export menu for page header: " + e); + } + + /******************************************************************************* + ** + *******************************************************************************/ const getPageHeader = (tableMetaData: QTableMetaData, visibleJoinTables: Set, tableVariant: QTableVariant): string | JSX.Element => { let label: string = tableMetaData?.label ?? ""; + if(currentSavedView?.values?.get("label")) + { + label += " / " + currentSavedView?.values?.get("label"); + } + if (visibleJoinTables.size > 0) { let joinLabels = []; @@ -548,203 +556,304 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return(
    - {label} + {label} {exportMenu} - emergency + emergency - {tableVariant && getTableVariantHeader()} + {tableVariant && getTableVariantHeader(tableVariant)}
    ); } else { return (
    - {label} - {tableVariant && getTableVariantHeader()} + {label} {exportMenu} + {tableVariant && getTableVariantHeader(tableVariant)}
    ); } }; - const getTableVariantHeader = () => + /******************************************************************************* + ** + *******************************************************************************/ + const getTableVariantHeader = (tableVariant: QTableVariant) => { return ( {tableMetaData?.variantTableLabel}: {tableVariant?.name} - settings + settings ); } - const updateTable = () => + /////////////////////// + // Keyboard handling // + /////////////////////// + useEffect(() => { + const down = (e: KeyboardEvent) => + { + const type = (e.target as any).type; + const validType = (type !== "text" && type !== "textarea" && type !== "input" && type !== "search"); + + if(validType && !dotMenuOpen && !keyboardHelpOpen && !activeModalProcess) + { + if (! e.metaKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) + { + e.preventDefault() + navigate(`${metaData?.getTablePathByName(tableName)}/create`); + } + else if (! e.metaKey && e.key === "r") + { + e.preventDefault() + updateTable("'r' keyboard event"); + } + else if (! e.metaKey && e.key === "c") + { + e.preventDefault() + gridApiRef.current.showPreferences(GridPreferencePanelsValue.columns) + } + else if (! e.metaKey && e.key === "f") + { + e.preventDefault() + + // @ts-ignore + if(basicAndAdvancedQueryControlsRef?.current?.getCurrentMode() == "advanced") + { + gridApiRef.current.showFilterPanel() + } + } + } + } + + document.addEventListener("keydown", down) + return () => + { + document.removeEventListener("keydown", down) + } + }, [dotMenuOpen, keyboardHelpOpen, metaData, activeModalProcess]) + + + /******************************************************************************* + ** + *******************************************************************************/ + const urlLooksLikeProcess = (): boolean => + { + return (pathParts[pathParts.length - 2] === tableName); + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + // monitor location changes - if our url looks like a savedView, then load that view, kinda // + ////////////////////////////////////////////////////////////////////////////////////////////// + useEffect(() => + { + try + { + ///////////////////////////////////////////////////////////////// + // the path for a savedView looks like: .../table/savedView/32 // + // so if path has '/savedView/' get last parsed string // + ///////////////////////////////////////////////////////////////// + let currentSavedViewId = null as number; + if (location.pathname.indexOf("/savedView/") != -1) + { + const parts = location.pathname.split("/"); + currentSavedViewId = Number.parseInt(parts[parts.length - 1]); + setViewIdInLocation(currentSavedViewId); + + ///////////////////////////////////////////////////////////////////////////////////////////// + // 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") + { + handleSavedViewChange(currentSavedViewId); + } + } + else if (!searchParams.has("filter")) + { + if (localStorage.getItem(currentSavedViewLocalStorageKey)) + { + currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey)); + navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); + } + else + { + doClearCurrentSavedView(); + } + } + } + catch (e) + { + console.log(e); + } + }, [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)); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + const handleColumnVisibilityChange = (columnVisibilityModel: GridColumnVisibilityModel) => + { + setColumnVisibilityModel(columnVisibilityModel); + queryColumns.updateVisibility(columnVisibilityModel) + + view.queryColumns = queryColumns; + doSetView(view) + + 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(); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + const setupGridColumnModels = (metaData: QInstance, tableMetaData: QTableMetaData, queryColumns: QQueryColumns) => + { + let linkBase = metaData.getTablePath(tableMetaData); + linkBase += linkBase.endsWith("/") ? "" : "/"; + const columns = DataGridUtils.setupGridColumns(tableMetaData, linkBase, metaData, "alphabetical"); + + /////////////////////////////////////////////// + // sort columns based on queryColumns object // + /////////////////////////////////////////////// + const columnSortValues = queryColumns.getColumnSortValues(); + columns.sort((a: GridColDef, b: GridColDef) => + { + const aIndex = columnSortValues[a.field]; + const bIndex = columnSortValues[b.field]; + return aIndex - bIndex; + }); + + /////////////////////////////////////////////////////////////////////// + // if there are column widths (e.g., from local storage), apply them // + /////////////////////////////////////////////////////////////////////// + const columnWidths = queryColumns.getColumnWidths(); + for (let i = 0; i < columns.length; i++) + { + const width = columnWidths[columns[i].field]; + if (width) + { + columns[i].width = width; + } + } + + setPinnedColumns(queryColumns.toGridPinnedColumns()); + setColumnVisibilityModel(queryColumns.toColumnVisibilityModel()); + setColumnsModel(columns); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + const promptForTableVariantSelection = () => + { + setTableVariantPromptOpen(true); + } + + + /******************************************************************************* + ** return array of table names that need ... added to query + *******************************************************************************/ + const ensureOrderBysFromJoinTablesAreVisibleTables = (queryFilter: QQueryFilter, visibleJoinTablesParam?: Set): string[] => + { + const rs: string[] = []; + const vjtToUse = visibleJoinTablesParam ?? visibleJoinTables; + + for (let i = 0; i < queryFilter?.orderBys?.length; i++) + { + const fieldName = queryFilter.orderBys[i].fieldName; + if(fieldName.indexOf(".") > -1) + { + const joinTableName = fieldName.replaceAll(/\..*/g, ""); + if(!vjtToUse.has(joinTableName)) + { + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + handleChangeOneColumnVisibility(field, fieldTable, true); + rs.push(fieldTable.name); + } + } + } + + return (rs); + } + + + /******************************************************************************* + ** This is the method that actually executes a query to update the data in the table. + *******************************************************************************/ + const updateTable = (reason?: string) => + { + if(pageState != "ready") + { + console.log(`In updateTable, but pageSate[${pageState}] is not ready, so returning with noop`); + return; + } + + if(tableMetaData?.usesVariants && (!tableVariant || tableVariantPromptOpen)) + { + console.log("In updateTable, but a variant is needed, so returning with noop"); + return; + } + + console.log(`In updateTable for ${reason} ${JSON.stringify(queryFilter)}`); setLoading(true); setRows([]); (async () => { - const tableMetaData = await qController.loadTableMetaData(tableName); - const visibleJoinTables = getVisibleJoinTables(); - setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, tableVariant)); - - //////////////////////////////////////////////////////////////////////////////////////////////////////// - // if there's an exposedJoin that we haven't seen before, we want to make sure that all of its fields // - // don't immediately become visible to the user, so, turn them all off! // - //////////////////////////////////////////////////////////////////////////////////////////////////////// - if (tableMetaData?.exposedJoins) - { - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) - { - const join = tableMetaData.exposedJoins[i]; - const joinTableName = join.joinTable.name; - if(!seenJoinTables[joinTableName] || shouldSetAllNewJoinFieldsToHidden) - { - for (let fieldName of join.joinTable.fields.keys()) - { - columnVisibilityModel[`${join.joinTable.name}.${fieldName}`] = false; - } - } - } - handleColumnVisibilityChange(columnVisibilityModel); - setShouldSetAllNewJoinFieldsToHidden(false); - } - - setColumnVisibilityModel(columnVisibilityModel); - - /////////////////////////////////////////////////////////////////////////////////////////////////// - // store the set of join tables that the user has "seen" (e.g, have been in the table meta data) // - // this is part of the turning-off of new joins seen above // - /////////////////////////////////////////////////////////////////////////////////////////////////// - if(tableMetaData?.exposedJoins) - { - const newSeenJoins: {[tableName: string]: boolean} = {}; - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) - { - const join = tableMetaData.exposedJoins[i]; - newSeenJoins[join.joinTable.name] = true; - } - localStorage.setItem(seenJoinTablesLocalStorageKey, JSON.stringify(newSeenJoins)); - } - - //////////////////////////////////////////////////////////////////////////////////////////////// - // we need the table meta data to look up the default filter (if it comes from query string), // - // because we need to know field types to translate qqq filter to material filter // - // return here ane wait for the next 'turn' to allow doing the actual query // - //////////////////////////////////////////////////////////////////////////////////////////////// - let localFilterModel = filterModel; - if (!defaultFilterLoaded) - { - setDefaultFilterLoaded(true); - - let models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, null, searchParams, filterLocalStorageKey, sortLocalStorageKey); - setFilterModel(models.filter); - setColumnSortModel(models.sort); - setWarningAlert(models.warning); - - setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage)); - return; - } - - setTableMetaData(tableMetaData); - setTableLabel(tableMetaData.label); - - if(tableMetaData?.usesVariants && ! tableVariant) - { - promptForTableVariantSelection(); - return; - } - - if (columnsModel.length == 0) - { - let linkBase = metaData.getTablePath(table); - linkBase += linkBase.endsWith("/") ? "" : "/"; - const columns = DataGridUtils.setupGridColumns(tableMetaData, linkBase, metaData, "alphabetical"); - - /////////////////////////////////////////////////////////////////////// - // if there's a column-ordering (e.g., from local storage), apply it // - /////////////////////////////////////////////////////////////////////// - if(defaultColumnOrdering) - { - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - // note - may need to put this in its own function, e.g., for restoring "Saved Columns" when we add that // - /////////////////////////////////////////////////////////////////////////////////////////////////////////// - columns.sort((a: GridColDef, b: GridColDef) => - { - const aIndex = defaultColumnOrdering.indexOf(a.field); - const bIndex = defaultColumnOrdering.indexOf(b.field); - return aIndex - bIndex; - }); - } - - /////////////////////////////////////////////////////////////////////// - // if there are column widths (e.g., from local storage), apply them // - /////////////////////////////////////////////////////////////////////// - if(defaultColumnWidths) - { - for (let i = 0; i < columns.length; i++) - { - const width = defaultColumnWidths[columns[i].field]; - if(width) - { - columns[i].width = width; - } - } - } - - setColumnsModel(columns); - - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // let the next render (since columnsModel is watched below) build the filter, using the new columnsModel (in case of joins) // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - return; - } - - ////////////////////////////////////////////////////////////////////////////////////////////////// - // make sure that any if any sort columns are from a join table, that the join table is visible // - ////////////////////////////////////////////////////////////////////////////////////////////////// - let resetColumnSortModel = false; - for (let i = 0; i < columnSortModel.length; i++) - { - const gridSortItem = columnSortModel[i]; - if (gridSortItem.field.indexOf(".") > -1) - { - const tableName = gridSortItem.field.split(".")[0]; - if (!visibleJoinTables?.has(tableName)) - { - columnSortModel.splice(i, 1); - setColumnSortModel(columnSortModel); - // todo - need to setQueryFilter? - resetColumnSortModel = true; - i--; - } - } - } - - /////////////////////////////////////////////////////////// - // if there's no column sort, make a default - pkey desc // - /////////////////////////////////////////////////////////// - if (columnSortModel.length === 0) - { - columnSortModel.push({ - field: tableMetaData.primaryKeyField, - sort: "desc", - }); - setColumnSortModel(columnSortModel); - // todo - need to setQueryFilter? - resetColumnSortModel = true; - } - - if (resetColumnSortModel && latestQueryId > 0) - { - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// - // let the next render (since columnSortModel is watched below) build the filter, using the new columnSort // - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// - return; - } - - const qFilter = buildQFilter(tableMetaData, localFilterModel); - qFilter.skip = pageNumber * rowsPerPage; - qFilter.limit = rowsPerPage; + ///////////////////////////////////////////////////////////////////////////////////// + // build filter object to submit to backend count & query endpoints // + // copy the orderBys & operator into it - but we'll build its criteria one-by-one, // + // as clones, as we'll need to tweak them a bit // + ///////////////////////////////////////////////////////////////////////////////////// + const filterForBackend = prepQueryFilterForBackend(queryFilter); ////////////////////////////////////////// // figure out joins to use in the query // @@ -753,6 +862,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); } @@ -767,7 +880,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (tableMetaData.capabilities.has(Capability.TABLE_COUNT)) { let includeDistinct = isJoinMany(tableMetaData, getVisibleJoinTables()); - qController.count(tableName, qFilter, queryJoins, includeDistinct, tableVariant).then(([count, distinctCount]) => + qController.count(tableName, filterForBackend, queryJoins, includeDistinct, tableVariant).then(([count, distinctCount]) => { console.log(`Received count results for query ${thisQueryId}: ${count} ${distinctCount}`); countResults[thisQueryId] = []; @@ -784,8 +897,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return; } - setLastFetchedQFilterJSON(JSON.stringify(qFilter)); - qController.query(tableName, qFilter, queryJoins, tableVariant).then((results) => + setLastFetchedQFilterJSON(JSON.stringify(queryFilter)); + setLastFetchedVariant(tableVariant); + qController.query(tableName, filterForBackend, queryJoins, tableVariant).then((results) => { console.log(`Received results for query ${thisQueryId}`); queryResults[thisQueryId] = results; @@ -820,6 +934,19 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element })(); }; + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if, after a column was turned on or off, the set of visibleJoinTables is changed, then update the table // + // check this on each render - it should only be different if there was a change. note that putting this // + // in handleColumnVisibilityChange "didn't work" - it was always "behind by one" (like, maybe data grid // + // calls that function before it updates the visible model or some-such). // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + const newVisibleJoinTables = getVisibleJoinTables(); + if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()])) + { + updateTable("visible joins change"); + setVisibleJoinTables(newVisibleJoinTables); + } + /////////////////////////// // display count results // /////////////////////////// @@ -909,33 +1036,53 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }, [receivedQueryErrorTimestamp]); - const handlePageChange = (page: number) => + /******************************************************************************* + ** Event handler from grid - when page number changes + *******************************************************************************/ + const handlePageNumberChange = (page: number) => { setPageNumber(page); }; + /******************************************************************************* + ** Event handler from grid - when rows per page changes + *******************************************************************************/ const handleRowsPerPageChange = (size: number) => { setRowsPerPage(size); - localStorage.setItem(rowsPerPageLocalStorageKey, JSON.stringify(size)); + + view.rowsPerPage = size; + doSetView(view) }; + /******************************************************************************* + ** event handler from grid - when user changes pins + *******************************************************************************/ const handlePinnedColumnsChange = (pinnedColumns: GridPinnedColumns) => { setPinnedColumns(pinnedColumns); - localStorage.setItem(pinnedColumnsLocalStorageKey, JSON.stringify(pinnedColumns)); + queryColumns.setPinnedLeftColumns(pinnedColumns.left) + queryColumns.setPinnedRightColumns(pinnedColumns.right) + + view.queryColumns = queryColumns; + doSetView(view) }; + /******************************************************************************* + ** event handler from grid - when "state" changes - which we use just for density + *******************************************************************************/ const handleStateChange = (state: GridState, event: MuiEvent, details: GridCallbackDetails) => { if (state && state.density && state.density.value !== density) { setDensity(state.density.value); localStorage.setItem(densityLocalStorageKey, JSON.stringify(state.density.value)); - } }; + /******************************************************************************* + ** event handler from grid - for when user clicks a row. + *******************************************************************************/ const handleRowClick = (params: GridRowParams, event: MuiEvent, details: GridCallbackDetails) => { ///////////////////////////////////////////////////////////////// @@ -944,7 +1091,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element console.log(gridPreferencesWindow); if (gridPreferencesWindow !== undefined) { - clearTimeout(instance.current.timer); + clearTimeout(timerInstance.current.timer); return; } @@ -954,11 +1101,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element id = encodeURIComponent(params.row[tableMetaData.primaryKeyField]); } const tablePath = `${metaData.getTablePathByName(table.name)}/${id}`; - DataGridUtils.handleRowClick(tablePath, event, gridMouseDownX, gridMouseDownY, navigate, instance); + DataGridUtils.handleRowClick(tablePath, event, gridMouseDownX, gridMouseDownY, navigate, timerInstance); }; - - const selectionChanged = (selectionModel: GridSelectionModel, details: GridCallbackDetails) => + /******************************************************************************* + ** event handler from grid - for when selection (checked rows) changes. + *******************************************************************************/ + const handleSelectionChanged = (selectionModel: GridSelectionModel, details: GridCallbackDetails) => { //////////////////////////////////////////////////// // since we manage this object, we must re-set it // @@ -985,226 +1134,234 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; - const handleColumnVisibilityChange = (columnVisibilityModel: GridColumnVisibilityModel) => - { - setColumnVisibilityModel(columnVisibilityModel); - if (columnVisibilityLocalStorageKey) - { - localStorage.setItem(columnVisibilityLocalStorageKey, JSON.stringify(columnVisibilityModel)); - } - }; - - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// - // if, after a column was turned on or off, the set of visibleJoinTables is changed, then update the table // - // check this on each render - it should only be different if there was a change. note that putting this // - // in handleColumnVisibilityChange "didn't work" - it was always "behind by one" (like, maybe data grid // - // calls that function before it updates the visible model or some-such). // - ///////////////////////////////////////////////////////////////////////////////////////////////////////////// - const newVisibleJoinTables = getVisibleJoinTables(); - if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()])) - { - console.log("calling update table for visible join table change"); - updateTable(); - setVisibleJoinTables(newVisibleJoinTables); - } - - /******************************************************************************* - ** Event handler for column ordering change + ** event handler from grid - for when the order of columns changes *******************************************************************************/ const handleColumnOrderChange = (columnOrderChangeParams: GridColumnOrderChangeParams) => { + ///////////////////////////////////////////////////////////////////////////////////// + // get current state from gridApiRef - as the changeParams only have the delta // + // and we don't want to worry about being out of sync - just reset fully each time // + ///////////////////////////////////////////////////////////////////////////////////// const columnOrdering = gridApiRef.current.state.columns.all; - localStorage.setItem(columnOrderingLocalStorageKey, JSON.stringify(columnOrdering)); + queryColumns.updateColumnOrder(columnOrdering); + + view.queryColumns = queryColumns; + doSetView(view) }; /******************************************************************************* - ** Event handler for column resizing + ** event handler from grid - for when user resizes a column *******************************************************************************/ const handleColumnResize = (params: GridColumnResizeParams, event: MuiEvent, details: GridCallbackDetails) => { - defaultColumnWidths[params.colDef.field] = params.width; - localStorage.setItem(columnWidthsLocalStorageKey, JSON.stringify(defaultColumnWidths)); + queryColumns.updateColumnWidth(params.colDef.field, params.width); + + view.queryColumns = queryColumns; + doSetView(view) }; - const handleFilterChange = (filterModel: GridFilterModel, doSetQueryFilter = true, isChangeFromDataGrid = false) => + + /******************************************************************************* + ** event handler from grid - for when the sort-model changes (e.g., user clicks + ** a column header to re-sort table). + *******************************************************************************/ + const handleSortChange = (gridSort: GridSortModel) => { - setFilterModel(filterModel); + /////////////////////////////////////// + // store the sort model for the grid // + /////////////////////////////////////// + setColumnSortModel(gridSort); - if (doSetQueryFilter) + //////////////////////////////////////////////// + // convert the grid's sort to qqq-filter sort // + //////////////////////////////////////////////// + queryFilter.orderBys = []; + for (let i = 0; i < gridSort?.length; i++) { - ////////////////////////////////////////////////////////////////////////////////// - // someone might have already set the query filter, so, only set it if asked to // - ////////////////////////////////////////////////////////////////////////////////// - setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage)); + const fieldName = gridSort[i].field; + const isAscending = gridSort[i].sort == "asc"; + queryFilter.orderBys.push(new QFilterOrderBy(fieldName, isAscending)) } - if (isChangeFromDataGrid) + ////////////////////////////////////////////////////////// + // set a default order-by, if none is otherwise present // + ////////////////////////////////////////////////////////// + if(queryFilter.orderBys.length == 0) { - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // this function is called by our code several times, but also from dataGridPro when its filter model changes. // - // in general, we don't want a "partial" criteria to be part of our query filter object (e.g., w/ no values) // - // BUT - for one use-case, when the user adds a "filter" (criteria) from column-header "..." menu, then dataGridPro // - // puts a partial item in its filter - so - in that case, we do like to get this partial criteria in our QFilter. // - // so far, not seeing any negatives to this being here, and it fixes that user experience, so keep this. // - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage, true)); + queryFilter.orderBys.push(new QFilterOrderBy(tableMetaData.primaryKeyField, false)); } - if (filterLocalStorageKey) - { - localStorage.setItem(filterLocalStorageKey, JSON.stringify(filterModel)); - } + //////////////////////////////// + // store the new query filter // + //////////////////////////////// + doSetQueryFilter(queryFilter); }; - const handleSortChangeForDataGrid = (gridSort: GridSortModel) => + + /******************************************************************************* + ** + *******************************************************************************/ + const handleColumnHeaderClick = (params: GridColumnHeaderParams, event: MuiEvent, details: GridCallbackDetails): void => { - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - // this method just wraps handleSortChange, but w/o the optional 2nd param, so we can use it in data grid // - //////////////////////////////////////////////////////////////////////////////////////////////////////////// - handleSortChange(gridSort); + event.defaultMuiPrevented = true; } - const handleSortChange = (gridSort: GridSortModel, overrideFilterModel?: GridFilterModel) => + + /******************************************************************************* + ** bigger than doSetView - this method does call doSetView, but then also + ** updates all other related state on the screen from the view. + *******************************************************************************/ + const activateView = (view: RecordQueryView): void => { - if (gridSort && gridSort.length > 0) + ///////////////////////////////////////////////////////////////////////////////////////////// + // pass the 'isFromActivateView' flag into these functions - so that they don't try to set // + // the filter (or columns) back into the old view. // + ///////////////////////////////////////////////////////////////////////////////////////////// + doSetQueryFilter(view.queryFilter, true); + doSetQueryColumns(view.queryColumns, true); + + setRowsPerPage(view.rowsPerPage ?? defaultRowsPerPage); + setMode(view.mode ?? defaultMode); + setQuickFilterFieldNames(view.quickFilterFieldNames ?? []) // todo not i think ?? getDefaultQuickFilterFieldNames(tableMetaData)); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // do this last - in case anything in the view got modified in any of those other doSet methods // + ////////////////////////////////////////////////////////////////////////////////////////////////// + doSetView(view); + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // do this in a timeout - so the current view can get set into state properly, before it potentially // + // gets modified inside these calls (e.g., if a new field gets turned on) // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // @ts-ignore + setTimeout(() => basicAndAdvancedQueryControlsRef?.current?.ensureAllFilterCriteriaAreActiveQuickFilters(view.queryFilter, "activatedView")); + } + + + /******************************************************************************* + ** Wrapper around setQueryFilter that also puts it in the view, and calls doSetView + *******************************************************************************/ + const doSetQueryFilter = (queryFilter: QQueryFilter, isFromActivateView = false): void => + { + 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 // + /////////////////////////////////////////////////// + if(!queryFilter.orderBys || queryFilter.orderBys.length == 0) { - setColumnSortModel(gridSort); - const gridFilterModelToUse = overrideFilterModel ?? filterModel; - setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, gridFilterModelToUse, gridSort, rowsPerPage)); - localStorage.setItem(sortLocalStorageKey, JSON.stringify(gridSort)); + queryFilter.orderBys = [new QFilterOrderBy(tableMetaData?.primaryKeyField, false)]; + 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); + + /////////////////////////////////////////////////////// + // propagate filter's orderBy into grid's sort model // + /////////////////////////////////////////////////////// + const gridSort = FilterUtils.getGridSortFromQueryFilter(queryFilter); + setColumnSortModel(gridSort); + + /////////////////////////////////////////////// + // put this query filter in the current view // + /////////////////////////////////////////////// + if(!isFromActivateView) + { + view.queryFilter = queryFilter; + doSetView(view) + } + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // this force-update causes a re-render that'll see the changed filter hash/json string, and make an updateTable run (if appropriate) // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + forceUpdate(); + } + + /******************************************************************************* + ** Wrapper around setQueryColumns that also sets column models for the grid, puts + ** updated queryColumns in the view, and calls doSetView + *******************************************************************************/ + const doSetQueryColumns = (queryColumns: QQueryColumns, isFromActivateView = false): void => + { + /////////////////////////////////////////////////////////////////////////////////////// + // if we didn't get queryColumns from our view, it should be a PreLoadQueryColumns - // + // so that means we should now replace it with defaults for the table. // + /////////////////////////////////////////////////////////////////////////////////////// + if (queryColumns instanceof PreLoadQueryColumns || queryColumns.columns.length == 0) + { + console.log(`Building new default QQueryColumns for table [${tableMetaData.name}]`); + queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData); + view.queryColumns = queryColumns; + } + + setQueryColumns(queryColumns); + + //////////////////////////////// + // set the DataGridPro models // + //////////////////////////////// + setupGridColumnModels(metaData, tableMetaData, queryColumns); + // const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage); + + /////////////////////////////////////////// + // put these columns in the current view // + /////////////////////////////////////////// + if(!isFromActivateView) + { + view.queryColumns = queryColumns; + doSetView(view) + } + } + + + /******************************************************************************* + ** Event handler from BasicAndAdvancedQueryControls for when quickFilterFields change + ** or other times we need to change them (e.g., activating a view) + *******************************************************************************/ + const doSetQuickFilterFieldNames = (names: string[]): void => + { + setQuickFilterFieldNames([...(names ?? [])]); + + view.quickFilterFieldNames = names; + doSetView(view) }; - if (tableName !== tableState) + + /******************************************************************************* + ** Wrapper around setMode - places it into the view and state. + *******************************************************************************/ + const doSetMode = (newValue: string) => { - (async () => - { - setTableMetaData(null); - setTableState(tableName); - const metaData = await qController.loadMetaData(); - setMetaData(metaData); + setMode(newValue); - setTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName)); // these are the ones to show in the dropdown - setAllTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName, true)); // these include hidden ones (e.g., to find the bulks) - - if (launchingProcess) - { - setLaunchingProcess(null); - setActiveModalProcess(launchingProcess); - } - - // reset rows to trigger rerender - setRows([]); - })(); + view.mode = newValue; + doSetView(view); } - interface QExportMenuItemProps extends GridExportMenuItemProps<{}> - { - format: string; - } - function ExportMenuItem(props: QExportMenuItemProps) - { - const {format, hideMenu} = props; - - return ( - - { - /////////////////////////////////////////////////////////////////////////////// - // build the list of visible fields. note, not doing them in-order (in case // - // the user did drag & drop), because column order model isn't right yet // - // so just doing them to match columns (which were pKey, then sorted) // - /////////////////////////////////////////////////////////////////////////////// - const visibleFields: string[] = []; - columnsModel.forEach((gridColumn) => - { - const fieldName = gridColumn.field; - if (columnVisibilityModel[fieldName] !== false) - { - visibleFields.push(fieldName); - } - }); - - /////////////////////// - // zero-pad function // - /////////////////////// - const zp = (value: number): string => (value < 10 ? `0${value}` : `${value}`); - - ////////////////////////////////////// - // construct the url for the export // - ////////////////////////////////////// - const dateString = ValueUtils.formatDateTimeForFileName(new Date()); - const filename = `${tableMetaData.label} Export ${dateString}.${format}`; - const url = `/data/${tableMetaData.name}/export/${filename}`; - - const encodedFilterJSON = encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel))); - - ////////////////////////////////////////////////////////////////////////////////////// - // open a window (tab) with a little page that says the file is being generated. // - // then have that page load the url for the export. // - // If there's an error, it'll appear in that window. else, the file will download. // - ////////////////////////////////////////////////////////////////////////////////////// - const exportWindow = window.open("", "_blank"); - exportWindow.document.write(` - - - ${filename} - - - - Generating file ${filename}${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}... -
    - - -
    - - `); - - /* - // todo - probably better - generate the report in an iframe... - // only open question is, giving user immediate feedback, and knowing when the stream has started and/or stopped - // maybe a busy-loop that would check iframe's url (e.g., after posting should change, maybe?) - const iframe = document.getElementById("exportIFrame"); - const form = iframe.querySelector("form"); - form.action = url; - form.target = "exportIFrame"; - (iframe.querySelector("#authorizationInput") as HTMLInputElement).value = qController.getAuthorizationHeaderValue(); - form.submit(); - */ - - /////////////////////////////////////////// - // Hide the export menu after the export // - /////////////////////////////////////////// - hideMenu?.(); - }} - > - Export - {` ${format.toUpperCase()}`} -
    - ); - } - - function getNoOfSelectedRecords() + /******************************************************************************* + ** Helper function for launching processes - counts selected records. + *******************************************************************************/ + const getNoOfSelectedRecords = () => { if (selectFullFilterState === "filter") { - if(isJoinMany(tableMetaData, getVisibleJoinTables())) + if (isJoinMany(tableMetaData, getVisibleJoinTables())) { return (distinctRecords); } @@ -1214,16 +1371,21 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return (selectedIds.length); } - function getRecordsQueryString() + + /******************************************************************************* + ** get a query-string to put on the url to indicate what records are going into + ** a process. + *******************************************************************************/ + const getRecordsQueryString = () => { if (selectFullFilterState === "filter") { - return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel)))}`; + return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(queryFilter))}`; } if (selectFullFilterState === "filterSubset") { - return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel, selectionSubsetSize)))}`; + return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(queryFilter))}`; } if (selectedIds.length > 0) @@ -1234,15 +1396,20 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return ""; } + + /******************************************************************************* + ** launch/open a modal process. Ends up navigating to the process's path w/ + ** records selected via query string. + *******************************************************************************/ const openModalProcess = (process: QProcessMetaData = null) => { if (selectFullFilterState === "filter") { - setRecordIdsForProcess(buildQFilter(tableMetaData, filterModel)); + setRecordIdsForProcess(queryFilter); } else if (selectFullFilterState === "filterSubset") { - setRecordIdsForProcess(buildQFilter(tableMetaData, filterModel, selectionSubsetSize)); + setRecordIdsForProcess(new QQueryFilter(queryFilter.criteria, queryFilter.orderBys, queryFilter.booleanOperator, 0, selectionSubsetSize)); } else if (selectedIds.length > 0) { @@ -1254,21 +1421,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } navigate(`${metaData?.getTablePathByName(tableName)}/${process.name}${getRecordsQueryString()}`); - closeActionsMenu(); }; - const closeColumnStats = (event: object, reason: string) => - { - if (reason === "backdropClick" || reason === "escapeKeyDown") - { - return; - } - - setColumnStatsFieldName(null); - setColumnStatsFieldTableName(null); - setColumnStatsField(null); - }; + /******************************************************************************* + ** close callback for modal processes + *******************************************************************************/ const closeModalProcess = (event: object, reason: string) => { if (reason === "backdropClick" || reason === "escapeKeyDown") @@ -1283,10 +1441,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element newPath.pop(); navigate(newPath.join("/")); - console.log("calling update table for close modal"); - updateTable(); + updateTable("close modal process"); }; + + /******************************************************************************* + ** function to open one of the bulk (insert/edit/delete) processes. + *******************************************************************************/ const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete", processLabelPart: "Load" | "Edit" | "Delete") => { const processList = allTableProcesses.filter(p => p.name.endsWith(`.bulk${processNamePart}`)); @@ -1300,15 +1461,20 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; + /******************************************************************************* + ** Event handler for the bulk-load process being selected + *******************************************************************************/ const bulkLoadClicked = () => { - closeActionsMenu(); openBulkProcess("Insert", "Load"); }; + + /******************************************************************************* + ** Event handler for the bulk-edit process being selected + *******************************************************************************/ const bulkEditClicked = () => { - closeActionsMenu(); if (getNoOfSelectedRecords() === 0) { setAlertContent("No records were selected to Bulk Edit."); @@ -1317,9 +1483,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element openBulkProcess("Edit", "Edit"); }; + + /******************************************************************************* + ** Event handler for the bulk-delete process being selected + *******************************************************************************/ const bulkDeleteClicked = () => { - closeActionsMenu(); if (getNoOfSelectedRecords() === 0) { setAlertContent("No records were selected to Bulk Delete."); @@ -1328,6 +1497,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element openBulkProcess("Delete", "Delete"); }; + + /******************************************************************************* + ** Event handler for selecting a process from the menu + *******************************************************************************/ const processClicked = (process: QProcessMetaData) => { // todo - let the process specify that it needs initial rows - err if none selected. @@ -1335,128 +1508,324 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element openModalProcess(process); }; - // @ts-ignore - const defaultLabelDisplayedRows = ({from, to, count}) => - { - const tooltipHTML = <> - The number of rows shown on this screen may be greater than the number of {tableMetaData?.label} records - that match your query, because you have included fields from other tables which may have - more than one record associated with each {tableMetaData?.label}. - - let distinctPart = isJoinMany(tableMetaData, getVisibleJoinTables()) ? ( -  ({safeToLocaleString(distinctRecords)} distinct - info_outlined - - ) - ) : <>; - - if (tableMetaData && !tableMetaData.capabilities.has(Capability.TABLE_COUNT)) - { - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // to avoid a non-countable table showing (this is what data-grid did) 91-100 even if there were only 95 records, // - // we'll do this... not quite good enough, but better than the original // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if (rows.length > 0 && rows.length < to - from) - { - to = from + rows.length; - } - return (`Showing ${from.toLocaleString()} to ${to.toLocaleString()}`); - } - - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // treat -1 as the sentinel that it's set as below -- remember, we did that so that 'to' would have a value in here when there's no count. // - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if (count !== null && count !== undefined && count !== -1) - { - if (count === 0) - { - return (loading ? "Counting..." : "No rows"); - } - - return - Showing {from.toLocaleString()} to {to.toLocaleString()} of - { - count == -1 ? - <>more than {to.toLocaleString()} - : <> {count.toLocaleString()}{distinctPart} - } - ; - } - else - { - return ("Counting..."); - } - }; + ////////////////////////////////////////////// + // custom pagination component for DataGrid // + ////////////////////////////////////////////// function CustomPagination() { - return ( - handlePageChange(value)} - onRowsPerPageChange={(event) => handleRowsPerPageChange(Number(event.target.value))} - labelDisplayedRows={defaultLabelDisplayedRows} - /> - ); + return (); } - function Loading() + ///////////////////////////////////////// + // custom loading overlay for DataGrid // + ///////////////////////////////////////// + function CustomLoadingOverlay() { return ( ); } - async function handleSavedFilterChange(selectedSavedFilterId: number) + /******************************************************************************* + ** wrapper around setting current saved view (as a QRecord) - which also activates + ** that view. + *******************************************************************************/ + const doSetCurrentSavedView = (savedViewRecord: QRecord) => { - if (selectedSavedFilterId != null) + if(savedViewRecord == null) { - const qRecord = await fetchSavedFilter(selectedSavedFilterId); - setCurrentSavedFilter(qRecord); // this fixed initial load not showing filter name + console.log("doSetCurrentView called with a null view record - calling doClearCurrentSavedView instead."); + doClearCurrentSavedView(); + return; + } - const models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, qRecord.values.get("filterJson"), null, null, null); - handleFilterChange(models.filter); - handleSortChange(models.sort, models.filter); - setWarningAlert(models.warning); + setCurrentSavedView(savedViewRecord); - localStorage.setItem(currentSavedFilterLocalStorageKey, selectedSavedFilterId.toString()); + const viewJson = savedViewRecord.values.get("viewJson") + const newView = RecordQueryView.buildFromJSON(viewJson); + + activateView(newView); + + //////////////////////////////////////////////////////////////// + // todo can/should/does this move into the view's "identity"? // + //////////////////////////////////////////////////////////////// + localStorage.setItem(currentSavedViewLocalStorageKey, `${savedViewRecord.values.get("id")}`); + } + + + /******************************************************************************* + ** wrapper around un-setting current saved view and removing its id from local-stroage + *******************************************************************************/ + const doClearCurrentSavedView = () => + { + setCurrentSavedView(null); + localStorage.removeItem(currentSavedViewLocalStorageKey); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + const buildTableDefaultView = (tableMetaData: QTableMetaData): 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) + *******************************************************************************/ + const handleSavedViewChange = async (selectedSavedViewId: number) => + { + if (selectedSavedViewId != null) + { + ////////////////////////////////////////////// + // fetch, then activate the selected filter // + ////////////////////////////////////////////// + setLoading(true); + setLoadingSavedView(true); + const qRecord = await fetchSavedView(selectedSavedViewId); + setLoading(false); + setLoadingSavedView(false); + doSetCurrentSavedView(qRecord); } else { - handleFilterChange({items: []} as GridFilterModel); - handleSortChange([{field: tableMetaData.primaryKeyField, sort: "desc"}], {items: []} as GridFilterModel); - localStorage.removeItem(currentSavedFilterLocalStorageKey); + ///////////////////////////////// + // this is 'new view' - right? // + ///////////////////////////////// + + ////////////////////////////// + // wipe away the saved view // + ////////////////////////////// + setCurrentSavedView(null); + localStorage.removeItem(currentSavedViewLocalStorageKey); + + /////////////////////////////////////////////// + // activate a new default view for the table // + /////////////////////////////////////////////// + activateView(buildTableDefaultView(tableMetaData)) } } - async function fetchSavedFilter(filterId: number): Promise + /******************************************************************************* + ** utility function to fetch a saved view from the backend. + *******************************************************************************/ + const fetchSavedView = async (id: number): Promise => { let qRecord = null; const formData = new FormData(); - formData.append("id", filterId); + formData.append("id", id); formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000); - const processResult = await qController.processInit("querySavedFilter", formData, qController.defaultMultipartFormDataHeaders()); + const processResult = await qController.processInit("querySavedView", formData, qController.defaultMultipartFormDataHeaders()); if (processResult instanceof QJobError) { const jobError = processResult as QJobError; console.error("Could not retrieve saved filter: " + jobError.userFacingError); + setAlertContent("There was an error loading the selected view."); } else { const result = processResult as QJobComplete; - qRecord = new QRecord(result.values.savedFilterList[0]); + qRecord = new QRecord(result.values.savedViewList[0]); + + ////////////////////////////////////////////////////////////////////////////// + // make the view json a good and healthy object for the UI here. // + // such as, making values be what they'd be in the UI (not necessarily // + // what they're like in the backend); similarly, set anything that's unset. // + ////////////////////////////////////////////////////////////////////////////// + const viewJson = qRecord.values.get("viewJson") + const newView = RecordQueryView.buildFromJSON(viewJson); + + setWarningAlert(null); + reconcileCurrentTableMetaDataWithView(newView, "loadingSavedView"); + + newView.viewIdentity = "savedView:" + id; + + /////////////////////////////////////////////////////////////////// + // e.g., translate possible values from ids to objects w/ labels // + /////////////////////////////////////////////////////////////////// + await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, newView.queryFilter); + + /////////////////////////// + // set columns if absent // + /////////////////////////// + if(!newView.queryColumns || !newView.queryColumns.columns || newView.queryColumns.columns?.length == 0) + { + newView.queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData); + } + + qRecord.values.set("viewJson", JSON.stringify(newView)) } return (qRecord); } + + /******************************************************************************* + ** after a page-load, or before activating a saved view, make sure that no + ** fields are missing from its column list, and that no deleted-fields are still + ** being used. + *******************************************************************************/ + const reconcileCurrentTableMetaDataWithView = (view: RecordQueryView, useCase: "initialPageLoad" | "loadingSavedView") => + { + let changedView = false; + const removedFieldNames = new Set(); + + if (view.queryColumns?.columns?.length > 0) + { + const fieldNamesInView: { [name: string]: boolean } = {}; + view.queryColumns?.columns?.forEach(column => fieldNamesInView[column.name] = true); + for (let i = 0; i < tableDefaultView?.queryColumns?.columns.length; i++) + { + const currentColumn = tableDefaultView?.queryColumns?.columns[i]; + if (!fieldNamesInView[currentColumn.name]) + { + console.log(`Adding a new column to this view ${currentColumn.name}`); + view.queryColumns.addColumnForNewField(tableMetaData, currentColumn.name, useCase == "initialPageLoad"); + changedView = true; + } + else + { + delete fieldNamesInView[currentColumn.name]; + } + } + + ////////////////////////////////////////////////////////////// + // delete, from the view, any fields no longer in the table // + ////////////////////////////////////////////////////////////// + for (let fieldName in fieldNamesInView) + { + console.log(`Deleting an old column from this view ${fieldName}`); + view.queryColumns.deleteColumnForOldField(tableMetaData, fieldName); + changedView = true; + removedFieldNames.add(fieldName); + } + } + + ///////////////////////////////////////// + // look for deleted fields as criteria // + ///////////////////////////////////////// + for (let i = 0; i < view?.queryFilter?.criteria?.length; i++) + { + const fieldName = view.queryFilter.criteria[i].fieldName; + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + if (field == null) + { + console.log(`Deleting an old criteria field from this view ${fieldName}`); + view.queryFilter.criteria.splice(i, 1); + changedView = true; + removedFieldNames.add(fieldName); + i--; + } + } + ///////////////////////////////////////// + // look for deleted fields as orderBys // + ///////////////////////////////////////// + for (let i = 0; i < view?.queryFilter?.orderBys?.length; i++) + { + const fieldName = view.queryFilter.orderBys[i].fieldName; + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + if (field == null) + { + console.log(`Deleting an old orderBy field from this view ${fieldName}`); + view.queryFilter.orderBys.splice(i, 1); + changedView = true; + removedFieldNames.add(fieldName); + i--; + } + } + + ////////////////////////////////////////////// + // look for deleted fields as quick-filters // + ////////////////////////////////////////////// + for (let i = 0; i < view?.quickFilterFieldNames?.length; i++) + { + const fieldName = view.quickFilterFieldNames[i]; + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + if (field == null) + { + console.log(`Deleting an old quikc-filter field from this view ${fieldName}`); + view.quickFilterFieldNames.splice(i, 1); + changedView = true; + removedFieldNames.add(fieldName); + i--; + } + } + + if (changedView && useCase == "initialPageLoad") + { + activateView(view); + } + + const removedFieldCount = removedFieldNames.size; + if(removedFieldCount > 0) + { + const plural = removedFieldCount > 1; + setWarningAlert(`${removedFieldCount} field${plural ? "s" : ""} that ${plural ? "were" : "was"} part of this view ${plural ? "are" : "is"} no longer in this table, and ${plural ? "were" : "was"} removed from this view (${[...removedFieldNames.values()].join(", ")}).`); + } + } + + + /******************************************************************************* + ** event handler for selecting 'filter' action from columns menu in advanced mode. + *******************************************************************************/ + const handleColumnMenuAdvancedFilterSelection = (fieldName: string) => + { + const newCriteria = new QFilterCriteria(fieldName, null, []); + + if(!queryFilter.criteria) + { + queryFilter.criteria = []; + } + + const length = queryFilter.criteria.length; + if (length > 0 && !queryFilter.criteria[length - 1].fieldName) + { + ///////////////////////////////////////////////////////////////////////////////// + // if the last criteria in the filter has no field name (e.g., a default state // + // when there's 1 criteria that's all blank - may happen other times too?), // + // then replace that criteria with a new one for this field. // + ///////////////////////////////////////////////////////////////////////////////// + queryFilter.criteria[length - 1] = newCriteria; + } + else + { + ////////////////////////////////////////////////////////////////////// + // else, add a new criteria for this field onto the end of the list // + ////////////////////////////////////////////////////////////////////// + queryFilter.criteria.push(newCriteria); + } + + /////////////////////////// + // open the filter panel // + /////////////////////////// + gridApiRef.current.showPreferences(GridPreferencePanelsValue.filters) + } + + + /******************************************************************************* + ** event handler from columns menu - that copies values from that column + *******************************************************************************/ const copyColumnValues = async (column: GridColDef) => { let data = ""; @@ -1479,18 +1848,23 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { await navigator.clipboard.writeText(data); setSuccessAlert(`Copied ${counter} ${qFieldMetaData.label} value${counter == 1 ? "" : "s"}.`); + setTimeout(() => setSuccessAlert(null), 3000); } else { - setSuccessAlert(`There are no ${qFieldMetaData.label} values to copy.`); + setWarningAlert(`There are no ${qFieldMetaData.label} values to copy.`); + setTimeout(() => setWarningAlert(null), 3000); } - setTimeout(() => setSuccessAlert(null), 3000); } }; + + /******************************************************************************* + ** event handler from columns menu - to open the column statistics modal + *******************************************************************************/ const openColumnStatistics = async (column: GridColDef) => { - setFilterForColumnStats(buildQFilter(tableMetaData, filterModel)); + setFilterForColumnStats(prepQueryFilterForBackend(queryFilter)); setColumnStatsFieldName(column.field); const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, column.field); @@ -1498,12 +1872,33 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setColumnStatsFieldTableName(fieldTable.name); }; + + /******************************************************************************* + ** close handler for column stats modal + *******************************************************************************/ + const closeColumnStats = (event: object, reason: string) => + { + if (reason === "backdropClick" || reason === "escapeKeyDown") + { + return; + } + + setColumnStatsFieldName(null); + setColumnStatsFieldTableName(null); + setColumnStatsField(null); + }; + + + ///////////////////////////////////////////////// + // custom component for the grid's column-menu // + // todo - break out into own component/file?? // + ///////////////////////////////////////////////// const CustomColumnMenu = forwardRef( function GridColumnMenu(props: GridColumnMenuProps, ref) { const {hideMenu, currentColumn} = props; - /* + /* see below where this could be used for future additional copy functions const [copyMoreMenu, setCopyMoreMenu] = useState(null) const openCopyMoreMenu = (event: any) => { @@ -1516,9 +1911,24 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return ( - + + + { + hideMenu(e); + if(mode == "advanced") + { + handleColumnMenuAdvancedFilterSelection(currentColumn.field); + } + else + { + // @ts-ignore + basicAndAdvancedQueryControlsRef.current.addField(currentColumn.field); + } + }}> + Filter + + - @@ -1531,7 +1941,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }}> Copy values - {/* + {/* idea here was, more options, like what format, or copy all, not just current page... Oh @@ -1552,18 +1962,55 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); }); - - const safeToLocaleString = (n: Number): string => - { - if(n != null && n != undefined) + ///////////////////////////////////////////////////////////// + // custom component for the column header cells // + // where we need custom event handlers for the filter icon // + // todo - break out into own component/file?? // + ///////////////////////////////////////////////////////////// + const CustomColumnHeaderFilterIconButton = forwardRef( + function ColumnHeaderFilterIconButton(props: ColumnHeaderFilterIconButtonProps, ref) { - return (n.toLocaleString()); - } - return (""); - } + let showFilter = false; + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const criteria = queryFilter.criteria[i]; + if(criteria.fieldName == props.field && validateCriteria(criteria, null).criteriaIsValid) + { + showFilter = true; + } + } + if(showFilter) + { + return ( + { + if(mode == "basic") + { + // @ts-ignore !? + basicAndAdvancedQueryControlsRef.current.addField(props.field); + } + else + { + gridApiRef.current.showPreferences(GridPreferencePanelsValue.filters) + } + + event.stopPropagation(); + }}>filter_alt); + } + + return (<>); + }); + + //////////////////////////////////////////////// + // custom component for the grid toolbar // + // todo - break out into own component/file?? // + //////////////////////////////////////////////// function CustomToolbar() { + + /******************************************************************************* + ** event handler for mouse-down event - helps w/ avoiding accidental clicks into rows + *******************************************************************************/ const handleMouseDown: GridEventListener<"cellMouseDown"> = ( params, // GridRowParams event, // MuiEvent> @@ -1572,15 +2019,17 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { setGridMouseDownX(event.clientX); setGridMouseDownY(event.clientY); - clearTimeout(instance.current.timer); + clearTimeout(timerInstance.current.timer); }; + /******************************************************************************* + ** event handler for double-click event - helps w/ avoiding accidental clicks into rows + *******************************************************************************/ const handleDoubleClick: GridEventListener<"rowDoubleClick"> = (event: any) => { - clearTimeout(instance.current.timer); + clearTimeout(timerInstance.current.timer); }; - const apiRef = useGridApiContext(); useGridApiEventHandler(apiRef, "cellMouseDown", handleMouseDown); useGridApiEventHandler(apiRef, "rowDoubleClick", handleDoubleClick); @@ -1597,11 +2046,16 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const joinIsMany = isJoinMany(tableMetaData, visibleJoinTables); const selectionMenuOptions: string[] = []; - selectionMenuOptions.push(`This page (${safeToLocaleString(distinctRecordsOnPageCount)} ${joinIsMany ? "distinct " : ""}record${distinctRecordsOnPageCount == 1 ? "" : "s"})`); - selectionMenuOptions.push(`Full query result (${joinIsMany ? safeToLocaleString(distinctRecords) + ` distinct record${distinctRecords == 1 ? "" : "s"}` : safeToLocaleString(totalRecords) + ` record${totalRecords == 1 ? "" : "s"}`})`); - selectionMenuOptions.push(`Subset of the query result ${selectionSubsetSize ? `(${safeToLocaleString(selectionSubsetSize)} ${joinIsMany ? "distinct " : ""}record${selectionSubsetSize == 1 ? "" : "s"})` : "..."}`); + selectionMenuOptions.push(`This page (${ValueUtils.safeToLocaleString(distinctRecordsOnPageCount)} ${joinIsMany ? "distinct " : ""}record${distinctRecordsOnPageCount == 1 ? "" : "s"})`); + selectionMenuOptions.push(`Full query result (${joinIsMany ? ValueUtils.safeToLocaleString(distinctRecords) + ` distinct record${distinctRecords == 1 ? "" : "s"}` : ValueUtils.safeToLocaleString(totalRecords) + ` record${totalRecords == 1 ? "" : "s"}`})`); + selectionMenuOptions.push(`Subset of the query result ${selectionSubsetSize ? `(${ValueUtils.safeToLocaleString(selectionSubsetSize)} ${joinIsMany ? "distinct " : ""}record${selectionSubsetSize == 1 ? "" : "s"})` : "..."}`); selectionMenuOptions.push("Clear selection"); + + /******************************************************************************* + ** util function to check boxes for some or all rows in the grid, in response to + ** selection menu actions + *******************************************************************************/ function programmaticallySelectSomeOrAllRows(max?: number) { /////////////////////////////////////////////////////////////////////////////////////////// @@ -1614,11 +2068,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element rows.forEach((value: GridRowModel, index: number) => { const primaryKeyValue = latestQueryResults[index].values.get(tableMetaData.primaryKeyField); - if(max) + if (max) { - if(selectedPrimaryKeys.size < max) + if (selectedPrimaryKeys.size < max) { - if(!selectedPrimaryKeys.has(primaryKeyValue)) + if (!selectedPrimaryKeys.has(primaryKeyValue)) { rowSelectionModel.push(value.__rowIndex); selectedPrimaryKeys.add(primaryKeyValue as string); @@ -1635,6 +2089,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setSelectedIds([...selectedPrimaryKeys.values()]); } + + /******************************************************************************* + ** event handler (callback) for optiosn in the selection menu + *******************************************************************************/ const selectionMenuCallback = (selectedIndex: number) => { if(selectedIndex == 0) @@ -1659,58 +2117,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; - const doClearFilter = (event: React.KeyboardEvent, isYesButton: boolean = false) => - { - if (isYesButton|| event.key == "Enter") - { - setShowClearFiltersWarning(false); - handleFilterChange({items: []} as GridFilterModel); - } - } - return (
    - +
    - {/* @ts-ignore */} -
    - {/* @ts-ignore */} - - { - hasValidFilters && ( -
    - - setShowClearFiltersWarning(true)}>clear - - setShowClearFiltersWarning(false)} onKeyPress={(e) => doClearFilter(e)}> - Confirm - - Are you sure you want to remove all conditions from the current filter? - - - setShowClearFiltersWarning(false)} /> - doClearFilter(null, true)}/> - - -
    - ) - } {/* @ts-ignore */} - {/* @ts-ignore */} - - - - -
    - + { setSelectionSubsetSizePromptOpen(false); @@ -1761,14 +2179,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { selectFullFilterState === "filterSubset" && (
    - The setSelectionSubsetSizePromptOpen(true)} style={{cursor: "pointer"}}>first {safeToLocaleString(selectionSubsetSize)} {joinIsMany ? "distinct" : ""} record{selectionSubsetSize == 1 ? "" : "s"} matching this query {selectionSubsetSize == 1 ? "is" : "are"} selected. + The setSelectionSubsetSizePromptOpen(true)} style={{cursor: "pointer"}}>first {ValueUtils.safeToLocaleString(selectionSubsetSize)} {joinIsMany ? "distinct" : ""} record{selectionSubsetSize == 1 ? "" : "s"} matching this query {selectionSubsetSize == 1 ? "is" : "are"} selected.
    ) } { (selectFullFilterState === "n/a" && selectedIds.length > 0) && (
    - {safeToLocaleString(selectedIds.length)} {joinIsMany ? "distinct" : ""} {selectedIds.length == 1 ? "record is" : "records are"} selected. + {ValueUtils.safeToLocaleString(selectedIds.length)} {joinIsMany ? "distinct" : ""} {selectedIds.length == 1 ? "record is" : "records are"} selected.
    ) } @@ -1780,72 +2198,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } - const pushDividerIfNeeded = (menuItems: JSX.Element[]) => - { - if (menuItems.length > 0) - { - menuItems.push(); - } - }; - - const menuItems: JSX.Element[] = []; - if (table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) - { - menuItems.push(library_addBulk Load); - } - if (table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission) - { - menuItems.push(editBulk Edit); - } - if (table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission) - { - menuItems.push(deleteBulk Delete); - } - - const runRecordScriptProcess = metaData?.processes.get("runRecordScript"); - if (runRecordScriptProcess) - { - const process = runRecordScriptProcess; - menuItems.push( processClicked(process)}>{process.iconName ?? "arrow_forward"}{process.label}); - } - - menuItems.push( navigate(`${metaData.getTablePathByName(tableName)}/dev`)}>codeDeveloper Mode); - - if (tableProcesses && tableProcesses.length) - { - pushDividerIfNeeded(menuItems); - } - - tableProcesses.sort((a, b) => a.label.localeCompare(b.label)); - tableProcesses.map((process) => - { - menuItems.push( processClicked(process)}>{process.iconName ?? "arrow_forward"}{process.label}); - }); - - if (menuItems.length === 0) - { - menuItems.push(blockNo actions available); - } - - const renderActionsMenu = ( - - {menuItems} - - ); - /////////////////////////////////////////////////////////////////////////////////////////// // for changes in table controls that don't change the count, call to update the table - // // but without clearing out totalRecords (so pagination doesn't flash) // @@ -1858,48 +2210,22 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // to avoid both this useEffect and the one below from both doing an "initial query", // // only run this one if at least 1 query has already been ran // //////////////////////////////////////////////////////////////////////////////////////// - updateTable(); + updateTable("useEffect(pageNumber,rowsPerPage)"); } - }, [pageNumber, rowsPerPage, columnSortModel, currentSavedFilter]); - - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // for state changes that DO change the filter, call to update the table - and DO clear out the totalRecords // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - useEffect(() => - { - setTotalRecords(null); - setDistinctRecords(null); - updateTable(); - }, [columnsModel, tableState, tableVariant]); - - useEffect(() => - { - const currentQFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage); - currentQFilter.skip = pageNumber * rowsPerPage; - const currentQFilterJSON = JSON.stringify(currentQFilter); - - if(currentQFilterJSON !== lastFetchedQFilterJSON) - { - setTotalRecords(null); - setDistinctRecords(null); - updateTable(); - } - - }, [filterModel]); + }, [pageNumber, rowsPerPage]); + //////////////////////////////////////////////////////////// + // scroll to the origin when pageNo or rowsPerPage change // + //////////////////////////////////////////////////////////// useEffect(() => { document.documentElement.scrollTop = 0; document.scrollingElement.scrollTop = 0; }, [pageNumber, rowsPerPage]); - const updateFilterFromFilterPanel = (newFilter: QQueryFilter): void => - { - setQueryFilter(newFilter); - const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); - handleFilterChange(gridFilterModel, false); - }; - + //////////////////////////////////////////////////////////////////// + // if user doesn't have read permission, just show an error alert // + //////////////////////////////////////////////////////////////////// if (tableMetaData && !tableMetaData.readPermission) { return ( @@ -1911,6 +2237,280 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } + + ///////////////////////////////////////////////////////////////////////////////// + // use this to make changes to the queryFilter more likely to re-run the query // + ///////////////////////////////////////////////////////////////////////////////// + const [filterHash, setFilterHash] = useState(""); + + if(pageState == "ready") + { + const newFilterHash = JSON.stringify(prepQueryFilterForBackend(queryFilter)); + if (filterHash != newFilterHash) + { + setFilterHash(newFilterHash); + updateTable("hash change"); + } + } + + //////////////////////////////////////////////////////////// + // handle the initial page state -- by fetching meta-data // + //////////////////////////////////////////////////////////// + if (pageState == "initial") + { + console.log("page state is initial - going to loadingMetaData..."); + setPageState("loadingMetaData"); + pageLoadingState.setLoading(); + + (async () => + { + const metaData = await qController.loadMetaData(); + setMetaData(metaData); + + const tableMetaData = await qController.loadTableMetaData(tableName); + setTableMetaData(tableMetaData); + setTableLabel(tableMetaData.label); + + setTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName)); // these are the ones to show in the dropdown + setAllTableProcesses(ProcessUtils.getProcessesForTable(metaData, tableName, true)); // these include hidden ones (e.g., to find the bulks) + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // 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(tableMetaData); + setTableDefaultView(newDefaultView); + + setPageState("loadedMetaData"); + })(); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // handle the secondary page state - after meta-data is in state - by figuring out the current view // + ////////////////////////////////////////////////////////////////////////////////////////////////////// + if (pageState == "loadedMetaData") + { + console.log("page state is loadedMetaData - going to loadingView..."); + setPageState("loadingView"); + + (async () => + { + ////////////////////////////////////////////////////////////////////////////////////////////// + // once we've loaded meta data, let's check the location to see if we should open a process // + ////////////////////////////////////////////////////////////////////////////////////////////// + try + { + ///////////////////////////////////////////////////////////////// + // the path for a process looks like: .../table/process // + // so if our tableName is in the -2 index, try to open process // + ///////////////////////////////////////////////////////////////// + if (pathParts[pathParts.length - 2] === tableName) + { + const processName = pathParts[pathParts.length - 1]; + const processList = allTableProcesses.filter(p => p.name == processName); + if (processList.length > 0) + { + setActiveModalProcess(processList[0]); + } + else if (metaData?.processes.has(processName)) + { + /////////////////////////////////////////////////////////////////////////////////////// + // check for generic processes - should this be a specific attribute on the process? // + /////////////////////////////////////////////////////////////////////////////////////// + setActiveModalProcess(metaData?.processes.get(processName)); + } + else + { + console.log(`Couldn't find process named ${processName}`); + } + } + } + catch (e) + { + console.log(e); + } + + if (searchParams && searchParams.has("filter")) + { + ////////////////////////////////////////////////////////////////////////////////////// + // if there's a filter in the URL - then set that as the filter in the current view // + ////////////////////////////////////////////////////////////////////////////////////// + try + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // todo - some version of "you've browsed back here, so if active view (local-storage) is the same as this, then keep old... // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + console.log(`history state: ${JSON.stringify(window.history.state)}`); + + /////////////////////////////////////////////////////////////////////////////////////////////////// + // parse the filter json into a filer object - then clean up values in it (e.g., translate PV's) // + /////////////////////////////////////////////////////////////////////////////////////////////////// + const filterJSON = JSON.parse(searchParams.get("filter")); + const queryFilter = filterJSON as QQueryFilter; + + await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, queryFilter); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // so, URLs with filters, they might say NOT_EQUALS - but - everything else we do in here, uses NOT_EQUALS_OR_IS_NULL... // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const criteria = queryFilter.criteria[i]; + if(criteria.operator == QCriteriaOperator.NOT_EQUALS) + { + criteria.operator = QCriteriaOperator.NOT_EQUALS_OR_IS_NULL; + } + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // set this new query filter in the view, and activate the full view // + // stuff other than the query filter should "stick" from what user had active previously // + /////////////////////////////////////////////////////////////////////////////////////////// + view.queryFilter = queryFilter; + activateView(view); + + ///////////////////////////////////////////////////////////////////////////////////////////// + // make sure that we clear out any currently saved view - we're no longer in such a state. // + ///////////////////////////////////////////////////////////////////////////////////////////// + doClearCurrentSavedView(); + } + catch(e) + { + setAlertContent("Error parsing filter from URL"); + } + } + else if (viewIdInLocation) + { + if(view.viewIdentity == `savedView:${viewIdInLocation}`) + { + ///////////////////////////////////////////////////////////////////////////////////////////////// + // if the view id in the location is the same as the view that was most-recently active here, // + // then we want to act like that old view is active - but - in case the user changed anything, // + // we want to keep their current settings as the active view - thus - use the current 'view' // + // state variable (e.g., from local storage) as the view to be activated. // + ///////////////////////////////////////////////////////////////////////////////////////////////// + console.log(`Initializing view to a (potentially dirty) saved view (id=${viewIdInLocation})`); + activateView(view); + + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // now fetch that savedView, and set it in state, but don't activate it - because that would overwrite // + // anything the user may have changed (e.g., anything in the local-storage/state view). // + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + const savedViewRecord = await fetchSavedView(viewIdInLocation); + setCurrentSavedView(savedViewRecord); + } + else + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if there's a filterId in the location, but it isn't the last one the user had active, then set that as our active view // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + console.log(`Initializing view to a clean saved view (id=${viewIdInLocation})`); + await handleSavedViewChange(viewIdInLocation); + } + } + else + { + /////////////////////////////////////////////////////////////////////////////////////////////// + // if the last time we were on this table, a currentSavedView was written to local storage - // + // then navigate back to that view's URL - unless - it looks like we're on a process! // + /////////////////////////////////////////////////////////////////////////////////////////////// + if (localStorage.getItem(currentSavedViewLocalStorageKey) && !urlLooksLikeProcess()) + { + const currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey)); + console.log(`returning to previously active saved view ${currentSavedViewId}`); + navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); + setViewIdInLocation(currentSavedViewId); + + ///////////////////////////////////////////////////////////////////////////////////////////////////// + // return - without activating any view, and actually, reset the pageState back to loadedMetaData, // + // so the useEffect that monitors location will see the change, and will set viewIdInLocation // + // so upon a re-render we'll hit this block again. // + ///////////////////////////////////////////////////////////////////////////////////////////////////// + setPageState("loadedMetaData") + return; + } + + ////////////////////////////////////////////////////////////////// + // view is ad-hoc - just activate the view that was last active // + ////////////////////////////////////////////////////////////////// + activateView(view); + } + + setPageState("loadedView"); + })(); + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + // handle the 3rd page state - after we have the view loaded - prepare the grid for display // + ////////////////////////////////////////////////////////////////////////////////////////////// + if (pageState == "loadedView") + { + console.log("page state is loadedView - going to preparingGrid..."); + setPageState("preparingGrid"); + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // check if any new columns have been added to the table since last time this view was activated... // + // or if anything in the view is no longer in the table // + ////////////////////////////////////////////////////////////////////////////////////////////////////// + reconcileCurrentTableMetaDataWithView(view, "initialPageLoad"); + + //////////////////////////////////////////////////////////////////////////////////////// + // this ref may not be defined on the initial render, so, make this call in a timeout // + //////////////////////////////////////////////////////////////////////////////////////// + setTimeout(() => + { + // @ts-ignore + basicAndAdvancedQueryControlsRef?.current?.ensureAllFilterCriteriaAreActiveQuickFilters(view.queryFilter, "defaultFilterLoaded") + }); + + console.log("finished preparing grid, going to page state ready"); + setPageState("ready"); + + //////////////////////////////////////////// + // if we need a variant, show that prompt // + //////////////////////////////////////////// + if (tableMetaData?.usesVariants && !tableVariant) + { + promptForTableVariantSelection(); + } + + return (getLoadingScreen()); + } + + //////////////////////////////////////////////////////////////////////// + // trigger initial update-table call after page-state goes into ready // + //////////////////////////////////////////////////////////////////////// + useEffect(() => + { + if(pageState == "ready") + { + pageLoadingState.setNotLoading() + + if(!tableVariantPromptOpen) + { + updateTable("pageState is now ready") + } + } + }, [pageState, tableVariantPromptOpen]); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // any time these are out of sync, it means we've navigated to a different table, so we need to reload :allthethings: // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (tableMetaData && tableMetaData.name !== tableName) + { + console.log(`Found mis-match between tableMetaData.name and tableName [${tableMetaData.name}]!=[${tableName}] - reload everything.`); + setPageState("initial"); + setTableMetaData(null); + setColumnSortModel([]); + setColumnsModel([]); + setQueryFilter(new QQueryFilter()); + setQueryColumns(new PreLoadQueryColumns()); + setRows([]); + setIsFirstRenderAfterChangingTables(true); + + return (getLoadingScreen()); + } + ///////////////////////////////////////////////////////////////////////////////////////////// // if the table doesn't allow QUERY, but does allow GET, don't render a data grid - // // instead, try to just render a Goto Record button, in auto-open, and may-not-close modes // @@ -1936,7 +2536,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element let gotoVariantSubHeader = <>; if(tableMetaData?.usesVariants) { - gotoVariantSubHeader = {getTableVariantHeader()} + gotoVariantSubHeader = {getTableVariantHeader(tableVariant)} } return ( @@ -1946,70 +2546,211 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } + /////////////////////////////////////////////////////////// + // render a loading screen if the page state isn't ready // + /////////////////////////////////////////////////////////// + if(pageState != "ready") + { + console.log(`page state is ${pageState}... no-op while those complete async's run...`); + return (getLoadingScreen()); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // if the table isn't loaded yet, display loading screen. // + // this shouldn't be possible, to be out-of-sync with pageState, but just as a fail-safe // + /////////////////////////////////////////////////////////////////////////////////////////// + if(!tableMetaData) + { + return (getLoadingScreen()); + } + + let savedViewsComponent = null; + if(metaData && metaData.processes.has("querySavedView")) + { + savedViewsComponent = (); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + 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 ( + view_week_outline Columns ({view.queryColumns.getVisibleColumnCount()}) keyboard_arrow_down} + isModeToggle={true} + toggleStates={view.queryColumns.getVisibilityToggleStates()} + handleToggleField={handleChangeOneColumnVisibility} + /> + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // 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 ( + + + + {pageLoadingState.isLoading() && ""} + {pageLoadingState.isLoadingSlow() && "Loading..."} + {pageLoadingState.isNotLoading() && getPageHeader(tableMetaData, visibleJoinTables, tableVariant)} + + + + + + { + tableMetaData && + + } + + { + table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && + + } + +
    {/* - // see above code that would use this + // see code in ExportMenuItem that would use this */} - - {alertContent ? ( - - - { - setAlertContent(null); - }} - > - {alertContent} - - - ) : ( - "" - )} + + { + alertContent ? ( + + setAlertContent(null)}>{alertContent} + + ) : null + } { (tableLabel && showSuccessfullyDeletedAlert) ? ( - setShowSuccessfullyDeletedAlert(false)}>{`${tableLabel} successfully deleted`} + + setShowSuccessfullyDeletedAlert(false)}>{`${tableLabel} successfully deleted`} + ) : null } { (successAlert) ? ( - setSuccessAlert(null)}>{successAlert} + setSuccessAlert(null)}>{successAlert} ) : null } { (warningAlert) ? ( - warning} sx={{mb: 3}} onClose={() => setWarningAlert(null)}>{warningAlert} + warning} sx={{mt: 1.5, mb: 0.5}} onClose={() => setWarningAlert(null)}>{warningAlert} ) : null } - - - { - metaData && metaData.processes.has("querySavedFilter") && - - } - - - - - {renderActionsMenu} - - { - table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && - - } - + { + metaData && tableMetaData && + + } + count !== 1 ? `${count} conditions` : `${count} condition` + columnMenuSortAsc: "Sort ascending", + columnMenuSortDesc: "Sort descending", }} pinnedColumns={pinnedColumns} onPinnedColumnsChange={handlePinnedColumnsChange} @@ -2067,21 +2798,19 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element onStateChange={handleStateChange} density={density} loading={loading} - filterModel={filterModel} - onFilterModelChange={(model) => handleFilterChange(model, true, true)} columnVisibilityModel={columnVisibilityModel} onColumnVisibilityModelChange={handleColumnVisibilityChange} onColumnOrderChange={handleColumnOrderChange} onColumnResize={handleColumnResize} - onSelectionModelChange={selectionChanged} - onSortModelChange={handleSortChangeForDataGrid} + 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: "calc(100vh - 250px)"}} + sx={{border: 0, height: `calc(100vh - ${spaceAboveGrid + spaceBelowGrid}px)`}} /> @@ -2097,7 +2826,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } { - tableMetaData && + tableMetaData && tableMetaData.usesVariants && { setTableVariantPromptOpen(false); @@ -2127,136 +2856,4 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } -//////////////////////////////////////////////////////////////////////////////////////////////////////// -// mini-component that is the dialog for the user to select a variant on tables with variant backends // -//////////////////////////////////////////////////////////////////////////////////////////////////////// -function TableVariantDialog(props: {isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void}) -{ - const [value, setValue] = useState(null) - const [dropDownOpen, setDropDownOpen] = useState(false) - const [variants, setVariants] = useState(null); - - const handleVariantChange = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) => - { - const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${props.table.name}`; - if(value != null) - { - localStorage.setItem(tableVariantLocalStorageKey, JSON.stringify(value)); - } - else - { - localStorage.removeItem(tableVariantLocalStorageKey); - } - props.closeHandler(value); - }; - - const keyPressed = (e: React.KeyboardEvent) => - { - if(e.key == "Enter" && value) - { - props.closeHandler(value); - } - } - - useEffect(() => - { - console.log("queryVariants") - try - { - (async () => - { - const variants = await qController.tableVariants(props.table.name); - console.log(JSON.stringify(variants)); - setVariants(variants); - })(); - } - catch (e) - { - console.log(e); - } - }, []); - - - return variants && ( - keyPressed(e)}> - {props.table.variantTableLabel} - - Select the {props.table.variantTableLabel} to be used on this table: - - { - setDropDownOpen(true); - }} - onClose={() => - { - setDropDownOpen(false); - }} - // @ts-ignore - onChange={handleVariantChange} - isOptionEqualToValue={(option, value) => option.id === value.id} - options={variants} - renderInput={(params) => } - getOptionLabel={(option) => - { - if(typeof option == "object") - { - return (option as QTableVariant).name; - } - return option; - }} - /> - - - ) -} - -////////////////////////////////////////////////////////////////////////////////// -// mini-component that is the dialog for the user to enter the selection-subset // -////////////////////////////////////////////////////////////////////////////////// -function SelectionSubsetDialog(props: {isOpen: boolean; initialValue: number; closeHandler: (value?: number) => void}) -{ - const [value, setValue] = useState(props.initialValue) - - const handleChange = (newValue: string) => - { - setValue(parseInt(newValue)) - } - - const keyPressed = (e: React.KeyboardEvent) => - { - if(e.key == "Enter" && value) - { - props.closeHandler(value); - } - } - - return ( - props.closeHandler()} onKeyPress={(e) => keyPressed(e)}> - Subset of the Query Result - - How many records do you want to select? - handleChange(e.target.value)} - value={value} - sx={{width: "100%"}} - onFocus={event => event.target.select()} - /> - - - props.closeHandler()} /> - props.closeHandler(value)} /> - - - ) -} - - - export default RecordQuery; diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index ad51d57..b327e62 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -29,6 +29,13 @@ min-height: calc(100vh - 450px) !important; } +/* we want to leave columns w/ the sortable attribute (so they have it in the column menu), +but we've turned off the click-to-sort function, so remove hand cursor */ +.recordQuery .MuiDataGrid-columnHeader--sortable +{ + cursor: default !important; +} + /* Disable red outlines on clicked cells */ .MuiDataGrid-cell:focus, .MuiDataGrid-columnHeader:focus, @@ -402,7 +409,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } margin-right: 8px; } -.custom-columns-panel .MuiSwitch-thumb +.custom-columns-panel .MuiSwitch-thumb, +.fieldListMenuBody .MuiSwitch-thumb { width: 15px !important; height: 15px !important; @@ -424,6 +432,20 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } top: -60px !important; } +.MuiDataGrid-panel:has(.customFilterPanel) +{ + /* overwrite what the grid tries to do here, where it changes based on density... we always want the same. */ + /* transform: translate(274px, 305px) !important; */ + transform: translate(274px, 264px) !important; +} + +/* within the data-grid, the filter-panel's container has a max-height. mirror that, and set an overflow-y */ +.MuiDataGrid-panel .customFilterPanel +{ + max-height: 450px; + overflow-y: auto; +} + /* tighten the text in the field select dropdown in custom filters */ .customFilterPanel .MuiAutocomplete-paper { @@ -487,7 +509,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } } /* change tags in any-of value fields to not be black bg with white text */ -.customFilterPanel .filterValuesColumn .MuiChip-root +.customFilterPanel .filterValuesColumn .MuiChip-root, +.quickFilter.filterValuesColumn .MuiChip-root { background: none; color: black; @@ -495,20 +518,23 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } } /* change 'x' icon in tags in any-of value */ -.customFilterPanel .filterValuesColumn .MuiChip-root .MuiChip-deleteIcon +.customFilterPanel .filterValuesColumn .MuiChip-root .MuiChip-deleteIcon, +.quickFilter.filterValuesColumn .MuiChip-root .MuiChip-deleteIcon { color: gray; } /* change tags in any-of value fields to not be black bg with white text */ -.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag +.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag, +.quickFilter.filterValuesColumn .MuiAutocomplete-tag { color: #191919; background: none; } /* default hover color for the 'x' to remove a tag from an 'any-of' value was white, which made it disappear */ -.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag .MuiSvgIcon-root:hover +.customFilterPanel .filterValuesColumn .MuiAutocomplete-tag .MuiSvgIcon-root:hover, +.quickFilter.filterValuesColumn .MuiAutocomplete-tag .MuiSvgIcon-root:hover { color: lightgray; } @@ -597,4 +623,38 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } .dataGridHeaderTooltip { top: -1.25rem; -} \ No newline at end of file +} + +/* when grid contents weren't filling the height of the screen, the gray panel for pinned columns + was stretching to most of the grid height, but it wasn't the full height and so looked a little + broken. just turing off this min height changes to not try to stretch at all, and is not broken. */ +.MuiDataGrid-pinnedColumns +{ + min-height: unset !important; +} + +/* new style for toggle buttons */ +.MuiToggleButtonGroup-root +{ + padding: 0.25rem; + border: 1px solid #BDBDBD; + border-radius: 0.5rem !important; +} +.MuiToggleButtonGroup-root .MuiButtonBase-root +{ + text-transform: none; + font-size: 0.75rem; + color: black; + font-weight: 600; + border-radius: 0.375rem !important; /* overriding left/right edge overrides for first/last */ + border: none; + flex: 1 1 0px; +} +.MuiToggleButtonGroup-root .MuiButtonBase-root.Mui-selected +{ + background: rgba(117, 117, 117, 0.20); +} +.MuiToggleButtonGroup-root .MuiButtonBase-root.Mui-disabled +{ + border: none; +} diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts deleted file mode 100644 index 6fbb489..0000000 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ /dev/null @@ -1,752 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; -import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; -import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; -import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; -import {NowExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowExpression"; -import {NowWithOffsetExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression"; -import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; -import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; -import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; -import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; -import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression"; -import {GridFilterItem, GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro"; -import TableUtils from "qqq/utils/qqq/TableUtils"; -import ValueUtils from "qqq/utils/qqq/ValueUtils"; - -const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId"; - -/******************************************************************************* - ** Utility class for working with QQQ Filters - ** - *******************************************************************************/ -class FilterUtils -{ - /******************************************************************************* - ** Convert a grid operator to a QQQ Criteria Operator. - *******************************************************************************/ - public static gridCriteriaOperatorToQQQ = (operator: string): QCriteriaOperator => - { - switch (operator) - { - case "contains": - return QCriteriaOperator.CONTAINS; - case "notContains": - return QCriteriaOperator.NOT_CONTAINS; - case "startsWith": - return QCriteriaOperator.STARTS_WITH; - case "notStartsWith": - return QCriteriaOperator.NOT_STARTS_WITH; - case "endsWith": - return QCriteriaOperator.ENDS_WITH; - case "notEndsWith": - return QCriteriaOperator.NOT_ENDS_WITH; - case "is": - case "equals": - case "=": - case "isTrue": - case "isFalse": - return QCriteriaOperator.EQUALS; - case "isNot": - case "!=": - return QCriteriaOperator.NOT_EQUALS_OR_IS_NULL; - case "after": - case ">": - return QCriteriaOperator.GREATER_THAN; - case "onOrAfter": - case ">=": - return QCriteriaOperator.GREATER_THAN_OR_EQUALS; - case "before": - case "<": - return QCriteriaOperator.LESS_THAN; - case "onOrBefore": - case "<=": - return QCriteriaOperator.LESS_THAN_OR_EQUALS; - case "isEmpty": - return QCriteriaOperator.IS_BLANK; - case "isNotEmpty": - return QCriteriaOperator.IS_NOT_BLANK; - case "isAnyOf": - return QCriteriaOperator.IN; - case "isNone": - return QCriteriaOperator.NOT_IN; - case "between": - return QCriteriaOperator.BETWEEN; - case "notBetween": - return QCriteriaOperator.NOT_BETWEEN; - default: - return QCriteriaOperator.EQUALS; - } - }; - - /******************************************************************************* - ** Convert a qqq criteria operator to one expected by the grid. - *******************************************************************************/ - public static qqqCriteriaOperatorToGrid = (operator: QCriteriaOperator, field: QFieldMetaData, criteriaValues: any[]): string => - { - const fieldType = field.type; - switch (operator) - { - case QCriteriaOperator.EQUALS: - - if (field.possibleValueSourceName) - { - return ("is"); - } - - switch (fieldType) - { - case QFieldType.INTEGER: - case QFieldType.DECIMAL: - return ("="); - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - case QFieldType.STRING: - case QFieldType.TEXT: - case QFieldType.HTML: - case QFieldType.PASSWORD: - case QFieldType.BLOB: - return ("equals"); - case QFieldType.BOOLEAN: - if (criteriaValues && criteriaValues[0] === true) - { - return ("isTrue"); - } - else if (criteriaValues && criteriaValues[0] === false) - { - return ("isFalse"); - } - return ("is"); - default: - return ("is"); - } - case QCriteriaOperator.NOT_EQUALS: - case QCriteriaOperator.NOT_EQUALS_OR_IS_NULL: - - if (field.possibleValueSourceName) - { - return ("isNot"); - } - - switch (fieldType) - { - case QFieldType.INTEGER: - case QFieldType.DECIMAL: - return ("!="); - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - case QFieldType.BOOLEAN: - case QFieldType.STRING: - case QFieldType.TEXT: - case QFieldType.HTML: - case QFieldType.PASSWORD: - case QFieldType.BLOB: - default: - return ("isNot"); - } - case QCriteriaOperator.IN: - return ("isAnyOf"); - case QCriteriaOperator.NOT_IN: - return ("isNone"); - case QCriteriaOperator.STARTS_WITH: - return ("startsWith"); - case QCriteriaOperator.ENDS_WITH: - return ("endsWith"); - case QCriteriaOperator.CONTAINS: - return ("contains"); - case QCriteriaOperator.NOT_STARTS_WITH: - return ("notStartsWith"); - case QCriteriaOperator.NOT_ENDS_WITH: - return ("notEndsWith"); - case QCriteriaOperator.NOT_CONTAINS: - return ("notContains"); - case QCriteriaOperator.LESS_THAN: - switch (fieldType) - { - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - return ("before"); - default: - return ("<"); - } - case QCriteriaOperator.LESS_THAN_OR_EQUALS: - switch (fieldType) - { - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - return ("onOrBefore"); - default: - return ("<="); - } - case QCriteriaOperator.GREATER_THAN: - switch (fieldType) - { - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - return ("after"); - default: - return (">"); - } - case QCriteriaOperator.GREATER_THAN_OR_EQUALS: - switch (fieldType) - { - case QFieldType.DATE: - case QFieldType.TIME: - case QFieldType.DATE_TIME: - return ("onOrAfter"); - default: - return (">="); - } - case QCriteriaOperator.IS_BLANK: - return ("isEmpty"); - case QCriteriaOperator.IS_NOT_BLANK: - return ("isNotEmpty"); - case QCriteriaOperator.BETWEEN: - return ("between"); - case QCriteriaOperator.NOT_BETWEEN: - return ("notBetween"); - default: - console.warn(`Unhandled criteria operator: ${operator}`); - return ("="); - } - }; - - /******************************************************************************* - ** the values object needs handled differently based on cardinality of the operator. - ** that is - qqq always wants a list, but the grid provides it differently per-operator. - ** for single-values (the default), we must wrap it in an array. - ** for non-values (e.g., blank), set it to null. - ** for list-values, it's already in an array, so don't wrap it. - *******************************************************************************/ - public static gridCriteriaValueToQQQ = (operator: QCriteriaOperator, value: any, gridOperatorValue: string, fieldMetaData: QFieldMetaData): any[] => - { - if (gridOperatorValue === "isTrue") - { - return [true]; - } - else if (gridOperatorValue === "isFalse") - { - return [false]; - } - - if (operator === QCriteriaOperator.IS_BLANK || operator === QCriteriaOperator.IS_NOT_BLANK) - { - return (null); - } - else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN) - { - if ((value == null || value.length < 2) && (operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN)) - { - ///////////////////////////////////////////////////////////////////////////////////////////////// - // if we send back null, we get a 500 - bad look every time you try to set up a BETWEEN filter // - // but array of 2 nulls? comes up sunshine. // - ///////////////////////////////////////////////////////////////////////////////////////////////// - return ([null, null]); - } - return (FilterUtils.cleanseCriteriaValueForQQQ(value, fieldMetaData)); - } - - return (FilterUtils.cleanseCriteriaValueForQQQ([value], fieldMetaData)); - }; - - - /******************************************************************************* - ** Helper method - take a list of values, which may be possible values, and - ** either return the original list, or a new list that is just the ids of the - ** possible values (if it was a list of possible values). - ** - ** Or, if the values are date-times, convert them to UTC. - *******************************************************************************/ - private static cleanseCriteriaValueForQQQ = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] => - { - if (param === null || param === undefined) - { - return (param); - } - - if (FilterUtils.gridCriteriaValueToExpression(param)) - { - return (param); - } - - let rs = []; - for (let i = 0; i < param.length; i++) - { - console.log(param[i]); - if (param[i] && param[i].id && param[i].label) - { - ////////////////////////////////////////////////////////////////////////////////////////// - // if the param looks like a possible value, return its id // - // during build of new custom filter panel, this ended up causing us // - // problems (because we wanted the full PV object in the filter model for the frontend) // - // so, we can keep the PV as-is here, and see calls to convertFilterPossibleValuesToIds // - // to do what this used to do. // - ////////////////////////////////////////////////////////////////////////////////////////// - // rs.push(param[i].id); - rs.push(param[i]); - } - else - { - if (fieldMetaData?.type == QFieldType.DATE_TIME) - { - try - { - let toPush = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(param[i]); - rs.push(toPush); - } - catch (e) - { - console.log("Error converting date-time to UTC: ", e); - rs.push(param[i]); - } - } - else - { - rs.push(param[i]); - } - } - } - return (rs); - }; - - - /******************************************************************************* - ** Convert a filter field's value from the style that qqq uses, to the style that - ** the grid uses. - *******************************************************************************/ - public static qqqCriteriaValuesToGrid = (operator: QCriteriaOperator, values: any[], field: QFieldMetaData): any | any[] => - { - const fieldType = field.type; - if (operator === QCriteriaOperator.IS_BLANK || operator === QCriteriaOperator.IS_NOT_BLANK) - { - return null; - } - else if (operator === QCriteriaOperator.IN || operator === QCriteriaOperator.NOT_IN || operator === QCriteriaOperator.BETWEEN || operator === QCriteriaOperator.NOT_BETWEEN) - { - return (values); - } - - if (values && values.length > 0) - { - //////////////////////////////////////////////////////////////////////////////////////////////// - // make sure dates are formatted for the grid the way it expects - not the way we pass it in. // - //////////////////////////////////////////////////////////////////////////////////////////////// - if (fieldType === QFieldType.DATE_TIME) - { - for(let i = 0; i - { - let defaultFilter = {items: []} as GridFilterModel; - let defaultSort = [] as GridSortItem[]; - let warningParts = [] as string[]; - - if (tableMetaData && tableMetaData.fields !== undefined) - { - if (filterString != null || (searchParams && searchParams.has("filter"))) - { - try - { - const filterJSON = (filterString !== null) ? JSON.parse(filterString) : JSON.parse(searchParams.get("filter")); - const qQueryFilter = filterJSON as QQueryFilter; - - ////////////////////////////////////////////////////////////////// - // translate from a qqq-style filter to one that the grid wants // - ////////////////////////////////////////////////////////////////// - let id = 1; - for (let i = 0; i < qQueryFilter?.criteria?.length; i++) - { - const criteria = qQueryFilter.criteria[i]; - let [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName); - if (field == null) - { - console.log("Couldn't find field for filter: " + criteria.fieldName); - warningParts.push("Your filter contained an unrecognized field name: " + criteria.fieldName) - continue; - } - - let values = criteria.values; - if (field.possibleValueSourceName) - { - ////////////////////////////////////////////////////////////////////////////////// - // possible-values in query-string are expected to only be their id values. // - // e.g., ...values=[1]... // - // but we need them to be possibleValue objects (w/ id & label) so the label // - // can be shown in the filter dropdown. So, make backend call to look them up. // - ////////////////////////////////////////////////////////////////////////////////// - if (values && values.length > 0) - { - values = await qController.possibleValues(fieldTable.name, null, field.name, "", values); - } - - //////////////////////////////////////////// - // log message if no values were returned // - //////////////////////////////////////////// - if (!values || values.length === 0) - { - console.warn("WARNING: No possible values were returned for [" + field.possibleValueSourceName + "] for values [" + criteria.values + "]."); - } - } - - ////////////////////////////////////////////////////////////////////////// - // replace objects that look like expressions with expression instances // - ////////////////////////////////////////////////////////////////////////// - if(values && values.length) - { - for (let i = 0; i < values.length; i++) - { - const expression = this.gridCriteriaValueToExpression(values[i]) - if (expression) - { - values[i] = expression; - } - } - } - - defaultFilter.items.push({ - columnField: criteria.fieldName, - operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, values), - value: FilterUtils.qqqCriteriaValuesToGrid(criteria.operator, values, field), - id: id++ - }); - } - - defaultFilter.linkOperator = GridLinkOperator.And; - if (qQueryFilter.booleanOperator === "OR") - { - defaultFilter.linkOperator = GridLinkOperator.Or; - } - - ///////////////////////////////////////////////////////////////// - // translate from qqq-style orderBy to one that the grid wants // - ///////////////////////////////////////////////////////////////// - if (qQueryFilter.orderBys && qQueryFilter.orderBys.length > 0) - { - for (let i = 0; i < qQueryFilter.orderBys.length; i++) - { - const orderBy = qQueryFilter.orderBys[i]; - defaultSort.push({ - field: orderBy.fieldName, - sort: orderBy.isAscending ? "asc" : "desc" - }); - } - } - - if (searchParams && searchParams.has("filter")) - { - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // if we're setting the filter based on a filter query-string param, then make sure we don't have a currentSavedFilter in local storage. // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - localStorage.removeItem(`${CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT}.${tableMetaData.name}`); - localStorage.setItem(filterLocalStorageKey, JSON.stringify(defaultFilter)); - localStorage.setItem(sortLocalStorageKey, JSON.stringify(defaultSort)); - } - - return ({filter: defaultFilter, sort: defaultSort, warning: warningParts.length > 0 ? "Warning: " + warningParts.join("; ") : ""}); - } - catch (e) - { - console.warn("Error parsing filter from query string", e); - } - } - - if (localStorage.getItem(filterLocalStorageKey)) - { - defaultFilter = JSON.parse(localStorage.getItem(filterLocalStorageKey)); - console.log(`Got default from LS: ${JSON.stringify(defaultFilter)}`); - } - - if (localStorage.getItem(sortLocalStorageKey)) - { - defaultSort = JSON.parse(localStorage.getItem(sortLocalStorageKey)); - console.log(`Got default from LS: ${JSON.stringify(defaultSort)}`); - } - } - - ///////////////////////////////////////////////////////////////////////////////// - // if any values in the items are objects, but should be expression instances, // - // then convert & replace them. // - ///////////////////////////////////////////////////////////////////////////////// - if(defaultFilter && defaultFilter.items && defaultFilter.items.length) - { - defaultFilter.items.forEach((item) => - { - if(item.value && item.value.length) - { - for (let i = 0; i < item.value.length; i++) - { - const expression = this.gridCriteriaValueToExpression(item.value[i]) - if(expression) - { - item.value[i] = expression; - } - } - } - else - { - const expression = this.gridCriteriaValueToExpression(item.value) - if(expression) - { - item.value = expression; - } - } - }); - } - - return ({filter: defaultFilter, sort: defaultSort, warning: warningParts.length > 0 ? "Warning: " + warningParts.join("; ") : ""}); - } - - - /******************************************************************************* - ** build a grid filter from a qqq filter - *******************************************************************************/ - public static buildGridFilterFromQFilter(tableMetaData: QTableMetaData, queryFilter: QQueryFilter): GridFilterModel - { - const gridItems: GridFilterItem[] = []; - - for (let i = 0; i < queryFilter.criteria.length; i++) - { - const criteria = queryFilter.criteria[i]; - const [field, fieldTable] = FilterUtils.getField(tableMetaData, criteria.fieldName); - if (field) - { - gridItems.push({columnField: criteria.fieldName, id: i, operatorValue: FilterUtils.qqqCriteriaOperatorToGrid(criteria.operator, field, criteria.values), value: FilterUtils.qqqCriteriaValuesToGrid(criteria.operator, criteria.values, field)}); - } - } - - const gridFilter: GridFilterModel = {items: gridItems, linkOperator: queryFilter.booleanOperator == "AND" ? GridLinkOperator.And : GridLinkOperator.Or}; - return (gridFilter); - } - - - /******************************************************************************* - ** - *******************************************************************************/ - public static getField(tableMetaData: QTableMetaData, fieldName: string): [QFieldMetaData, QTableMetaData] - { - if (fieldName == null) - { - return ([null, null]); - } - - if (fieldName.indexOf(".") > -1) - { - let parts = fieldName.split(".", 2); - if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length) - { - for (let i = 0; i < tableMetaData.exposedJoins.length; i++) - { - const joinTable = tableMetaData.exposedJoins[i].joinTable; - if (joinTable.name == parts[0]) - { - return ([joinTable.fields.get(parts[1]), joinTable]); - } - } - } - - console.log(`Failed to find join field: ${fieldName}`); - return ([null, null]); - } - else - { - return ([tableMetaData.fields.get(fieldName), tableMetaData]); - } - } - - - /******************************************************************************* - ** build a qqq filter from a grid and column sort model - *******************************************************************************/ - public static buildQFilterFromGridFilter(tableMetaData: QTableMetaData, filterModel: GridFilterModel, columnSortModel: GridSortItem[], limit?: number, allowIncompleteCriteria = false): QQueryFilter - { - console.log("Building q filter with model:"); - console.log(filterModel); - - const qFilter = new QQueryFilter(); - if (columnSortModel) - { - columnSortModel.forEach((gridSortItem) => - { - qFilter.addOrderBy(new QFilterOrderBy(gridSortItem.field, gridSortItem.sort === "asc")); - }); - } - - if (limit) - { - console.log("Setting limit to: " + limit); - qFilter.limit = limit; - } - - if (filterModel) - { - let foundFilter = false; - filterModel.items.forEach((item) => - { - ///////////////////////////////////////////////////////////////////////// - // set the values for these operators that otherwise don't have values // - ///////////////////////////////////////////////////////////////////////// - if (item.operatorValue === "isTrue") - { - item.value = [true]; - } - else if (item.operatorValue === "isFalse") - { - item.value = [false]; - } - - //////////////////////////////////////////////////////////////////////////////// - // if no value set and not 'empty' or 'not empty' operators, skip this filter // - //////////////////////////////////////////////////////////////////////////////// - let incomplete = false; - if (item.operatorValue === "between" || item.operatorValue === "notBetween") - { - if(!item.value || !item.value.length || item.value.length < 2 || this.isUnset(item.value[0]) || this.isUnset(item.value[1])) - { - incomplete = true; - } - } - else if ((!item.value || item.value.length == 0 || (item.value.length == 1 && this.isUnset(item.value[0]))) && item.operatorValue !== "isEmpty" && item.operatorValue !== "isNotEmpty") - { - incomplete = true; - } - - if (incomplete && !allowIncompleteCriteria) - { - console.log(`Discarding incomplete filter criteria: ${JSON.stringify(item)}`); - return; - } - - const fieldMetadata = tableMetaData?.fields.get(item.columnField); - const operator = FilterUtils.gridCriteriaOperatorToQQQ(item.operatorValue); - const values = FilterUtils.gridCriteriaValueToQQQ(operator, item.value, item.operatorValue, fieldMetadata); - let criteria = new QFilterCriteria(item.columnField, operator, values); - qFilter.addCriteria(criteria); - foundFilter = true; - }); - - qFilter.booleanOperator = "AND"; - if (filterModel.linkOperator == "or") - { - /////////////////////////////////////////////////////////////////////////////////////////// - // by default qFilter uses AND - so only if we see linkOperator=or do we need to set it // - /////////////////////////////////////////////////////////////////////////////////////////// - qFilter.booleanOperator = "OR"; - } - } - - return qFilter; - }; - - - /******************************************************************************* - ** - *******************************************************************************/ - private static isUnset(value: any) - { - return value === "" || value === undefined; - } - - /******************************************************************************* - ** - *******************************************************************************/ - private static gridCriteriaValueToExpression(value: any) - { - if (value && value.length) - { - value = value[0]; - } - - if (value && value.type) - { - if (value.type == "NowWithOffset") - { - return (new NowWithOffsetExpression(value)); - } - else if (value.type == "Now") - { - return (new NowExpression(value)); - } - else if (value.type == "ThisOrLastPeriod") - { - return (new ThisOrLastPeriodExpression(value)); - } - } - - return (null); - } - - - /******************************************************************************* - ** edit the input filter object, replacing any values which have {id,label} attributes - ** to instead just have the id part. - *******************************************************************************/ - public static convertFilterPossibleValuesToIds(inputFilter: QQueryFilter): QQueryFilter - { - const filter = Object.assign({}, inputFilter); - - if (filter.criteria) - { - for (let i = 0; i < filter.criteria.length; i++) - { - const criteria = filter.criteria[i]; - if (criteria.values) - { - for (let j = 0; j < criteria.values.length; j++) - { - let value = criteria.values[j]; - if (value && value.id && value.label) - { - criteria.values[j] = value.id; - } - } - } - } - } - - return (filter); - } - -} - -export default FilterUtils; diff --git a/src/qqq/utils/qqq/FilterUtils.tsx b/src/qqq/utils/qqq/FilterUtils.tsx new file mode 100644 index 0000000..88ab5ea --- /dev/null +++ b/src/qqq/utils/qqq/FilterUtils.tsx @@ -0,0 +1,573 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {NowExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowExpression"; +import {NowWithOffsetExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression"; +import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression"; +import Box from "@mui/material/Box"; +import {GridSortModel} from "@mui/x-data-grid-pro"; +import TableUtils from "qqq/utils/qqq/TableUtils"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +/******************************************************************************* + ** Utility class for working with QQQ Filters + ** + *******************************************************************************/ +class FilterUtils +{ + + /******************************************************************************* + ** Helper method - take a list of values, which may be possible values, and + ** either return the original list, or a new list that is just the ids of the + ** possible values (if it was a list of possible values). + ** + ** Or, if the values are date-times, convert them to UTC. + *******************************************************************************/ + public static cleanseCriteriaValueForQQQ = (param: any[], fieldMetaData: QFieldMetaData): number[] | string[] => + { + if (param === null || param === undefined) + { + return (param); + } + + if (FilterUtils.gridCriteriaValueToExpression(param)) + { + return (param); + } + + let rs = []; + for (let i = 0; i < param.length; i++) + { + console.log(param[i]); + if (param[i] && param[i].id && param[i].label) + { + ///////////////////////////////////////////////////////////// + // if the param looks like a possible value, return its id // + ///////////////////////////////////////////////////////////// + rs.push(param[i].id); + } + else + { + if (fieldMetaData?.type == QFieldType.DATE_TIME) + { + try + { + let toPush = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(param[i]); + rs.push(toPush); + } + catch (e) + { + console.log("Error converting date-time to UTC: ", e); + rs.push(param[i]); + } + } + else + { + rs.push(param[i]); + } + } + } + return (rs); + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public static async cleanupValuesInFilerFromQueryString(qController: QController, tableMetaData: QTableMetaData, queryFilter: QQueryFilter) + { + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const criteria = queryFilter.criteria[i]; + let [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName); + + let values = criteria.values; + if (field.possibleValueSourceName) + { + ////////////////////////////////////////////////////////////////////////////////// + // possible-values in query-string are expected to only be their id values. // + // e.g., ...values=[1]... // + // but we need them to be possibleValue objects (w/ id & label) so the label // + // can be shown in the filter dropdown. So, make backend call to look them up. // + ////////////////////////////////////////////////////////////////////////////////// + if (values && values.length > 0) + { + values = await qController.possibleValues(fieldTable.name, null, field.name, "", values); + } + + //////////////////////////////////////////// + // log message if no values were returned // + //////////////////////////////////////////// + if (!values || values.length === 0) + { + console.warn("WARNING: No possible values were returned for [" + field.possibleValueSourceName + "] for values [" + criteria.values + "]."); + } + } + + if (values && values.length) + { + for (let i = 0; i < values.length; i++) + { + ////////////////////////////////////////////////////////////////////////// + // replace objects that look like expressions with expression instances // + ////////////////////////////////////////////////////////////////////////// + const expression = this.gridCriteriaValueToExpression(values[i]); + if (expression) + { + values[i] = expression; + } + else + { + /////////////////////////////////////////// + // make date-times work for the frontend // + /////////////////////////////////////////// + if (field.type == QFieldType.DATE_TIME) + { + values[i] = ValueUtils.formatDateTimeValueForForm(values[i]); + } + } + } + } + + criteria.values = values; + } + } + + + /******************************************************************************* + ** given a table, and a field name (which may be prefixed with an exposed-join + ** table name (from the table) - return the corresponding field-meta-data, and + ** the table that the field is from (e.g., may be a join table!) + *******************************************************************************/ + public static getField(tableMetaData: QTableMetaData, fieldName: string): [QFieldMetaData, QTableMetaData] + { + if (fieldName == null) + { + return ([null, null]); + } + + if (fieldName.indexOf(".") > -1) + { + let parts = fieldName.split(".", 2); + if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length) + { + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const joinTable = tableMetaData.exposedJoins[i].joinTable; + if (joinTable.name == parts[0]) + { + return ([joinTable.fields.get(parts[1]), joinTable]); + } + } + } + + console.log(`Failed to find join field: ${fieldName}`); + return ([null, null]); + } + else + { + return ([tableMetaData.fields.get(fieldName), tableMetaData]); + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static gridCriteriaValueToExpression(value: any) + { + if (value && value.length) + { + value = value[0]; + } + + if (value && value.type) + { + if (value.type == "NowWithOffset") + { + return (new NowWithOffsetExpression(value)); + } + else if (value.type == "Now") + { + return (new NowExpression(value)); + } + else if (value.type == "ThisOrLastPeriod") + { + return (new ThisOrLastPeriodExpression(value)); + } + } + + return (null); + } + + + /******************************************************************************* + ** edit the input filter object, replacing any values which have {id,label} attributes + ** to instead just have the id part. + *******************************************************************************/ + public static convertFilterPossibleValuesToIds(inputFilter: QQueryFilter): QQueryFilter + { + const filter = Object.assign({}, inputFilter); + + if (filter.criteria) + { + for (let i = 0; i < filter.criteria.length; i++) + { + const criteria = filter.criteria[i]; + if (criteria.values) + { + for (let j = 0; j < criteria.values.length; j++) + { + let value = criteria.values[j]; + if (value && value.id && value.label) + { + criteria.values[j] = value.id; + } + } + } + } + } + + return (filter); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static canFilterWorkAsBasic(tableMetaData: QTableMetaData, filter: QQueryFilter): { canFilterWorkAsBasic: boolean; reasonsWhyItCannot?: string[] } + { + const reasonsWhyItCannot: string[] = []; + + if(filter == null) + { + return ({canFilterWorkAsBasic: true}); + } + + if(filter.booleanOperator == "OR") + { + reasonsWhyItCannot.push("Filter uses the 'OR' operator.") + } + + if(filter.criteria) + { + const usedFields: {[name: string]: boolean} = {}; + const warnedFields: {[name: string]: boolean} = {}; + for (let i = 0; i < filter.criteria.length; i++) + { + const criteriaName = filter.criteria[i].fieldName; + if(!criteriaName) + { + continue; + } + + if(usedFields[criteriaName]) + { + if(!warnedFields[criteriaName]) + { + const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, criteriaName); + let fieldLabel = field.label; + if(tableForField.name != tableMetaData.name) + { + let fieldLabel = `${tableForField.label}: ${field.label}`; + } + reasonsWhyItCannot.push(`Filter contains more than 1 condition for the field: ${fieldLabel}`); + warnedFields[criteriaName] = true; + } + } + usedFields[criteriaName] = true; + } + } + + if(reasonsWhyItCannot.length == 0) + { + return ({canFilterWorkAsBasic: true}); + } + else + { + return ({canFilterWorkAsBasic: false, reasonsWhyItCannot: reasonsWhyItCannot}); + } + } + + /******************************************************************************* + ** get the values associated with a criteria as a string, e.g., for showing + ** in a tooltip. + *******************************************************************************/ + public static getValuesString(fieldMetaData: QFieldMetaData, criteria: QFilterCriteria, maxValuesToShow: number = 3, andMoreFormat: "andNOther" | "+N" = "andNOther"): string + { + let valuesString = ""; + + if(criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK) + { + /////////////////////////////////////////////// + // we don't want values for these operators. // + /////////////////////////////////////////////// + return valuesString; + } + + if (criteria.values && criteria.values.length) + { + let labels = [] as string[]; + + let maxLoops = criteria.values.length; + if (maxLoops > (maxValuesToShow + 2)) + { + maxLoops = maxValuesToShow; + } + else if(maxValuesToShow == 1 && criteria.values.length > 1) + { + maxLoops = 1; + } + + for (let i = 0; i < maxLoops; i++) + { + const value = criteria.values[i]; + if (value.type == "NowWithOffset") + { + const expression = new NowWithOffsetExpression(value); + labels.push(expression.toString()); + } + else if (value.type == "Now") + { + const expression = new NowExpression(value); + labels.push(expression.toString()); + } + else if (value.type == "ThisOrLastPeriod") + { + const expression = new ThisOrLastPeriodExpression(value); + labels.push(expression.toString()); + } + else if(fieldMetaData.type == QFieldType.BOOLEAN) + { + labels.push(value == true ? "yes" : "no") + } + else if(fieldMetaData.type == QFieldType.DATE_TIME) + { + labels.push(ValueUtils.formatDateTime(value)); + } + else if(fieldMetaData.type == QFieldType.DATE) + { + labels.push(ValueUtils.formatDate(value)); + } + else if (value && value.label) + { + labels.push(value.label); + } + else + { + labels.push(value); + } + } + + if (maxLoops < criteria.values.length) + { + const n = criteria.values.length - maxLoops; + switch (andMoreFormat) + { + case "andNOther": + labels.push(` and ${n} other value${n == 1 ? "" : "s"}.`); + break; + case "+N": + labels[labels.length-1] += ` +${n}`; + break; + } + } + + valuesString = (labels.join(", ")); + } + return valuesString; + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static buildQFilterFromJSONObject(object: any): QQueryFilter + { + const queryFilter = new QQueryFilter(); + + queryFilter.criteria = []; + for (let i = 0; i < object.criteria?.length; i++) + { + const criteriaObject = object.criteria[i]; + queryFilter.criteria.push(new QFilterCriteria(criteriaObject.fieldName, criteriaObject.operator, criteriaObject.values)); + } + + queryFilter.orderBys = []; + for (let i = 0; i < object.orderBys?.length; i++) + { + const orderByObject = object.orderBys[i]; + queryFilter.orderBys.push(new QFilterOrderBy(orderByObject.fieldName, orderByObject.isAscending)); + } + + queryFilter.booleanOperator = object.booleanOperator; + queryFilter.skip = object.skip; + queryFilter.limit = object.limit; + + return (queryFilter); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static getGridSortFromQueryFilter(queryFilter: QQueryFilter): GridSortModel + { + const gridSortModel: GridSortModel = []; + for (let i = 0; i < queryFilter?.orderBys?.length; i++) + { + const orderBy = queryFilter.orderBys[i]; + gridSortModel.push({field: orderBy.fieldName, sort: orderBy.isAscending ? "asc" : "desc"}) + } + return (gridSortModel); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static operatorToHumanString(criteria: QFilterCriteria, field: QFieldMetaData): string + { + if(criteria == null || criteria.operator == null) + { + return (null); + } + + const isDate = field.type == QFieldType.DATE; + const isDateTime = field.type == QFieldType.DATE_TIME; + + try + { + switch(criteria.operator) + { + case QCriteriaOperator.EQUALS: + return ("equals"); + case QCriteriaOperator.NOT_EQUALS: + case QCriteriaOperator.NOT_EQUALS_OR_IS_NULL: + return ("does not equal"); + case QCriteriaOperator.IN: + return ("is any of"); + case QCriteriaOperator.NOT_IN: + return ("is none of"); + case QCriteriaOperator.STARTS_WITH: + return ("starts with"); + case QCriteriaOperator.ENDS_WITH: + return ("ends with"); + case QCriteriaOperator.CONTAINS: + return ("contains"); + case QCriteriaOperator.NOT_STARTS_WITH: + return ("does not start with"); + case QCriteriaOperator.NOT_ENDS_WITH: + return ("does not end with"); + case QCriteriaOperator.NOT_CONTAINS: + return ("does not contain"); + case QCriteriaOperator.LESS_THAN: + if(isDate || isDateTime) + { + return ("is before") + } + return ("less than"); + case QCriteriaOperator.LESS_THAN_OR_EQUALS: + if(isDate) + { + return ("is on or before") + } + if(isDateTime) + { + return ("is at or before") + } + return ("less than or equals"); + case QCriteriaOperator.GREATER_THAN: + if(isDate || isDateTime) + { + return ("is after") + } + return ("greater than or equals"); + case QCriteriaOperator.GREATER_THAN_OR_EQUALS: + if(isDate) + { + return ("is on or after") + } + if(isDateTime) + { + return ("is at or after") + } + return ("greater than or equals"); + case QCriteriaOperator.IS_BLANK: + return ("is empty"); + case QCriteriaOperator.IS_NOT_BLANK: + return ("is not empty"); + case QCriteriaOperator.BETWEEN: + return ("is between"); + case QCriteriaOperator.NOT_BETWEEN: + return ("is not between"); + } + } + catch(e) + { + console.log(`Error getting operator human string for ${JSON.stringify(criteria)}: ${e}`); + return criteria?.operator + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public static criteriaToHumanString(table: QTableMetaData, criteria: QFilterCriteria, styled = false): string | JSX.Element + { + if(criteria == null) + { + return (null); + } + + const [field, fieldTable] = TableUtils.getFieldAndTable(table, criteria.fieldName); + const fieldLabel = TableUtils.getFieldFullLabel(table, criteria.fieldName); + const valuesString = FilterUtils.getValuesString(field, criteria); + + if(styled) + { + return ( + + {fieldLabel} + {FilterUtils.operatorToHumanString(criteria, field)} + {valuesString && {valuesString}} +   + + ) + } + else + { + return (`${fieldLabel} ${FilterUtils.operatorToHumanString(criteria, field)} ${valuesString}`); + } + } + +} + +export default FilterUtils; diff --git a/src/qqq/utils/qqq/SavedViewUtils.ts b/src/qqq/utils/qqq/SavedViewUtils.ts new file mode 100644 index 0000000..a40b962 --- /dev/null +++ b/src/qqq/utils/qqq/SavedViewUtils.ts @@ -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 . + */ + + +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; + }; + +} \ No newline at end of file diff --git a/src/qqq/utils/qqq/TableUtils.ts b/src/qqq/utils/qqq/TableUtils.ts index dd0f6eb..b1b08f3 100644 --- a/src/qqq/utils/qqq/TableUtils.ts +++ b/src/qqq/utils/qqq/TableUtils.ts @@ -113,6 +113,31 @@ class TableUtils return (null); } + + /******************************************************************************* + ** for a field that might be from a join table, get its label - either the field's + ** label, if it's from "this" table - or the table's label: field's label, if it's + ** from a join table. + *******************************************************************************/ + public static getFieldFullLabel(tableMetaData: QTableMetaData, fieldName: string): string + { + try + { + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + if (fieldTable.name == tableMetaData.name) + { + return (field.label); + } + return `${fieldTable.label}: ${field.label}`; + } + catch (e) + { + console.log(`Error getting full field label for ${fieldName} in table ${tableMetaData?.name}: ${e}`); + return fieldName + } + } + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 909ec04..e786336 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -462,6 +462,19 @@ class ValueUtils return (String(param).replaceAll(/"/g, "\"\"")); } + + /******************************************************************************* + ** + *******************************************************************************/ + public static safeToLocaleString(n: Number): string + { + if (n != null && n != undefined) + { + return (n.toLocaleString()); + } + return (""); + } + } //////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/BaseTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/BaseTest.java new file mode 100644 index 0000000..0b0b0bd --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/BaseTest.java @@ -0,0 +1,75 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.junit; + + +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class BaseTest +{ + private static final QLogger LOG = QLogger.getLogger(BaseTest.class); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void baseBeforeEach() + { + QContext.init(TestUtils.defineInstance(), new QSession()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void baseAfterEach() + { + QContext.clear(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + protected static void reInitInstanceInContext(QInstance qInstance) + { + if(qInstance.equals(QContext.getQInstance())) + { + LOG.warn("Unexpected condition - the same qInstance that is already in the QContext was passed into reInit. You probably want a new QInstance object instance."); + } + QContext.init(qInstance, new QSession()); + } +} diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/TestUtils.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/TestUtils.java new file mode 100644 index 0000000..4f4d485 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/junit/TestUtils.java @@ -0,0 +1,101 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.junit; + + +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class TestUtils +{ + public static final String DEFAULT_BACKEND_NAME = "memoryBackend"; + public static final String TABLE_NAME_PERSON = "person"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static QInstance defineInstance() + { + QInstance qInstance = new QInstance(); + qInstance.addBackend(defineBackend()); + qInstance.addTable(defineTablePerson()); + qInstance.setAuthentication(defineAuthentication()); + return (qInstance); + } + + + + /******************************************************************************* + ** Define the authentication used in standard tests - using 'mock' type. + ** + *******************************************************************************/ + public static QAuthenticationMetaData defineAuthentication() + { + return new QAuthenticationMetaData() + .withName("mock") + .withType(QAuthenticationType.MOCK); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static QBackendMetaData defineBackend() + { + return (new QBackendMetaData() + .withName(DEFAULT_BACKEND_NAME) + .withBackendType("memory")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QTableMetaData defineTablePerson() + { + return new QTableMetaData() + .withName(TABLE_NAME_PERSON) + .withLabel("Person") + .withBackendName(DEFAULT_BACKEND_NAME) + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false)) + .withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)) + .withField(new QFieldMetaData("firstName", QFieldType.STRING)) + .withField(new QFieldMetaData("lastName", QFieldType.STRING)) + .withField(new QFieldMetaData("birthDate", QFieldType.DATE)) + .withField(new QFieldMetaData("email", QFieldType.STRING)); + } +} diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaDataTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaDataTest.java new file mode 100644 index 0000000..63c7521 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaDataTest.java @@ -0,0 +1,178 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.model.metadata; + + +import java.util.List; +import java.util.function.Consumer; +import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.frontend.materialdashboard.junit.BaseTest; +import com.kingsrook.qqq.frontend.materialdashboard.junit.TestUtils; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + + +/******************************************************************************* + ** Unit test for MaterialDashboardTableMetaData + *******************************************************************************/ +class MaterialDashboardTableMetaDataTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testValidateGoToFieldNames() + { + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of()))), + "empty gotoFieldNames list"); + + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of("foo")))), + "unrecognized field name: foo"); + + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of("foo"), List.of("bar", "baz")))), + "unrecognized field name: foo", + "unrecognized field name: bar", + "unrecognized field name: baz"); + + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withGotoFieldNames(List.of(List.of("firstName", "firstName")))), + "duplicated field name: firstName"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testValidateQuickFilterFieldNames() + { + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withDefaultQuickFilterFieldNames(List.of("foo"))), + "unrecognized field name: foo"); + + assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withDefaultQuickFilterFieldNames(List.of("firstName", "lastName", "firstName"))), + "duplicated field name: firstName"); + + } + + ////////////////////////////////////////////////////////////////////////// + // todo - methods below here were copied from QInstanceValidatorTest... // + // how to share those... // + ////////////////////////////////////////////////////////////////////////// + + + + /******************************************************************************* + ** Run a little setup code on a qInstance; then validate it, and assert that it + ** failed validation with reasons that match the supplied vararg-reasons (but allow + ** more reasons - e.g., helpful when one thing we're testing causes other errors). + *******************************************************************************/ + private void assertValidationFailureReasonsAllowingExtraReasons(Consumer setup, String... reasons) + { + assertValidationFailureReasons(setup, true, reasons); + } + + + + /******************************************************************************* + ** Run a little setup code on a qInstance; then validate it, and assert that it + ** failed validation with reasons that match the supplied vararg-reasons (and + ** require that exact # of reasons). + *******************************************************************************/ + private void assertValidationFailureReasons(Consumer setup, String... reasons) + { + assertValidationFailureReasons(setup, false, reasons); + } + + + + /******************************************************************************* + ** Implementation for the overloads of this name. + *******************************************************************************/ + private void assertValidationFailureReasons(Consumer setup, boolean allowExtraReasons, String... reasons) + { + try + { + QInstance qInstance = TestUtils.defineInstance(); + setup.accept(qInstance); + new QInstanceValidator().validate(qInstance); + fail("Should have thrown validationException"); + } + catch(QInstanceValidationException e) + { + if(!allowExtraReasons) + { + int noOfReasons = e.getReasons() == null ? 0 : e.getReasons().size(); + assertEquals(reasons.length, noOfReasons, "Expected number of validation failure reasons.\nExpected reasons: " + String.join(",", reasons) + + "\nActual reasons: " + (noOfReasons > 0 ? String.join("\n", e.getReasons()) : "--")); + } + + for(String reason : reasons) + { + assertReason(reason, e); + } + } + } + + + + /******************************************************************************* + ** Assert that an instance is valid! + *******************************************************************************/ + private void assertValidationSuccess(Consumer setup) + { + try + { + QInstance qInstance = TestUtils.defineInstance(); + setup.accept(qInstance); + new QInstanceValidator().validate(qInstance); + } + catch(QInstanceValidationException e) + { + fail("Expected no validation errors, but received: " + e.getMessage()); + } + } + + + + /******************************************************************************* + ** utility method for asserting that a specific reason string is found within + ** the list of reasons in the QInstanceValidationException. + ** + *******************************************************************************/ + private void assertReason(String reason, QInstanceValidationException e) + { + assertNotNull(e.getReasons(), "Expected there to be a reason for the failure (but there was not)"); + assertThat(e.getReasons()) + .withFailMessage("Expected any of:\n%s\nTo match: [%s]", e.getReasons(), reason) + .anyMatch(s -> s.contains(reason)); + } + + ///////////////////////////////////////////////////////////////// + // todo - end of methods copied from QInstanceValidatorTest... // + ///////////////////////////////////////////////////////////////// +} \ No newline at end of file diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QBaseSeleniumTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QBaseSeleniumTest.java similarity index 83% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/QBaseSeleniumTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QBaseSeleniumTest.java index bb88fc1..57ed9c1 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QBaseSeleniumTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QBaseSeleniumTest.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib; import java.io.File; @@ -6,7 +27,7 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import io.github.bonigarcia.wdm.WebDriverManager; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -156,7 +177,7 @@ public class QBaseSeleniumTest .withRouteToFile("/metaData/table/city", "metaData/table/person.json") .withRouteToFile("/metaData/table/script", "metaData/table/script.json") .withRouteToFile("/metaData/table/scriptRevision", "metaData/table/scriptRevision.json") - .withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json"); + .withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json"); } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QQQMaterialDashboardSelectors.java similarity index 90% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QQQMaterialDashboardSelectors.java index 91254ab..559bce2 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QQQMaterialDashboardSelectors.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QQQMaterialDashboardSelectors.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2023. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,7 +19,7 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.lib; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib; /******************************************************************************* @@ -28,7 +28,7 @@ package com.kingsrook.qqq.materialdashboard.lib; public interface QQQMaterialDashboardSelectors { String SIDEBAR_ITEM = ".MuiDrawer-paperAnchorDockedLeft li.MuiListItem-root"; - String BREADCRUMB_HEADER = ".MuiToolbar-root h3"; + String BREADCRUMB_HEADER = "h3"; String QUERY_GRID_CELL = ".MuiDataGrid-root .MuiDataGrid-cellContent"; String QUERY_FILTER_INPUT = ".customFilterPanel input.MuiInput-input"; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java similarity index 93% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java index ebd239f..afdda0b 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/QSeleniumLib.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QSeleniumLib.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2023. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,7 +19,7 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.lib; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib; import java.io.File; @@ -43,7 +43,7 @@ import org.openqa.selenium.WebElement; import org.openqa.selenium.interactions.Actions; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.fail; @@ -63,6 +63,11 @@ public class QSeleniumLib private boolean autoHighlight = false; + ////////////////////////////////////////////////////////////////////////////////////// + // useful to use on a WebElement, in a call like: .findElement(QSeleniumLib.PARENT) // + ////////////////////////////////////////////////////////////////////////////////////// + public static final By PARENT = By.xpath("./.."); + /******************************************************************************* @@ -196,7 +201,7 @@ public class QSeleniumLib /******************************************************************************* ** *******************************************************************************/ - public void gotoAndWaitForBreadcrumbHeader(String path, String headerText) + public void gotoAndWaitForBreadcrumbHeaderToContain(String path, String expectedHeaderText) { driver.get(BASE_URL + path); @@ -204,7 +209,27 @@ public class QSeleniumLib .until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER))); LOG.debug("Navigated to [" + path + "]. Breadcrumb Header: " + header.getText()); - assertEquals(headerText, header.getText()); + assertThat(header.getText()).contains(expectedHeaderText); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void clickBackdrop() + { + for(WebElement webElement : this.waitForSelectorAll(".MuiBackdrop-root", 0)) + { + try + { + webElement.click(); + } + catch(Exception e) + { + // ignore. + } + } } @@ -225,6 +250,18 @@ public class QSeleniumLib + /******************************************************************************* + ** + *******************************************************************************/ + public void moveMouseCursorToElement(WebElement element) + { + Actions actions = new Actions(driver); + actions.moveToElement(element); + actions.perform(); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QueryScreenLib.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QueryScreenLib.java new file mode 100644 index 0000000..c69b883 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/QueryScreenLib.java @@ -0,0 +1,230 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib; + + +import org.openqa.selenium.By; +import org.openqa.selenium.Keys; +import org.openqa.selenium.WebElement; + + +/******************************************************************************* + ** + *******************************************************************************/ +public class QueryScreenLib +{ + private final QSeleniumLib qSeleniumLib; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public QueryScreenLib(QSeleniumLib qSeleniumLib) + { + this.qSeleniumLib = qSeleniumLib; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public WebElement assertFilterButtonBadge(int valueInBadge) + { + return qSeleniumLib.waitForSelectorContaining(".filterBuilderCountBadge", String.valueOf(valueInBadge)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void clickAdvancedFilterClearIcon() + { + qSeleniumLib.moveMouseCursorToElement(qSeleniumLib.waitForSelector(".filterBuilderButton")); + qSeleniumLib.waitForSelector(".filterBuilderXIcon BUTTON").click(); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Yes").click(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void clickQuickFilterClearIcon(String fieldName) + { + qSeleniumLib.moveMouseCursorToElement(qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName)); + qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName + "+span button").click(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void assertNoFilterButtonBadge(int valueInBadge) + { + qSeleniumLib.waitForSelectorContainingToNotExist(".filterBuilderCountBadge", String.valueOf(valueInBadge)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public WebElement waitForQueryToHaveRan() + { + return qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void clickFilterButton() + { + qSeleniumLib.waitForSelectorContaining("BUTTON", "FILTER BUILDER").click(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public WebElement assertQuickFilterButtonIndicatesActiveFilter(String fieldName) + { + return qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName + ".filterActive"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void assertQuickFilterButtonDoesNotIndicateActiveFilter(String fieldName) + { + qSeleniumLib.waitForSelectorToNotExist("#quickFilter\\." + fieldName + ".filterActive"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void clickQuickFilterButton(String fieldName) + { + qSeleniumLib.waitForSelector("#quickFilter\\." + fieldName).click(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void gotoAdvancedMode() + { + qSeleniumLib.waitForSelectorContaining("BUTTON", "ADVANCED").click(); + qSeleniumLib.waitForSelectorContaining("BUTTON", "FILTER BUILDER"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void gotoBasicMode() + { + qSeleniumLib.waitForSelectorContaining("BUTTON", "BASIC").click(); + qSeleniumLib.waitForSelectorContaining("BUTTON", "ADD FILTER"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public void assertSavedViewNameOnScreen(String savedViewName) + { + qSeleniumLib.waitForSelectorContaining("H3", savedViewName); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public WebElement waitForDataGridCellContaining(String containingText) + { + return qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", containingText); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public void addAdvancedQueryFilterInput(QSeleniumLib qSeleniumLib, int index, String fieldLabel, String operator, String value, String booleanOperator) + { + if(index > 0) + { + qSeleniumLib.waitForSelectorContaining("BUTTON", "Add condition").click(); + } + + WebElement subFormForField = qSeleniumLib.waitForSelectorAll(".filterCriteriaRow", index + 1).get(index); + + if(index == 1) + { + WebElement booleanOperatorInput = subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); + booleanOperatorInput.click(); + qSeleniumLib.waitForMillis(100); + + subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); + qSeleniumLib.waitForSelectorContaining("li", booleanOperator).click(); + qSeleniumLib.waitForMillis(100); + } + + WebElement fieldInput = subFormForField.findElement(By.cssSelector(".fieldColumn INPUT")); + fieldInput.click(); + qSeleniumLib.waitForMillis(100); + fieldInput.clear(); + fieldInput.sendKeys(fieldLabel); + qSeleniumLib.waitForMillis(100); + fieldInput.sendKeys("\n"); + qSeleniumLib.waitForMillis(100); + + WebElement operatorInput = subFormForField.findElement(By.cssSelector(".operatorColumn INPUT")); + operatorInput.click(); + qSeleniumLib.waitForMillis(100); + operatorInput.sendKeys(Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, operator); + qSeleniumLib.waitForMillis(100); + operatorInput.sendKeys("\n"); + qSeleniumLib.waitForMillis(100); + + WebElement valueInput = subFormForField.findElement(By.cssSelector(".filterValuesColumn INPUT")); + valueInput.click(); + valueInput.sendKeys(value); + qSeleniumLib.waitForMillis(100); + } + +} diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturedContext.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturedContext.java similarity index 70% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturedContext.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturedContext.java index 4a8836d..dd091ea 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturedContext.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturedContext.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib.javalin; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin; import io.javalin.http.Context; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturingHandler.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturingHandler.java similarity index 59% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturingHandler.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturingHandler.java index ea0f555..4cff650 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/CapturingHandler.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/CapturingHandler.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib.javalin; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin; import io.javalin.http.Context; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/QSeleniumJavalin.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/QSeleniumJavalin.java similarity index 90% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/QSeleniumJavalin.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/QSeleniumJavalin.java index 455b34b..864b2db 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/QSeleniumJavalin.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/QSeleniumJavalin.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib.javalin; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin; import java.util.ArrayList; @@ -6,7 +27,7 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import com.kingsrook.qqq.materialdashboard.lib.QSeleniumLib; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib; import io.javalin.Javalin; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromFileHandler.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromFileHandler.java similarity index 65% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromFileHandler.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromFileHandler.java index 3861623..c3e1325 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromFileHandler.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromFileHandler.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib.javalin; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin; import java.nio.charset.StandardCharsets; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromStringHandler.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromStringHandler.java similarity index 60% rename from src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromStringHandler.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromStringHandler.java index 9de15a8..36db7e4 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/lib/javalin/RouteFromStringHandler.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/lib/javalin/RouteFromStringHandler.java @@ -1,4 +1,25 @@ -package com.kingsrook.qqq.materialdashboard.lib.javalin; +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin; import io.javalin.http.Context; diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AppPageNavTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AppPageNavTest.java similarity index 82% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/AppPageNavTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AppPageNavTest.java index 1a3c6c3..9d62a96 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AppPageNavTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AppPageNavTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,12 +19,12 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.QQQMaterialDashboardSelectors; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; @@ -57,7 +57,7 @@ public class AppPageNavTest extends QBaseSeleniumTest @Test void testHomeToAppPageViaLeftNav() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/", "Greetings App"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/", "Greetings App"); qSeleniumLib.waitForSelectorContaining(QQQMaterialDashboardSelectors.SIDEBAR_ITEM, "People App").click(); qSeleniumLib.waitForSelectorContaining(QQQMaterialDashboardSelectors.SIDEBAR_ITEM, "Greetings App").click(); } @@ -70,7 +70,7 @@ public class AppPageNavTest extends QBaseSeleniumTest @Test void testAppPageToTablePage() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp", "Greetings App"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp", "Greetings App"); qSeleniumLib.tryMultiple(3, () -> qSeleniumLib.waitForSelectorContaining("a", "Person").click()); qSeleniumLib.waitForSelectorContaining(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER, "Person"); } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AssociatedRecordScriptTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java similarity index 83% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/AssociatedRecordScriptTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java index 737d32a..89c7085 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AssociatedRecordScriptTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AssociatedRecordScriptTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,11 +19,11 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -53,13 +53,13 @@ public class AssociatedRecordScriptTest extends QBaseSeleniumTest @Test void testNavigatingBackAndForth() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1", "John Doe"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1", "John Doe"); qSeleniumLib.waitForSelectorContaining("BUTTON", "actions").click(); qSeleniumLib.waitForSelectorContaining("LI", "Developer Mode").click(); assertTrue(qSeleniumLib.driver.getCurrentUrl().endsWith("/1/dev")); - qSeleniumLib.waitForever(); + // qSeleniumLib.waitForever(); } } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AuditTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AuditTest.java similarity index 90% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/AuditTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AuditTest.java index 907d34b..4aff845 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/AuditTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/AuditTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,13 +19,13 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; import java.util.List; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import org.openqa.selenium.WebElement; import static org.assertj.core.api.Assertions.assertThat; @@ -63,7 +63,7 @@ public class AuditTest extends QBaseSeleniumTest qSeleniumJavalin.withRouteToFile("/data/audit/query", "data/audit/query-empty.json"); qSeleniumJavalin.restart(); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1701", "John Doe"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1701", "John Doe"); qSeleniumLib.waitForSelectorContaining("BUTTON", "Actions").click(); qSeleniumLib.waitForSelectorContaining("LI", "Audit").click(); @@ -90,7 +90,7 @@ public class AuditTest extends QBaseSeleniumTest qSeleniumJavalin.withRouteToFile(auditQueryPath, "data/audit/query.json"); qSeleniumJavalin.restart(); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1701", "John Doe"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1701", "John Doe"); qSeleniumLib.waitForSelectorContaining("BUTTON", "Actions").click(); qSeleniumLib.waitForSelectorContaining("LI", "Audit").click(); @@ -121,7 +121,7 @@ public class AuditTest extends QBaseSeleniumTest qSeleniumJavalin.withRouteToFile(auditQueryPath, "data/audit/query.json"); qSeleniumJavalin.restart(); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person/1701", "John Doe"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person/1701", "John Doe"); qSeleniumLib.waitForSelectorContaining("BUTTON", "Actions").click(); qSeleniumLib.waitForSelectorContaining("LI", "Audit").click(); diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java similarity index 92% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java index cc47c27..e0a49ac 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/BulkEditTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,11 +19,11 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; @@ -71,7 +71,7 @@ public class BulkEditTest extends QBaseSeleniumTest // @RepeatedTest(100) void test() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person"); qSeleniumLib.waitForSelectorContaining("button", "selection").click(); qSeleniumLib.waitForSelectorContaining("li", "This page").click(); qSeleniumLib.waitForSelectorContaining("div", "records on this page are selected"); diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/ClickLinkOnRecordThenEditShortcutTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ClickLinkOnRecordThenEditShortcutTest.java similarity index 86% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/ClickLinkOnRecordThenEditShortcutTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ClickLinkOnRecordThenEditShortcutTest.java index 741b6ef..28f11c6 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/ClickLinkOnRecordThenEditShortcutTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ClickLinkOnRecordThenEditShortcutTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,11 +19,11 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -55,7 +55,7 @@ public class ClickLinkOnRecordThenEditShortcutTest extends QBaseSeleniumTest @Test void testClickLinkOnRecordThenEditShortcutTest() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/developer/script/1", "Hello, Script"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/developer/script/1", "Hello, Script"); qSeleniumLib.waitForSelectorContaining("A", "100").click(); qSeleniumLib.waitForSelectorContaining("BUTTON", "actions").sendKeys("e"); diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/DashboardTableWidgetExportTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java similarity index 89% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/DashboardTableWidgetExportTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java index 4959bd8..23ad722 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/DashboardTableWidgetExportTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/DashboardTableWidgetExportTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,14 +19,15 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; @@ -76,7 +77,7 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest @Test void testDashboardTableWidgetExport() throws IOException { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/", "Greetings App"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/", "Greetings App"); //////////////////////////////////////////////////////////////////////// // assert that the table widget rendered its header and some contents // @@ -89,7 +90,7 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest // click the export button // ///////////////////////////// qSeleniumLib.waitForSelector("#SampleTableWidget h6") - .findElement(By.xpath("./..")) + .findElement(QSeleniumLib.PARENT) .findElement(By.cssSelector("button")) .click(); @@ -104,7 +105,7 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest "3","Bart J." """, fileContents); - qSeleniumLib.waitForever(); + // qSeleniumLib.waitForever(); } } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/ScriptTableTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ScriptTableTest.java similarity index 86% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/ScriptTableTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ScriptTableTest.java index c6d1e2b..39f7062 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/ScriptTableTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/ScriptTableTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,11 +19,11 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; @@ -59,7 +59,7 @@ public class ScriptTableTest extends QBaseSeleniumTest @Test void test() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/developer/script/1", "Hello, Script"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/developer/script/1", "Hello, Script"); qSeleniumLib.waitForSelectorContaining("DIV.ace_line", "var hello;"); qSeleniumLib.waitForSelectorContaining("DIV", "2nd commit"); diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/QueryScreenFilterInUrlAdvancedModeTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/QueryScreenFilterInUrlAdvancedModeTest.java new file mode 100755 index 0000000..0c42219 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/QueryScreenFilterInUrlAdvancedModeTest.java @@ -0,0 +1,181 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query; + + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.temporal.ChronoUnit; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.ThisOrLastPeriod; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; +import org.junit.jupiter.api.Test; + + +/******************************************************************************* + ** Test for the record query screen when a filter is given in the URL + *******************************************************************************/ +public class QueryScreenFilterInUrlAdvancedModeTest extends QBaseSeleniumTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin) + { + super.addJavalinRoutes(qSeleniumJavalin); + qSeleniumJavalin + .withRouteToFile("/data/person/count", "data/person/count.json") + .withRouteToFile("/data/person/query", "data/person/index.json") + .withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json") + .withRouteToFile("/data/person/variants", "data/person/variants.json") + .withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testUrlWithFilter() + { + QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); + + //////////////////////////////// + // put table in advanced mode // + //////////////////////////////// + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person"); + queryScreenLib.gotoAdvancedMode(); + + //////////////////////////////////////// + // not-blank -- criteria w/ no values // + //////////////////////////////////////// + String filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(1); + queryScreenLib.clickFilterButton(); + qSeleniumLib.waitForSelector("input[value=\"is not empty\"]"); + + /////////////////////////////// + // between on a number field // + /////////////////////////////// + filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(1); + queryScreenLib.clickFilterButton(); + qSeleniumLib.waitForSelector("input[value=\"is between\"]"); + qSeleniumLib.waitForSelector("input[value=\"1701\"]"); + qSeleniumLib.waitForSelector("input[value=\"74656\"]"); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // click the x to remove a condition from the filter (in the on-screen preview) // + // reload the page first, so filter-panel won't be up (clicking backdrop doesn't seem to be closing it like it should...) // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.highlightElement(qSeleniumLib.waitForSelectorContaining(".advancedQueryString DIV DIV", "1701")); + qSeleniumLib.moveMouseCursorToElement(qSeleniumLib.waitForSelectorContaining(".advancedQueryString DIV DIV", "1701")); + qSeleniumLib.waitForSelector(".advancedQueryPreviewX-0 button").click(); + queryScreenLib.assertNoFilterButtonBadge(1); + + ////////////////////////////////////// + // an IN for a possible-value field // + ////////////////////////////////////// + filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.IN, 1, 2))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(1); + queryScreenLib.clickFilterButton(); + qSeleniumLib.waitForSelector("input[value=\"is any of\"]"); + qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "St. Louis"); + qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "Chesterfield"); + + ///////////////////////////////////////// + // greater than a date-time expression // + ///////////////////////////////////////// + filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS)))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(1); + queryScreenLib.clickFilterButton(); + qSeleniumLib.waitForSelector("input[value=\"is after\"]"); + qSeleniumLib.waitForSelector("input[value=\"5 days ago\"]"); + + /////////////////////// + // multiple criteria // + /////////////////////// + filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("firstName", QCriteriaOperator.STARTS_WITH, "Dar")) + .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, ThisOrLastPeriod.this_(ChronoUnit.YEARS)))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(2); + queryScreenLib.clickFilterButton(); + qSeleniumLib.waitForSelector("input[value=\"is at or before\"]"); + qSeleniumLib.waitForSelector("input[value=\"start of this year\"]"); + qSeleniumLib.waitForSelector("input[value=\"starts with\"]"); + qSeleniumLib.waitForSelector("input[value=\"Dar\"]"); + + ///////////////////////////////////////////////// + // replace the homeCityId possible-value route // + ///////////////////////////////////////////////// + qSeleniumJavalin.stop(); + qSeleniumJavalin.clearRoutes(); + addJavalinRoutes(qSeleniumJavalin); + qSeleniumJavalin.withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId=1.json"); + qSeleniumJavalin.restart(); + + ////////////////////////////////////////// + // not-equals on a possible-value field // + ////////////////////////////////////////// + filterJSON = JsonUtils.toJson(new QQueryFilter() + .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1))); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertFilterButtonBadge(1); + queryScreenLib.clickFilterButton(); + qSeleniumLib.waitForSelector("input[value=\"does not equal\"]"); + qSeleniumLib.waitForSelector("input[value=\"St. Louis\"]"); + + //////////////// + // remove one // + //////////////// + queryScreenLib.clickAdvancedFilterClearIcon(); + queryScreenLib.assertNoFilterButtonBadge(1); + + // qSeleniumLib.waitForever(); + } + +} diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenFilterInUrlTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/QueryScreenFilterInUrlBasicModeTest.java similarity index 61% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenFilterInUrlTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/QueryScreenFilterInUrlBasicModeTest.java index 77e8b0e..acd6ed2 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenFilterInUrlTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/QueryScreenFilterInUrlBasicModeTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,7 +19,7 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query; import java.net.URLEncoder; @@ -31,17 +31,16 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset; import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.ThisOrLastPeriod; import com.kingsrook.qqq.backend.core.utils.JsonUtils; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.QQQMaterialDashboardSelectors; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; -import org.openqa.selenium.WebElement; /******************************************************************************* ** Test for the record query screen when a filter is given in the URL *******************************************************************************/ -public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest +public class QueryScreenFilterInUrlBasicModeTest extends QBaseSeleniumTest { /******************************************************************************* @@ -56,7 +55,7 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest .withRouteToFile("/data/person/query", "data/person/index.json") .withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json") .withRouteToFile("/data/person/variants", "data/person/variants.json") - .withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json"); + .withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json"); } @@ -67,15 +66,17 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest @Test void testUrlWithFilter() { + QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); + //////////////////////////////////////// // not-blank -- criteria w/ no values // //////////////////////////////////////// String filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(1); - clickFilterButton(); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("annualSalary"); + queryScreenLib.clickQuickFilterButton("annualSalary"); qSeleniumLib.waitForSelector("input[value=\"is not empty\"]"); /////////////////////////////// @@ -83,10 +84,10 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest /////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(1); - clickFilterButton(); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("annualSalary"); + queryScreenLib.clickQuickFilterButton("annualSalary"); qSeleniumLib.waitForSelector("input[value=\"is between\"]"); qSeleniumLib.waitForSelector("input[value=\"1701\"]"); qSeleniumLib.waitForSelector("input[value=\"74656\"]"); @@ -96,10 +97,10 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest ////////////////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(1); - clickFilterButton(); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("homeCityId"); + queryScreenLib.clickQuickFilterButton("homeCityId"); qSeleniumLib.waitForSelector("input[value=\"does not equal\"]"); qSeleniumLib.waitForSelector("input[value=\"St. Louis\"]"); @@ -108,10 +109,10 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest ////////////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.IN, 1, 2))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(1); - clickFilterButton(); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("homeCityId"); + queryScreenLib.clickQuickFilterButton("homeCityId"); qSeleniumLib.waitForSelector("input[value=\"is any of\"]"); qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "St. Louis"); qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "Chesterfield"); @@ -121,10 +122,10 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest ///////////////////////////////////////// filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS)))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(1); - clickFilterButton(); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("createDate"); + queryScreenLib.clickQuickFilterButton("createDate"); qSeleniumLib.waitForSelector("input[value=\"is after\"]"); qSeleniumLib.waitForSelector("input[value=\"5 days ago\"]"); @@ -134,52 +135,29 @@ public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest filterJSON = JsonUtils.toJson(new QQueryFilter() .withCriteria(new QFilterCriteria("firstName", QCriteriaOperator.STARTS_WITH, "Dar")) .withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, ThisOrLastPeriod.this_(ChronoUnit.YEARS)))); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); - waitForQueryToHaveRan(); - assertFilterButtonBadge(2); - clickFilterButton(); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("firstName"); + queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("createDate"); + queryScreenLib.clickQuickFilterButton("createDate"); qSeleniumLib.waitForSelector("input[value=\"is at or before\"]"); qSeleniumLib.waitForSelector("input[value=\"start of this year\"]"); + qSeleniumLib.clickBackdrop(); + queryScreenLib.clickQuickFilterButton("firstName"); qSeleniumLib.waitForSelector("input[value=\"starts with\"]"); qSeleniumLib.waitForSelector("input[value=\"Dar\"]"); - //////////////// - // remove one // - //////////////// - qSeleniumLib.waitForSelectorContaining(".MuiIcon-root", "close").click(); - assertFilterButtonBadge(1); + //////////////////////////////// + // remove one, then the other // + //////////////////////////////// + qSeleniumLib.clickBackdrop(); + queryScreenLib.clickQuickFilterClearIcon("createDate"); + queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("firstName"); + queryScreenLib.assertQuickFilterButtonDoesNotIndicateActiveFilter("createDate"); + queryScreenLib.clickQuickFilterClearIcon("firstName"); + queryScreenLib.assertQuickFilterButtonDoesNotIndicateActiveFilter("firstName"); - qSeleniumLib.waitForever(); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private WebElement assertFilterButtonBadge(int valueInBadge) - { - return qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", String.valueOf(valueInBadge)); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private WebElement waitForQueryToHaveRan() - { - return qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private void clickFilterButton() - { - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); + // qSeleniumLib.waitForever(); } } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/QueryScreenTest.java similarity index 51% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/QueryScreenTest.java index 2bc5fe0..9a458f0 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/QueryScreenTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,18 +19,15 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.QQQMaterialDashboardSelectors; -import com.kingsrook.qqq.materialdashboard.lib.QSeleniumLib; -import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; -import org.openqa.selenium.By; -import org.openqa.selenium.Keys; -import org.openqa.selenium.WebElement; import static org.assertj.core.api.Assertions.assertThat; @@ -51,7 +48,7 @@ public class QueryScreenTest extends QBaseSeleniumTest .withRouteToFile("/data/person/count", "data/person/count.json") .withRouteToFile("/data/person/query", "data/person/index.json") .withRouteToFile("/data/person/variants", "data/person/variants.json") - .withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json"); + .withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init.json"); } @@ -60,32 +57,28 @@ public class QueryScreenTest extends QBaseSeleniumTest ** *******************************************************************************/ @Test - void testBasicQueryAndClearFilters() + void testBuildQueryQueryAndClearFilters() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); - qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL); - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); + QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); + + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.gotoAdvancedMode(); + queryScreenLib.clickFilterButton(); ///////////////////////////////////////////////////////////////////// // open the filter window, enter a value, wait for query to re-run // ///////////////////////////////////////////////////////////////////// - WebElement filterInput = qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_FILTER_INPUT); - qSeleniumLib.waitForElementToHaveFocus(filterInput); - filterInput.sendKeys("id"); - filterInput.sendKeys("\t"); - driver.switchTo().activeElement().sendKeys("\t"); qSeleniumJavalin.beginCapture(); - driver.switchTo().activeElement().sendKeys("1"); + queryScreenLib.addAdvancedQueryFilterInput(qSeleniumLib, 0, "Id", "equals", "1", null); /////////////////////////////////////////////////////////////////// // assert that query & count both have the expected filter value // /////////////////////////////////////////////////////////////////// String idEquals1FilterSubstring = """ {"fieldName":"id","operator":"EQUALS","values":["1"]}"""; - CapturedContext capturedCount = qSeleniumJavalin.waitForCapturedPath("/data/person/count"); - CapturedContext capturedQuery = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); - assertThat(capturedCount).extracting("body").asString().contains(idEquals1FilterSubstring); - assertThat(capturedQuery).extracting("body").asString().contains(idEquals1FilterSubstring); + qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/count", idEquals1FilterSubstring); + qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", idEquals1FilterSubstring); qSeleniumJavalin.endCapture(); /////////////////////////////////////// @@ -93,20 +86,19 @@ public class QueryScreenTest extends QBaseSeleniumTest /////////////////////////////////////// qSeleniumLib.waitForSeconds(1); // todo grr. qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.BREADCRUMB_HEADER).click(); - qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", "1"); + queryScreenLib.assertFilterButtonBadge(1); /////////////////////////////////////////////////////////////////// // click the 'x' clear icon, then yes, then expect another query // /////////////////////////////////////////////////////////////////// qSeleniumJavalin.beginCapture(); - qSeleniumLib.tryMultiple(3, () -> qSeleniumLib.waitForSelector("#clearFiltersButton").click()); - qSeleniumLib.waitForSelectorContaining("BUTTON", "Yes").click(); + queryScreenLib.clickAdvancedFilterClearIcon(); //////////////////////////////////////////////////////////////////// // assert that query & count both no longer have the filter value // //////////////////////////////////////////////////////////////////// - capturedCount = qSeleniumJavalin.waitForCapturedPath("/data/person/count"); - capturedQuery = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); + CapturedContext capturedCount = qSeleniumJavalin.waitForCapturedPath("/data/person/count"); + CapturedContext capturedQuery = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); assertThat(capturedCount).extracting("body").asString().doesNotContain(idEquals1FilterSubstring); assertThat(capturedQuery).extracting("body").asString().doesNotContain(idEquals1FilterSubstring); qSeleniumJavalin.endCapture(); @@ -120,13 +112,16 @@ public class QueryScreenTest extends QBaseSeleniumTest @Test void testMultiCriteriaQueryWithOr() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); - qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL); - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); + QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); + + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person"); + queryScreenLib.waitForQueryToHaveRan(); + queryScreenLib.gotoAdvancedMode(); + queryScreenLib.clickFilterButton(); qSeleniumJavalin.beginCapture(); - addQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or"); - addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); + queryScreenLib.addAdvancedQueryFilterInput(qSeleniumLib, 0, "First Name", "contains", "Dar", "Or"); + queryScreenLib.addAdvancedQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); String expectedFilterContents0 = """ {"fieldName":"firstName","operator":"CONTAINS","values":["Dar"]}"""; @@ -142,51 +137,6 @@ public class QueryScreenTest extends QBaseSeleniumTest } - - /******************************************************************************* - ** - *******************************************************************************/ - static void addQueryFilterInput(QSeleniumLib qSeleniumLib, int index, String fieldLabel, String operator, String value, String booleanOperator) - { - if(index > 0) - { - qSeleniumLib.waitForSelectorContaining("BUTTON", "Add condition").click(); - } - - WebElement subFormForField = qSeleniumLib.waitForSelectorAll(".filterCriteriaRow", index + 1).get(index); - - if(index == 1) - { - WebElement booleanOperatorInput = subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); - booleanOperatorInput.click(); - qSeleniumLib.waitForMillis(100); - - subFormForField.findElement(By.cssSelector(".booleanOperatorColumn .MuiInput-input")); - qSeleniumLib.waitForSelectorContaining("li", booleanOperator).click(); - qSeleniumLib.waitForMillis(100); - } - - WebElement fieldInput = subFormForField.findElement(By.cssSelector(".fieldColumn INPUT")); - fieldInput.click(); - qSeleniumLib.waitForMillis(100); - fieldInput.clear(); - fieldInput.sendKeys(fieldLabel); - qSeleniumLib.waitForMillis(100); - fieldInput.sendKeys("\n"); - qSeleniumLib.waitForMillis(100); - - WebElement operatorInput = subFormForField.findElement(By.cssSelector(".operatorColumn INPUT")); - operatorInput.click(); - qSeleniumLib.waitForMillis(100); - operatorInput.sendKeys(Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, Keys.BACK_SPACE, operator); - qSeleniumLib.waitForMillis(100); - operatorInput.sendKeys("\n"); - qSeleniumLib.waitForMillis(100); - - WebElement valueInput = subFormForField.findElement(By.cssSelector(".filterValuesColumn INPUT")); - valueInput.click(); - valueInput.sendKeys(value); - qSeleniumLib.waitForMillis(100); - } + // todo - table requires variant - prompt for it, choose it, see query; change variant, change on-screen, re-query } diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/SavedViewsTest.java similarity index 59% rename from src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java rename to src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/SavedViewsTest.java index e3fc8ad..17c37fb 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/SavedFiltersTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/query/SavedViewsTest.java @@ -1,6 +1,6 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. Kingsrook, LLC + * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ @@ -19,24 +19,24 @@ * along with this program. If not, see . */ -package com.kingsrook.qqq.materialdashboard.tests; +package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest; -import com.kingsrook.qqq.materialdashboard.lib.javalin.CapturedContext; -import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext; +import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import org.junit.jupiter.api.Test; -import org.openqa.selenium.By; -import static com.kingsrook.qqq.materialdashboard.tests.QueryScreenTest.addQueryFilterInput; +import org.openqa.selenium.WebElement; import static org.junit.jupiter.api.Assertions.assertTrue; /******************************************************************************* - ** Test for Saved Filters functionality on the Query screen. + ** Test for Saved View functionality on the Query screen. *******************************************************************************/ -public class SavedFiltersTest extends QBaseSeleniumTest +public class SavedViewsTest extends QBaseSeleniumTest { /******************************************************************************* @@ -69,8 +69,11 @@ public class SavedFiltersTest extends QBaseSeleniumTest @Test void testNavigatingBackAndForth() { - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person", "Person"); - qSeleniumLib.waitForSelectorContaining("BUTTON", "Saved Filters").click(); + QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib); + + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person"); + + qSeleniumLib.waitForSelectorContaining("BUTTON", "Views").click(); qSeleniumLib.waitForSelectorContaining("LI", "Some People"); //////////////////////////////////////// @@ -79,46 +82,48 @@ public class SavedFiltersTest extends QBaseSeleniumTest qSeleniumJavalin.stop(); qSeleniumJavalin.clearRoutes(); addStandardRoutesForThisTest(qSeleniumJavalin); - qSeleniumJavalin.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init-id=2.json"); + qSeleniumJavalin.withRouteToFile("/processes/querySavedView/init", "processes/querySavedView/init-id=2.json"); qSeleniumJavalin.restart(); - /////////////////////////////////////////////////////// - // go to a specific filter - assert that it's loaded // - /////////////////////////////////////////////////////// + ///////////////////////////////////////////////////// + // go to a specific view - assert that it's loaded // + ///////////////////////////////////////////////////// qSeleniumLib.waitForSelectorContaining("LI", "Some People").click(); - qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedFilter/2")); - qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People"); + qSeleniumLib.waitForCondition("Current URL should have view id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2")); + queryScreenLib.assertSavedViewNameOnScreen("Some People"); ////////////////////////////// // click into a view screen // ////////////////////////////// qSeleniumLib.waitForSeconds(1); // wait for the filters menu to fully disappear? if this doesn't work, try a different word to look for... - qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click(); + queryScreenLib.waitForDataGridCellContaining("jdoe@kingsrook.com").click(); qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe"); - ///////////////////////////////////////////////////// - // take breadcrumb back to table query // - // assert the previously selected filter is loaded // - ///////////////////////////////////////////////////// + /////////////////////////////////////////////////// + // take breadcrumb back to table query // + // assert the previously selected View is loaded // + /////////////////////////////////////////////////// qSeleniumLib.waitForSelectorContaining("A", "Person").click(); - qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedFilter/2")); - qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People"); - qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1"); + qSeleniumLib.waitForCondition("Current URL should have View id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2")); + queryScreenLib.assertSavedViewNameOnScreen("Some People"); + queryScreenLib.assertQuickFilterButtonIndicatesActiveFilter("firstName"); ////////////////////// // modify the query // ////////////////////// - qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click(); - addQueryFilterInput(qSeleniumLib, 1, "First Name", "contains", "Jam", "Or"); - qSeleniumLib.waitForSelectorContaining("H3", "Person").click(); - qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People") - .findElement(By.cssSelector("CIRCLE")); - qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "2"); + queryScreenLib.clickQuickFilterButton("lastName"); + WebElement valueInput = qSeleniumLib.waitForSelector(".filterValuesColumn INPUT"); + valueInput.click(); + valueInput.sendKeys("Kelkhoff"); + qSeleniumLib.waitForMillis(100); + + qSeleniumLib.clickBackdrop(); + qSeleniumLib.waitForSelectorContaining("DIV", "Unsaved Changes"); ////////////////////////////// // click into a view screen // ////////////////////////////// - qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click(); + queryScreenLib.waitForDataGridCellContaining("jdoe@kingsrook.com").click(); qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe"); /////////////////////////////////////////////////////////////////////////////// @@ -127,37 +132,26 @@ public class SavedFiltersTest extends QBaseSeleniumTest /////////////////////////////////////////////////////////////////////////////// qSeleniumJavalin.beginCapture(); qSeleniumLib.waitForSelectorContaining("A", "Person").click(); - qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedFilter/2")); - qSeleniumLib.waitForSelectorContaining("DIV", "Current Filter: Some People") - .findElement(By.cssSelector("CIRCLE")); - qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "2"); + qSeleniumLib.waitForCondition("Current URL should have filter id", () -> driver.getCurrentUrl().endsWith("/person/savedView/2")); + queryScreenLib.assertSavedViewNameOnScreen("Some People"); + qSeleniumLib.waitForSelectorContaining("DIV", "Unsaved Changes"); CapturedContext capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); - assertTrue(capturedContext.getBody().contains("Jam")); + assertTrue(capturedContext.getBody().contains("Kelkhoff")); qSeleniumJavalin.endCapture(); - //////////////////////////////////////////////////// - // navigate to the table with a filter in the URL // - //////////////////////////////////////////////////// + ////////////////////////////////////////////////// + // navigate to the table with a View in the URL // + ////////////////////////////////////////////////// String filter = """ - { - "criteria": - [ - { - "fieldName": "id", - "operator": "LESS_THAN", - "values": [10] - } - ] - } + {"criteria":[{"fieldName":"id", "operator":"LESS_THAN", "values":[10]}]} """.replace('\n', ' ').replaceAll(" ", ""); - qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8), "Person"); - qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1"); - qSeleniumLib.waitForSelectorContainingToNotExist("DIV", "Current Filter"); + qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8), "Person"); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Save View As"); ////////////////////////////// // click into a view screen // ////////////////////////////// - qSeleniumLib.waitForSelectorContaining("DIV.MuiDataGrid-cell", "jdoe@kingsrook.com").click(); + queryScreenLib.waitForDataGridCellContaining("jdoe@kingsrook.com").click(); qSeleniumLib.waitForSelectorContaining("H5", "Viewing Person: John Doe"); ///////////////////////////////////////////////////////////////////////////////// @@ -166,8 +160,8 @@ public class SavedFiltersTest extends QBaseSeleniumTest ///////////////////////////////////////////////////////////////////////////////// qSeleniumJavalin.beginCapture(); qSeleniumLib.waitForSelectorContaining("A", "Person").click(); - qSeleniumLib.waitForCondition("Current URL should not have filter id", () -> !driver.getCurrentUrl().endsWith("/person/savedFilter/2")); - qSeleniumLib.waitForSelectorContaining(".MuiBadge-badge", "1"); + qSeleniumLib.waitForCondition("Current URL should not have filter id", () -> !driver.getCurrentUrl().endsWith("/person/savedView/2")); + qSeleniumLib.waitForSelectorContaining("BUTTON", "Save View As"); capturedContext = qSeleniumJavalin.waitForCapturedPath("/data/person/query"); assertTrue(capturedContext.getBody().matches("(?s).*id.*LESS_THAN.*10.*")); qSeleniumJavalin.endCapture(); diff --git a/src/test/resources/fixtures/data/person/possibleValues/homeCityId=1.json b/src/test/resources/fixtures/data/person/possibleValues/homeCityId=1.json new file mode 100644 index 0000000..8fc7cd6 --- /dev/null +++ b/src/test/resources/fixtures/data/person/possibleValues/homeCityId=1.json @@ -0,0 +1,8 @@ +{ + "options": [ + { + "id": 1, + "label": "St. Louis" + } + ] +} \ No newline at end of file diff --git a/src/test/resources/fixtures/metaData/index.json b/src/test/resources/fixtures/metaData/index.json index 4c076f8..9fb571f 100644 --- a/src/test/resources/fixtures/metaData/index.json +++ b/src/test/resources/fixtures/metaData/index.json @@ -223,21 +223,21 @@ "label": "Sleep Interactive", "isHidden": false }, - "querySavedFilter": { - "name": "querySavedFilter", - "label": "Query Saved Filter", + "querySavedView": { + "name": "querySavedView", + "label": "Query Saved View", "isHidden": false, "hasPermission": true }, - "storeSavedFilter": { - "name": "storeSavedFilter", - "label": "Store Saved Filter", + "storeSavedView": { + "name": "storeSavedView", + "label": "Store Saved View", "isHidden": false, "hasPermission": true }, - "deleteSavedFilter": { - "name": "deleteSavedFilter", - "label": "Delete Saved Filter", + "deleteSavedView": { + "name": "deleteSavedView", + "label": "Delete Saved View", "isHidden": false, "hasPermission": true }, diff --git a/src/test/resources/fixtures/processes/querySavedFilter/init-id=2.json b/src/test/resources/fixtures/processes/querySavedView/init-id=2.json similarity index 61% rename from src/test/resources/fixtures/processes/querySavedFilter/init-id=2.json rename to src/test/resources/fixtures/processes/querySavedView/init-id=2.json index fe19313..b6dd197 100644 --- a/src/test/resources/fixtures/processes/querySavedFilter/init-id=2.json +++ b/src/test/resources/fixtures/processes/querySavedView/init-id=2.json @@ -1,16 +1,16 @@ { "values": { "_qStepTimeoutMillis": "60000", - "savedFilterList": [ + "savedViewList": [ { - "tableName": "savedFilter", + "tableName": "savedView", "values": { "label": "Some People", "id": 2, "createDate": "2023-02-20T18:40:58Z", "modifyDate": "2023-02-20T18:40:58Z", "tableName": "person", - "filterJson": "{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}", + "viewJson": "{\"queryFilter\":{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}}", "userId": "darin.kelkhoff@kingsrook.com" } } diff --git a/src/test/resources/fixtures/processes/querySavedFilter/init.json b/src/test/resources/fixtures/processes/querySavedView/init.json similarity index 63% rename from src/test/resources/fixtures/processes/querySavedFilter/init.json rename to src/test/resources/fixtures/processes/querySavedView/init.json index 23b8f4a..fd8404b 100644 --- a/src/test/resources/fixtures/processes/querySavedFilter/init.json +++ b/src/test/resources/fixtures/processes/querySavedView/init.json @@ -1,28 +1,28 @@ { "values": { "_qStepTimeoutMillis": "60000", - "savedFilterList": [ + "savedViewList": [ { - "tableName": "savedFilter", + "tableName": "savedView", "values": { "label": "All People", "id": 1, "createDate": "2023-02-20T18:39:11Z", "modifyDate": "2023-02-20T18:39:11Z", "tableName": "person", - "filterJson": "{\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}", + "viewJson": "{\"queryFilter\":{\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}}", "userId": "darin.kelkhoff@kingsrook.com" } }, { - "tableName": "savedFilter", + "tableName": "savedView", "values": { "label": "Some People", "id": 2, "createDate": "2023-02-20T18:40:58Z", "modifyDate": "2023-02-20T18:40:58Z", "tableName": "person", - "filterJson": "{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}", + "viewJson": "{\"queryFilter\":{\"criteria\":[{\"fieldName\":\"firstName\",\"operator\":\"STARTS_WITH\",\"values\":[\"D\"]}],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":false}],\"booleanOperator\":\"AND\"}}", "userId": "darin.kelkhoff@kingsrook.com" } }